import type {
  DeletedObjectJSON,
  OrganizationJSON,
  OrganizationMembershipJSON,
  UserDeletedJSON,
  UserJSON,
} from "@clerk/backend";
import { verifyWebhook, type WebhookEvent } from "@clerk/backend/webhooks";
import type { SdpEnvironment } from "@sdp/types";
import type { RampProviderId } from "@sdp/types/provider-access";
import type { Context } from "hono";
import { getDb } from "@/db";
import { mapClerkRoleToOrgRole } from "@/lib/clerk-role";
import { AppError, badRequest } from "@/lib/errors";
import { invitationWasRevoked } from "@/lib/invitations";
import { success } from "@/lib/response";
import { getLogger } from "@/runtime/logger";
import {
  ensureClerkOrganizationMapping,
  findClerkOrganizationMapping,
  syncClerkOrganization,
} from "@/services/clerk-organization-provisioning.service";
import {
  ClerkUsersService,
  verifiedPrimaryEmailFromClerkUser,
} from "@/services/clerk-users.service";
import { ProjectService } from "@/services/project.service";
import { SessionService } from "@/services/session.service";
import type { Env } from "@/types/env";
import { BvnkWebhookProcessor } from "./ramps/bvnk";
import { CoinbaseWebhookProcessor } from "./ramps/coinbase";
import { LightsparkWebhookProcessor } from "./ramps/lightspark";
import { MoonpayWebhookProcessor } from "./ramps/moonpay";
import { MuralWebhookProcessor } from "./ramps/mural";
import type { WebhookProcessor } from "./ramps/processor";
import { StripeWebhookProcessor } from "./ramps/stripe";

type AppContext = Context<{ Bindings: Env }>;

const RAMP_PROVIDER_WEBHOOK_PROCESSOR = {
  moonpay: new MoonpayWebhookProcessor(),
  lightspark: new LightsparkWebhookProcessor(),
  bvnk: new BvnkWebhookProcessor(),
  coinbase: new CoinbaseWebhookProcessor(),
  mural: new MuralWebhookProcessor(),
  stripe: new StripeWebhookProcessor(),
} as const satisfies Record<
  Exclude<RampProviderId, "moneygram">,
  WebhookProcessor<unknown, unknown>
>;

type WebhookRampProvider = keyof typeof RAMP_PROVIDER_WEBHOOK_PROCESSOR;

function parseRampWebhookProvider(value: string | undefined): WebhookRampProvider {
  if (value !== undefined && Object.hasOwn(RAMP_PROVIDER_WEBHOOK_PROCESSOR, value)) {
    return value as WebhookRampProvider;
  }

  throw badRequest("Unsupported ramp webhook provider");
}

async function findOrganizationMapping(c: AppContext, clerkOrgId: string) {
  const mapping = await findClerkOrganizationMapping(getDb(c.env), clerkOrgId);
  return mapping ? { organization_id: mapping.organizationId, slug: mapping.slug } : null;
}

async function ensureOrganizationMapping(c: AppContext, org: OrganizationJSON): Promise<string> {
  const mapping = await ensureClerkOrganizationMapping({
    env: c.env,
    db: getDb(c.env),
    organization: org,
  });
  return mapping.organizationId;
}

async function syncOrganization(c: AppContext, data: OrganizationJSON) {
  await syncClerkOrganization({
    env: c.env,
    db: getDb(c.env),
    organization: data,
  });
}

async function deleteOrganization(c: AppContext, data: DeletedObjectJSON) {
  if (!data.id) {
    return;
  }

  const mapping = await findOrganizationMapping(c, data.id);
  if (!mapping) {
    return;
  }

  await getDb(c.env).batch([
    getDb(c.env)
      .prepare(
        `UPDATE organizations
         SET status = 'deleted', updated_at = datetime('now')
         WHERE id = ?`
      )
      .bind(mapping.organization_id),
    getDb(c.env)
      .prepare("UPDATE organization_members SET status = 'removed' WHERE organization_id = ?")
      .bind(mapping.organization_id),
    getDb(c.env)
      .prepare(
        `UPDATE api_keys
         SET status = 'revoked', revoked_at = datetime('now')
         WHERE organization_id = ? AND status = 'active'`
      )
      .bind(mapping.organization_id),
  ]);

  const sessionService = new SessionService(getDb(c.env));
  await sessionService
    .revokeOrganizationSessions(mapping.organization_id)
    .catch((error) =>
      getLogger().error({ error }, "Failed to revoke sessions after organization deletion")
    );
}

async function resolveVerifiedUserEmail(env: Env, userId: string): Promise<string | null> {
  const user = await new ClerkUsersService(env).getUser(userId);
  return verifiedPrimaryEmailFromClerkUser(user);
}

async function ensureUserMapping(
  c: AppContext,
  user: { id: string; email: string; name?: string | null }
): Promise<string> {
  const db = getDb(c.env);
  const email = user.email.toLowerCase();
  const existing = await db
    .prepare(
      `SELECT aui.user_id, u.email
       FROM auth_user_identities aui
       JOIN users u ON u.id = aui.user_id
       WHERE aui.provider = 'clerk' AND aui.provider_user_id = ?`
    )
    .bind(user.id)
    .first<{ user_id: string; email: string }>();

  if (existing?.user_id) {
    const updates = ["status = 'active'"];
    const params: (string | null)[] = [];
    let identityEmail = existing.email;

    const owner = await db
      .prepare("SELECT id FROM users WHERE email = ?")
      .bind(email)
      .first<{ id: string }>();

    if (!owner || owner.id === existing.user_id) {
      updates.push("email = ?");
      params.push(email);
      identityEmail = email;
    }

    if (user.name) {
      updates.push("name = ?");
      params.push(user.name);
    }

    params.push(existing.user_id);
    await db.batch([
      db.prepare(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).bind(...params),
      db
        .prepare(
          `UPDATE auth_user_identities
           SET email = ?, updated_at = datetime('now')
           WHERE provider = 'clerk' AND provider_user_id = ?`
        )
        .bind(identityEmail, user.id),
    ]);

    return existing.user_id;
  }

  const localUser = await db
    .prepare("SELECT id FROM users WHERE email = ?")
    .bind(email)
    .first<{ id: string }>();
  const userId = localUser?.id ?? `usr_${crypto.randomUUID()}`;

  if (!localUser) {
    await db
      .prepare(
        `INSERT INTO users (id, email, name, email_verified, status)
         VALUES (?, ?, ?, 1, 'active')`
      )
      .bind(userId, email, user.name)
      .run();
  }

  await db
    .prepare(
      `INSERT INTO auth_user_identities (id, provider, provider_user_id, user_id, email)
       VALUES (?, 'clerk', ?, ?, ?)
       ON CONFLICT (provider, provider_user_id)
       DO UPDATE SET user_id = EXCLUDED.user_id, email = EXCLUDED.email, updated_at = datetime('now')`
    )
    .bind(`aui_${crypto.randomUUID()}`, user.id, userId, email)
    .run();

  return userId;
}

async function syncUser(c: AppContext, data: UserJSON) {
  const email = verifiedPrimaryEmailFromClerkUser(data);
  if (!email) {
    return;
  }
  const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ").trim();
  const userId = await ensureUserMapping(c, {
    id: data.id,
    email,
    name: fullName || data.username,
  });
  const memberships = await new ClerkUsersService(c.env).getOrganizationMemberships(data.id);
  await Promise.all(
    memberships.map((membership) =>
      upsertVerifiedMembership(c, {
        organization: membership.organization,
        userId,
        role: membership.role,
        reactivateRemoved: false,
      })
    )
  );
}

async function deleteUser(c: AppContext, data: UserDeletedJSON) {
  if (!data.id) {
    return;
  }

  const identity = await getDb(c.env)
    .prepare(
      `SELECT user_id
       FROM auth_user_identities
       WHERE provider = 'clerk' AND provider_user_id = ?`
    )
    .bind(data.id)
    .first<{ user_id: string }>();

  if (!identity) {
    return;
  }

  await getDb(c.env).batch([
    getDb(c.env).prepare("UPDATE users SET status = 'deleted' WHERE id = ?").bind(identity.user_id),
    getDb(c.env)
      .prepare("UPDATE organization_members SET status = 'removed' WHERE user_id = ?")
      .bind(identity.user_id),
  ]);

  const sessionService = new SessionService(getDb(c.env));
  await sessionService
    .revokeAllUserSessions(identity.user_id)
    .catch((error) =>
      getLogger().error({ error }, "Failed to revoke sessions after user deletion")
    );
}

/** The address the revoked-invitation rule is keyed on. */
async function resolveMemberEmail(c: AppContext, userId: string): Promise<string | null> {
  const user = await getDb(c.env)
    .prepare("SELECT email FROM users WHERE id = ?")
    .bind(userId)
    .first<{ email: string }>();

  return user?.email ?? null;
}

async function upsertVerifiedMembership(
  c: AppContext,
  data: {
    organization: OrganizationJSON;
    userId: string;
    role: string;
    reactivateRemoved: boolean;
  }
) {
  const organizationId = await ensureOrganizationMapping(c, data.organization);
  const role = mapClerkRoleToOrgRole(data.role);
  const memberId = `mem_${crypto.randomUUID()}`;
  const existing = await getDb(c.env)
    .prepare(
      `SELECT role, status
       FROM organization_members
       WHERE organization_id = ? AND user_id = ?`
    )
    .bind(organizationId, data.userId)
    .first<{ role: string; status: string }>();

  const email = await resolveMemberEmail(c, data.userId);

  // The check and the write share one transaction, and the check locks the
  // invitation rows. Reading the status and inserting as separate statements
  // let a revocation commit in between and still admit the member.
  const admitted = await getDb(c.env).transaction(async (tx) => {
    if (
      existing?.status !== "active" &&
      email &&
      (await invitationWasRevoked(tx, organizationId, email, { lock: true }))
    ) {
      // Clerk says join, we say the invitation was withdrawn. Clerk mints the
      // acceptance link and we cannot expire it, so this sync is the last point
      // that can honour the revocation — without this, revoking an invitation
      // stopped the local token but not the Clerk link.
      //
      // Scoped to users who are not already active members, so this can only
      // decline a join, never strip access from someone who has it. A revoked
      // invitation for an existing member is stale and irrelevant.
      return false;
    }

    await tx
      .prepare(
        `INSERT INTO organization_members (id, organization_id, user_id, role, status)
       VALUES (?, ?, ?, ?, 'active')
       ON CONFLICT(organization_id, user_id)
       DO UPDATE SET
         role = excluded.role,
         status = CASE
           WHEN organization_members.status = 'removed' AND ? = 0
             THEN organization_members.status
           ELSE 'active'
         END`
      )
      .bind(memberId, organizationId, data.userId, role, data.reactivateRemoved ? 1 : 0)
      .run();

    return true;
  });

  if (!admitted) {
    getLogger().warn(
      {
        requestId: c.get("requestId"),
        organizationId,
        userId: data.userId,
      },
      "webhooks: declined membership for a revoked invitation"
    );
    return;
  }

  if (existing?.status === "active" && existing.role !== role) {
    const sessionService = new SessionService(getDb(c.env));
    await sessionService
      .revokeUserOrganizationSessions(data.userId, organizationId)
      .catch((error) =>
        getLogger().error({ error }, "Failed to revoke sessions after membership role change")
      );
  }

  const projectService = new ProjectService(getDb(c.env));
  await Promise.all([
    projectService.findOrCreateDefault(organizationId, "sandbox", data.userId),
    projectService.findOrCreateDefault(organizationId, "production", data.userId),
  ]);
}

async function upsertMembership(c: AppContext, data: OrganizationMembershipJSON) {
  const email = await resolveVerifiedUserEmail(c.env, data.public_user_data.user_id);
  if (!email) {
    return;
  }
  const userId = await ensureUserMapping(c, {
    id: data.public_user_data.user_id,
    email,
  });
  await upsertVerifiedMembership(c, {
    organization: data.organization,
    userId,
    role: data.role,
    reactivateRemoved: true,
  });
}

async function deleteMembership(c: AppContext, data: OrganizationMembershipJSON) {
  const mapping = await findOrganizationMapping(c, data.organization.id);
  if (!mapping) {
    return;
  }

  const identity = await getDb(c.env)
    .prepare(
      `SELECT user_id
       FROM auth_user_identities
       WHERE provider = 'clerk' AND provider_user_id = ?`
    )
    .bind(data.public_user_data.user_id)
    .first<{ user_id: string }>();

  if (!identity) {
    return;
  }

  await getDb(c.env)
    .prepare(
      `UPDATE organization_members
       SET status = 'removed'
       WHERE organization_id = ? AND user_id = ?`
    )
    .bind(mapping.organization_id, identity.user_id)
    .run();

  const sessionService = new SessionService(getDb(c.env));
  await sessionService
    .revokeUserOrganizationSessions(identity.user_id, mapping.organization_id)
    .catch((error) =>
      getLogger().error({ error }, "Failed to revoke sessions after membership deletion")
    );
}

export const handleRampProviderWebhook = async (c: AppContext, environment: SdpEnvironment) => {
  const provider = parseRampWebhookProvider(c.req.param("provider"));
  const rawBody = await c.req.raw.text();
  const processor: WebhookProcessor<unknown, unknown> = RAMP_PROVIDER_WEBHOOK_PROCESSOR[provider];

  const payload = await processor.verify({
    env: c.env as unknown as Record<string, string | undefined>,
    environment,
    headers: c.req.raw.headers,
    rawBody,
    requestUrl: c.req.url,
  });
  const event = processor.parse(payload);

  // Signature is verified, so ack with 200 immediately and settle in the background: a
  // slow DB write must not delay the 2xx the provider expects.
  // TODO(ramps): until the reconciliation cron lands, this background pass is the only
  // path that settles a transfer. The cron will reconcile any transaction left in a
  // non-terminal state here (e.g. background processing that failed).
  c.executionCtx.waitUntil(
    processor.process(c, environment, event).catch((error) =>
      getLogger().error(
        {
          provider: processor.provider,
          error: error instanceof Error ? error.message : String(error),
        },
        "[ramp webhook] background processing failed"
      )
    )
  );

  return success(c, {
    received: true,
    provider: processor.provider,
    environment,
  });
};

export const handleClerkWebhook = async (c: AppContext) => {
  const secret = c.env.CLERK_WEBHOOK_SECRET;
  if (!secret) {
    throw new AppError("INTERNAL_ERROR", "CLERK_WEBHOOK_SECRET is required");
  }

  let event: WebhookEvent;
  try {
    event = await verifyWebhook(c.req.raw, { signingSecret: secret });
  } catch (err) {
    throw new AppError("UNAUTHORIZED", "Invalid webhook signature", {
      cause: err instanceof Error ? err.message : String(err),
    });
  }

  switch (event.type) {
    case "organization.created":
    case "organization.updated":
      await syncOrganization(c, event.data);
      break;
    case "organization.deleted":
      await deleteOrganization(c, event.data);
      break;
    case "user.created":
    case "user.updated":
      await syncUser(c, event.data);
      break;
    case "user.deleted":
      await deleteUser(c, event.data);
      break;
    case "organizationMembership.created":
    case "organizationMembership.updated":
      await upsertMembership(c, event.data);
      break;
    case "organizationMembership.deleted":
      await deleteMembership(c, event.data);
      break;
    default:
      break;
  }

  return success(c, { received: true });
};
