/**
 * Profile creation — how an authenticated person becomes a PIG user.
 *
 * This closes the gap between the two halves of access control. Signing in
 * with the identity provider proves who someone is; it does not make them a
 * member of this workspace. This route is the deliberate step in between,
 * and it is the only way a `users` row is created for a real person.
 *
 * Two ways to pass:
 *
 *   1. **A valid invite code.** The normal path. Codes are stored hashed, can
 *      be pinned to an address, can expire, and are consumed on redemption.
 *
 *   2. **Being listed in `PIG_ADMIN_EMAILS`.** The bootstrap path, which
 *      exists to solve the first-administrator problem: on a fresh deployment
 *      nobody can issue an invite because nobody can sign in to issue one.
 *
 * The bootstrap path is the dangerous one, so it is narrow by construction:
 * the address must be listed in configuration by whoever controls the server,
 * AND must match the verified `email` claim on the token. Listing an address
 * nobody owns is a standing offer of admin rights to whoever registers it
 * first — which is why the deployment docs say every entry must already have
 * an account, and why the server warns at boot.
 */
import { Hono } from 'hono';
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { createHash } from 'node:crypto';
import type { Database } from '@pig/db';
import { invites, teamMemberships, users } from '@pig/db';
import { TEAMS } from '@pig/core';
import type { Config } from '../lib/config';
import type { AuthProvider } from '../lib/auth-provider';

const bodySchema = z.object({
  name: z.string().min(1).max(120),
  team: z.enum(TEAMS),
  inviteCode: z.string().min(1).max(200).optional(),
  title: z.string().max(160).optional(),
});

export function createSignupRoute(
  config: Config,
  db: Database,
  authProvider: AuthProvider | null,
) {
  const app = new Hono();

  app.post('/api/signup', async (c) => {
    const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({})));
    if (!parsed.success) {
      return c.json({ error: 'Invalid profile', issues: parsed.error.issues }, 400);
    }

    // Identity must be proven before anything else is considered.
    const header = c.req.header('authorization');
    if (!header?.startsWith('Bearer ')) {
      return c.json({ error: 'Sign in first.', code: 'no_token' }, 401);
    }

    let subject: string;
    let email: string;

    if (!authProvider) {
      // Development only. `loadConfig` refuses to start in production without
      // an identity provider, so this branch cannot exist in a real deployment.
      if (config.isProduction) {
        return c.json({ error: 'Authentication is not configured.' }, 500);
      }
      subject = '00000000-0000-0000-0000-000000000000';
      email = 'dev@localhost';
    } else {
      try {
        const identity = await authProvider.verifyAccessToken(header.slice(7).trim());
        if (!identity.subject || typeof identity.email !== 'string') {
          throw new Error('token missing subject or email');
        }
        subject = identity.subject;
        email = identity.email.toLowerCase();
      } catch {
        return c.json({ error: 'Invalid or expired token.', code: 'invalid_token' }, 401);
      }
    }

    // Already a member? Return the existing row rather than erroring — a
    // double-submitted form should be harmless.
    const [existing] = await db
      .select()
      .from(users)
      .where(or(eq(users.authSubject, subject), eq(users.email, email)))
      .limit(1);

    if (existing) {
      // An invited-but-never-signed-in row is claimed here, binding it to the
      // identity that just proved ownership of the address.
      if (!existing.authSubject) {
        const [claimed] = await db
          .update(users)
          .set({ authSubject: subject, updatedAt: new Date() })
          .where(eq(users.id, existing.id))
          .returning();
        return c.json({ user: claimed, claimed: true });
      }
      return c.json({ user: existing, alreadyMember: true });
    }

    const isBootstrapAdmin = config.adminEmails.includes(email);

    // Resolve the invite before writing anything, so a failed redemption
    // leaves no partial user behind.
    let inviteId: string | null = null;
    if (!isBootstrapAdmin) {
      if (!parsed.data.inviteCode) {
        return c.json(
          { error: 'An invite code is required to join this workspace.', code: 'invite_required' },
          403,
        );
      }

      const codeHash = createHash('sha256')
        .update(parsed.data.inviteCode.trim())
        .digest('hex');

      // Availability is decided by `usesRemaining`, NOT by `redeemedAt`.
      // A shared workspace code records who redeemed it most recently while
      // remaining valid for the next person; keying off `redeemedAt` would
      // make every reusable invite single-use, which is a confusing way to
      // lock a team out.
      const [invite] = await db
        .select()
        .from(invites)
        .where(
          and(
            eq(invites.codeHash, codeHash),
            isNull(invites.revokedAt),
            gt(invites.usesRemaining, 0),
          ),
        )
        .limit(1);

      if (!invite) {
        return c.json({ error: 'That invite code is not valid.', code: 'invalid_invite' }, 403);
      }
      if (invite.expiresAt && invite.expiresAt < new Date()) {
        return c.json({ error: 'That invite code has expired.', code: 'expired_invite' }, 403);
      }
      // A pinned invite may only be redeemed by the address it names.
      if (invite.email && invite.email.toLowerCase() !== email) {
        return c.json(
          { error: 'That invite code was issued to a different address.', code: 'invite_mismatch' },
          403,
        );
      }
      inviteId = invite.id;
    }

    const [created] = await db
      .insert(users)
      .values({
        authSubject: subject,
        email,
        name: parsed.data.name,
        title: parsed.data.title,
        // Admin rights are NOT taken from the request. They come from the
        // server's own configuration, so a crafted payload cannot grant them.
        isPlatformAdmin: isBootstrapAdmin,
      })
      .returning();

    if (!created) return c.json({ error: 'Could not create the profile.' }, 500);

    await db
      .insert(teamMemberships)
      .values({
        userId: created.id,
        team: parsed.data.team,
        role: isBootstrapAdmin ? 'admin' : 'member',
        isPrimary: true,
      })
      .onConflictDoNothing();

    if (inviteId) {
      // Consume the invite. Decrementing rather than deleting keeps the audit
      // trail of who redeemed what.
      await db
        .update(invites)
        .set({
          redeemedByUserId: created.id,
          redeemedAt: new Date(),
          usesRemaining: sql`GREATEST(0, ${invites.usesRemaining} - 1)`,
        })
        .where(eq(invites.id, inviteId));
    }

    return c.json({ user: created, bootstrapAdmin: isBootstrapAdmin }, 201);
  });

  return app;
}
