/**
 * Clerk Authentication Middleware (Option A)
 *
 * Verifies Clerk JWTs, maps external identities to internal users/orgs,
 * and sets a Clerk auth context for downstream handlers.
 */

import {
  getPermissionsForOrgRole,
  normalizeOrganizationRole,
  type OrganizationRole,
} from "@sdp/types";
import type { Context, Next } from "hono";
import { getDb } from "@/db";
import { mapClerkRoleToOrgRole } from "@/lib/clerk-role";
import {
  type ClerkJwtPayload,
  extractBearerToken,
  isPlausibleEmail,
  resolveClerkEmail,
  verifyClerkJwtForRequest,
} from "@/lib/clerk-token";
import { AppError, unauthorized } from "@/lib/errors";
import { invitationWasRevoked } from "@/lib/invitations";
import { ensureClerkOrganizationMapping } from "@/services/clerk-organization-provisioning.service";
import { ClerkOrganizationsService } from "@/services/clerk-organizations.service";
import {
  ClerkUsersService,
  verifiedPrimaryEmailFromClerkUser,
} from "@/services/clerk-users.service";
import { ProjectService } from "@/services/project.service";
import type { Env } from "@/types/env";

/**
 * The stored identity for a Clerk user, with both copies of the email.
 *
 * They are read separately rather than COALESCEd in SQL because either copy can hold an
 * unsubstituted JWT-template placeholder, and a plain COALESCE prefers whichever is
 * non-null — including the corrupted one. Which copy is good depends on the code path
 * that wrote the row, so the caller picks the first that is actually an address.
 */
async function resolveClerkUser(db: DatabaseClient, clerkUserId: string) {
  return db
    .prepare(
      `SELECT aui.user_id, aui.email AS identity_email, u.email AS user_email
       FROM auth_user_identities aui
       LEFT JOIN users u ON u.id = aui.user_id
       WHERE aui.provider = 'clerk' AND aui.provider_user_id = ?`
    )
    .bind(clerkUserId)
    .first<{ user_id: string; identity_email: string | null; user_email: string | null }>();
}

async function resolveClerkOrganization(db: DatabaseClient, clerkOrgId: string) {
  return db
    .prepare(
      `SELECT organization_id, slug
       FROM auth_organization_identities
       WHERE provider = 'clerk' AND provider_org_id = ?`
    )
    .bind(clerkOrgId)
    .first<{ organization_id: string; slug: string | null }>();
}

/**
 * Both copies of the email are read separately here for the same reason as
 * {@link resolveClerkUser}: a COALESCE prefers whichever copy is non-null, and the
 * corrupted one is a placeholder string rather than a NULL. This is the path taken for an
 * established user, so it is the one a repaired database still has to keep clean.
 */
async function resolveExistingClerkContext(
  db: DatabaseClient,
  params: {
    clerkUserId: string;
    clerkOrgId: string;
    fallbackOrgSlug: string | null;
  }
) {
  return db
    .prepare(
      `SELECT
         aui.user_id,
         aui.email AS identity_email,
         u.email AS user_email,
         aoi.organization_id,
         COALESCE(aoi.slug, ?) AS org_slug,
         om.role,
         EXISTS (
           SELECT 1
           FROM projects p
           JOIN project_members pm
             ON pm.project_id = p.id AND pm.user_id = aui.user_id
           WHERE p.organization_id = aoi.organization_id
             AND p.slug = 'default-sandbox'
             AND p.status = 'active'
         ) AS has_default_sandbox,
         EXISTS (
           SELECT 1
           FROM projects p
           JOIN project_members pm
             ON pm.project_id = p.id AND pm.user_id = aui.user_id
           WHERE p.organization_id = aoi.organization_id
             AND p.slug = 'default-production'
             AND p.status = 'active'
         ) AS has_default_production
       FROM auth_organization_identities aoi
       LEFT JOIN auth_user_identities aui
         ON aui.provider = 'clerk' AND aui.provider_user_id = ?
       LEFT JOIN users u
         ON u.id = aui.user_id
       LEFT JOIN organization_members om
         ON om.user_id = aui.user_id
        AND om.organization_id = aoi.organization_id
        AND om.status = 'active'
       WHERE aoi.provider = 'clerk' AND aoi.provider_org_id = ?
       LIMIT 1`
    )
    .bind(params.fallbackOrgSlug, params.clerkUserId, params.clerkOrgId)
    .first<{
      user_id: string | null;
      identity_email: string | null;
      user_email: string | null;
      organization_id: string;
      org_slug: string | null;
      role: string | null;
      has_default_sandbox: boolean | number;
      has_default_production: boolean | number;
    }>();
}

async function resolveOrgMembership(db: DatabaseClient, userId: string, organizationId: string) {
  return db
    .prepare(
      `SELECT role, status
       FROM organization_members
       WHERE user_id = ? AND organization_id = ?`
    )
    .bind(userId, organizationId)
    .first<{ role: string; status: string }>();
}

/**
 * Writes a known-good email over a stored placeholder.
 *
 * Each copy is repaired only when it is actually broken, so a healthy row does no
 * writes. `users.email` is UNIQUE, so the update is skipped when another row already
 * holds the address — that is a merge decision, not a repair, and matches how
 * migration 0040 leaves colliding rows alone.
 */
async function repairClerkEmails(
  db: DatabaseClient,
  params: {
    userId: string;
    clerkUserId: string;
    email: string;
    identityEmail: string | null;
    userEmail: string | null;
  }
): Promise<void> {
  const { userId, clerkUserId, email, identityEmail, userEmail } = params;

  if (!isPlausibleEmail(userEmail)) {
    await db
      .prepare(
        `UPDATE users SET email = ?
         WHERE id = ?
           AND NOT EXISTS (SELECT 1 FROM users other WHERE other.email = ? AND other.id <> ?)`
      )
      .bind(email, userId, email, userId)
      .run();
  }

  if (!isPlausibleEmail(identityEmail)) {
    await db
      .prepare(
        `UPDATE auth_user_identities SET email = ?, updated_at = sdp_datetime_now()
         WHERE provider = 'clerk' AND provider_user_id = ?`
      )
      .bind(email, clerkUserId)
      .run();
  }
}

async function ensureClerkUser(
  env: Env,
  db: DatabaseClient,
  clerkUserId: string,
  fallbackEmail: string
): Promise<{ userId: string; email: string }> {
  const existing = await resolveClerkUser(db, clerkUserId);
  if (existing) {
    // A stored placeholder is worse than no value: it is byte-identical for every
    // affected user, so it identifies nobody, and it is matched literally against
    // `invitations.email` below — which silently denies an invited role. Skip anything
    // that is not an address and fall through to the other copy.
    const email =
      [existing.identity_email, existing.user_email].find(isPlausibleEmail) ?? fallbackEmail;

    // Repair the corrupted copy from the good one while we are holding both.
    // Clerk's own guidance is that webhook delivery is not guaranteed and a synced
    // table should carry an on-demand fallback, and `user.updated` only fires when
    // something changes in Clerk — so an account corrupted once would otherwise stay
    // corrupted through every future sign-in, and every surface reading `users.email`
    // stays wrong with it. Migration 0040 does the same repair, but only on deploy.
    if (isPlausibleEmail(email)) {
      await repairClerkEmails(db, {
        userId: existing.user_id,
        clerkUserId,
        email,
        identityEmail: existing.identity_email,
        userEmail: existing.user_email,
      });
    }

    return { userId: existing.user_id, email };
  }

  const clerkUser = await new ClerkUsersService(env).getUser(clerkUserId);
  const normalizedEmail = verifiedPrimaryEmailFromClerkUser(clerkUser);
  if (!normalizedEmail) {
    throw unauthorized("Clerk primary email must be verified");
  }
  let user = await db
    .prepare("SELECT id, email FROM users WHERE email = ?")
    .bind(normalizedEmail)
    .first<{ id: string; email: string }>();

  if (!user) {
    const userId = `usr_${crypto.randomUUID()}`;
    await db
      .prepare(
        `INSERT INTO users (id, email, email_verified, status)
         VALUES (?, ?, 1, 'active')
         ON CONFLICT (email) DO NOTHING`
      )
      .bind(userId, normalizedEmail)
      .run();
    user = await db
      .prepare("SELECT id, email FROM users WHERE email = ?")
      .bind(normalizedEmail)
      .first<{ id: string; email: string }>();
  }

  if (!user) {
    throw new AppError("INTERNAL_ERROR", "Unable to resolve Clerk user");
  }

  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 = sdp_datetime_now()`
    )
    .bind(`aui_${crypto.randomUUID()}`, clerkUserId, user.id, normalizedEmail)
    .run();

  return { userId: user.id, email: user.email };
}

async function ensureMembership(
  db: DatabaseClient,
  params: {
    organizationId: string;
    userId: string;
    email: string;
    clerkRole?: string | null;
  }
): Promise<OrganizationRole> {
  const existing = await resolveOrgMembership(db, params.userId, params.organizationId);
  if (existing && existing.status !== "active") {
    throw unauthorized("Organization membership is inactive");
  }
  if (existing) {
    const normalizedRole = normalizeOrganizationRole(existing.role);
    if (normalizedRole !== existing.role) {
      await db
        .prepare(
          `UPDATE organization_members
           SET role = ?
           WHERE user_id = ? AND organization_id = ? AND status = 'active'`
        )
        .bind(normalizedRole, params.userId, params.organizationId)
        .run();
    }
    return normalizedRole;
  }

  const pendingInvite = await db
    .prepare(
      `SELECT id, role, expires_at
       FROM invitations
       WHERE organization_id = ? AND email = ? AND status = 'pending'
       ORDER BY created_at DESC
       LIMIT 1`
    )
    .bind(params.organizationId, params.email.toLowerCase())
    .first<{ id: string; role: string; expires_at: string }>();

  let invitedRole: OrganizationRole | null = null;

  if (pendingInvite) {
    if (new Date(pendingInvite.expires_at) >= new Date()) {
      invitedRole = normalizeOrganizationRole(pendingInvite.role);
    } else {
      await db
        .prepare("UPDATE invitations SET status = 'expired' WHERE id = ?")
        .bind(pendingInvite.id)
        .run();
    }
  }

  const role = invitedRole ?? mapClerkRoleToOrgRole(params.clerkRole);
  const memberId = `mem_${crypto.randomUUID()}`;

  try {
    // The claim and the membership insert have to commit together. Claiming
    // first alone would let a failing insert consume the invitation without
    // granting access, and inserting first would let a concurrent revocation
    // win the invitation transition after access was already persisted.
    await db.transaction(async (tx) => {
      // Reached only when the caller holds no membership row at all — an
      // existing member returned above — so this declines a join and never
      // revokes access someone already has.
      //
      // Without it, revoking an invitation stopped the local token while
      // Clerk's acceptance link stayed live: signing in through that link fell
      // through to the Clerk role and provisioned the membership anyway. Inside
      // the transaction and holding the invitation locks, so a revocation
      // cannot land between this check and the insert below.
      if (
        !invitedRole &&
        (await invitationWasRevoked(tx, params.organizationId, params.email, { lock: true }))
      ) {
        throw unauthorized("Invitation to this organization was revoked");
      }

      if (pendingInvite && invitedRole) {
        // Role normalization folds in here, so there is one transition rather
        // than a read, a role fixup and a later accept.
        const claimed = await tx
          .prepare(
            `UPDATE invitations
                SET status = 'accepted', accepted_at = datetime('now'), role = ?
              WHERE id = ? AND status = 'pending'`
          )
          .bind(invitedRole, pendingInvite.id)
          .run();

        // Zero rows means it stopped being pending between the read and here.
        // Revocation is the case that matters, and granting access off a
        // just-revoked invitation is the failure worth refusing.
        if (claimed === 0) {
          throw unauthorized("Invitation is no longer valid");
        }
      }

      await tx
        .prepare(
          `INSERT INTO organization_members (id, organization_id, user_id, role, status)
             VALUES (?, ?, ?, ?, 'active')`
        )
        .bind(memberId, params.organizationId, params.userId, role)
        .run();
    });
  } catch (error) {
    // A concurrent sign-in may have created the membership first; the rollback
    // released our claim, so deferring to the row that won is correct.
    const existingAfterInsert = await resolveOrgMembership(
      db,
      params.userId,
      params.organizationId
    );
    if (existingAfterInsert?.status === "active") {
      return normalizeOrganizationRole(existingAfterInsert.role);
    }
    if (existingAfterInsert) {
      throw unauthorized("Organization membership is inactive");
    }
    throw error;
  }

  return role;
}

async function ensureDefaultProjects(
  db: DatabaseClient,
  organizationId: string,
  userId: string
): Promise<void> {
  const projectService = new ProjectService(db);
  await Promise.all([
    projectService.findOrCreateDefault(organizationId, "sandbox", userId),
    projectService.findOrCreateDefault(organizationId, "production", userId),
  ]);
}

async function buildClerkContext(c: Context<{ Bindings: Env }>, payload: ClerkJwtPayload) {
  const email = resolveClerkEmail(payload);
  if (!email) {
    throw new AppError(
      "UNAUTHORIZED",
      "Clerk token has no usable email claim. Check the session token customization: an invalid shortcode is passed through unsubstituted rather than resolved."
    );
  }

  const existingContext = await resolveExistingClerkContext(getDb(c.env), {
    clerkUserId: payload.sub as string,
    clerkOrgId: payload.org_id as string,
    fallbackOrgSlug: payload.org_slug ?? null,
  });

  if (existingContext?.organization_id && existingContext.user_id && existingContext.role) {
    const role = normalizeOrganizationRole(existingContext.role);

    if (role !== existingContext.role) {
      await getDb(c.env)
        .prepare(
          `UPDATE organization_members
           SET role = ?
           WHERE user_id = ? AND organization_id = ? AND status = 'active'`
        )
        .bind(role, existingContext.user_id, existingContext.organization_id)
        .run();
    }

    if (!existingContext.has_default_sandbox || !existingContext.has_default_production) {
      await ensureDefaultProjects(
        getDb(c.env),
        existingContext.organization_id,
        existingContext.user_id
      );
    }

    const resolvedEmail =
      [existingContext.identity_email, existingContext.user_email].find(isPlausibleEmail) ??
      email.toLowerCase();

    // This is the branch an established member actually takes, and it returns before
    // `ensureClerkUser` — so repairing only in there left the corrupted population the
    // one population never repaired. It matters beyond the directory: the duplicate
    // guard in `inviteMember` matches `users.email` literally, so a row still holding a
    // placeholder lets an existing member be re-invited. A healthy row issues no writes.
    await repairClerkEmails(getDb(c.env), {
      userId: existingContext.user_id,
      clerkUserId: payload.sub as string,
      email: resolvedEmail,
      identityEmail: existingContext.identity_email,
      userEmail: existingContext.user_email,
    });

    return {
      userId: existingContext.user_id,
      organizationId: existingContext.organization_id,
      permissions: getPermissionsForOrgRole(role),
      role,
      clerkUserId: payload.sub as string,
      clerkOrgId: payload.org_id as string,
      email: resolvedEmail,
      orgSlug: payload.org_slug ?? existingContext.org_slug,
      orgRole: payload.org_role ?? null,
    };
  }

  const [userIdentity, orgIdentity] = await Promise.all([
    ensureClerkUser(c.env, getDb(c.env), payload.sub as string, email),
    resolveClerkOrganization(getDb(c.env), payload.org_id as string),
  ]);

  let resolvedOrgIdentity = orgIdentity;
  if (!resolvedOrgIdentity) {
    const organization = await new ClerkOrganizationsService(c.env).getOrganization(
      payload.org_id as string
    );
    const mapping = await ensureClerkOrganizationMapping({
      env: c.env,
      db: getDb(c.env),
      organization,
    });
    resolvedOrgIdentity = {
      organization_id: mapping.organizationId,
      slug: mapping.slug,
    };
  }

  const role = await ensureMembership(getDb(c.env), {
    organizationId: resolvedOrgIdentity.organization_id,
    userId: userIdentity.userId,
    email: userIdentity.email,
    clerkRole: payload.org_role,
  });
  await ensureDefaultProjects(
    getDb(c.env),
    resolvedOrgIdentity.organization_id,
    userIdentity.userId
  );

  const permissions = getPermissionsForOrgRole(role);

  return {
    userId: userIdentity.userId,
    organizationId: resolvedOrgIdentity.organization_id,
    permissions,
    role,
    clerkUserId: payload.sub as string,
    clerkOrgId: payload.org_id as string,
    email: userIdentity.email,
    orgSlug: payload.org_slug ?? resolvedOrgIdentity.slug,
    orgRole: payload.org_role ?? null,
  };
}

export function clerkAuthMiddleware() {
  return async (c: Context<{ Bindings: Env }>, next: Next) => {
    const token = extractBearerToken(c);

    if (!token) {
      throw unauthorized("Clerk session required");
    }

    let payload: ClerkJwtPayload;
    try {
      payload = await verifyClerkJwtForRequest(c, token);
    } catch (error) {
      throw new AppError("UNAUTHORIZED", "Invalid Clerk token", {
        cause: error instanceof Error ? error.message : String(error),
      });
    }

    if (!payload.sub) {
      throw new AppError("UNAUTHORIZED", "Clerk token missing subject");
    }

    if (!payload.org_id) {
      throw new AppError("UNAUTHORIZED", "Clerk token missing organization");
    }

    const clerkContext = await buildClerkContext(c, payload);
    c.set("clerk", clerkContext);

    await next();
  };
}

export function optionalClerkAuth() {
  return async (c: Context<{ Bindings: Env }>, next: Next) => {
    const token = extractBearerToken(c);

    if (!token) {
      await next();
      return;
    }

    try {
      const payload = await verifyClerkJwtForRequest(c, token);

      if (payload.sub && payload.org_id) {
        const clerkContext = await buildClerkContext(c, payload);
        if (clerkContext) {
          c.set("clerk", clerkContext);
        }
      }
    } catch {
      // Ignore invalid Clerk auth for optional usage
    }

    await next();
  };
}
