/**
 * Authentication and authorization.
 *
 * The distinction is the whole point of this file, and it is the thing most
 * likely to be got wrong by someone extending PIG later:
 *
 *   **Authentication** answers "who is this?" and is delegated to an identity
 *   provider. PIG stores no passwords and issues no sessions of its own.
 *
 *   **Authorization** answers "may they use PIG?" and is answered ONLY by a row
 *   in PIG's `users` table.
 *
 * These must stay separate because the Supabase project may be shared with
 * other applications. A valid token proves someone has an account *somewhere in
 * that project* — not that they belong here. Treating a verified token as
 * sufficient would silently grant every user of every sibling application full
 * access to the CRM.
 *
 * A token with no matching PIG user gets 403 with `needs_profile`, which the
 * front end turns into the invite-redemption screen.
 */
import { eq } from 'drizzle-orm';
import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import {
  permissionGranted,
  resolveReadPermissionGrants,
  resolveWritePermissionGrants,
  roleMeets,
  TEAM_CAPABILITY_RULES,
  type GlobalCapability,
  type PermissionGrant,
  type ReadCapability,
  type Team,
  type TeamCapability,
  type TeamRole,
  type WriteCapability,
} from '@pig/core';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
import type { AuthProvider } from './auth-provider';

export interface Principal {
  userId: string;
  email: string;
  name: string;
  isPlatformAdmin: boolean;
  teams: { team: Team; role: TeamRole }[];
  /** How this request authenticated. Agents get their own audit trail. */
  via: 'jwt' | 'api_key' | 'development';
  apiKeyId?: string;
  scopes: string[];
}

export class AuthError extends Error {
  constructor(
    message: string,
    readonly status: 401 | 403,
    readonly code: string,
  ) {
    super(message);
    this.name = 'AuthError';
  }
}

export function createAuthenticator(
  config: Config,
  db: Database,
  authProvider: AuthProvider | null,
) {
  async function loadPrincipal(
    userId: string,
    via: Principal['via'],
    extras: { apiKeyId?: string; scopes?: string[] } = {},
  ): Promise<Principal> {
    const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
    if (!user) throw new AuthError('No PIG profile for this account.', 403, 'needs_profile');
    if (user.deactivatedAt) throw new AuthError('This account is deactivated.', 403, 'deactivated');

    const memberships = await db
      .select({ team: teamMemberships.team, role: teamMemberships.role })
      .from(teamMemberships)
      .where(eq(teamMemberships.userId, user.id));

    return {
      userId: user.id,
      email: user.email,
      name: user.name,
      // Admin rights come from the database, but the environment allowlist can
      // grant them too — that is how the first admin exists before anyone has
      // been able to log in and promote anybody.
      isPlatformAdmin:
        user.isPlatformAdmin || config.adminEmails.includes(user.email.toLowerCase()),
      teams: memberships as { team: Team; role: TeamRole }[],
      via,
      apiKeyId: extras.apiKeyId,
      scopes: extras.scopes ?? ['read', 'write'],
    };
  }

  return {
    /**
     * Resolve the principal for a request, or throw.
     *
     * Accepts either a Supabase JWT or a PIG API key, both in the
     * Authorization header. API keys exist so that an agent acting for a person
     * is a distinct principal from that person — separately revocable, with its
    * own audit trail and its own scopes.
     */
    async authenticate(header: string | undefined): Promise<Principal> {
      const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null;

      // An explicit PIG key must be honoured even in development. Otherwise a
      // revoked or malformed key silently becomes the development user, which
      // makes local integration tests pass without testing the credential at
      // all and hides the exact failures developers need to see.
      if (token?.startsWith('pig_')) return authenticateApiKey(token);

      // Development escape hatch. Guarded three ways, and `loadConfig` refuses
      // to start in production without identity configuration, so this cannot
      // leak into a real deployment.
      if (!authProvider && !config.isProduction) {
        // Ordered by creation rather than left to the heap. An unordered
        // limit(1) lets Postgres return any row, and the order shifts after an
        // update, so who you are with auth disabled changed between runs — and
        // with it every capability gate on the page. The first seeded user is
        // the stable answer.
        const [devUser] = await db.select().from(users).orderBy(users.createdAt).limit(1);
        if (!devUser) {
          throw new AuthError(
            'Auth is disabled and the database has no users. Run `npm run db:seed`.',
            403,
            'no_dev_user',
          );
        }
        return loadPrincipal(devUser.id, 'development');
      }

      if (!header?.startsWith('Bearer ')) {
        throw new AuthError('Missing bearer token.', 401, 'no_token');
      }

      if (!authProvider) {
        throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
      }

      let subject: string;
      try {
        subject = (await authProvider.verifyAccessToken(token!)).subject;
      } catch {
        // Deliberately opaque: distinguishing "expired" from "malformed" from
        // "wrong issuer" tells an attacker which knob to turn.
        throw new AuthError('Invalid or expired token.', 401, 'invalid_token');
      }

      const [user] = await db
        .select({ id: users.id })
        .from(users)
        .where(eq(users.authSubject, subject))
        .limit(1);

      if (!user) {
        // Authenticated but not authorized — the case that matters when the
        // identity provider is shared with another application.
        throw new AuthError(
          'This account is not a member of this PIG workspace.',
          403,
          'needs_profile',
        );
      }

      return loadPrincipal(user.id, 'jwt');
    },
  };

  async function authenticateApiKey(token: string): Promise<Principal> {
    const hash = hashApiKey(token);
    const [record] = await db
      .select()
      .from(apiKeys)
      .where(eq(apiKeys.keyHash, hash))
      .limit(1);

    if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
    assertApiKeyActive(record);

    // Best-effort last-used stamp. Never block the request on it: a failed
    // bookkeeping write must not deny access.
    void db
      .update(apiKeys)
      .set({ lastUsedAt: new Date() })
      .where(eq(apiKeys.id, record.id))
      .catch(() => {});

    return loadPrincipal(record.userId, 'api_key', {
      apiKeyId: record.id,
      scopes: record.scopes,
    });
  }
}

export function assertApiKeyActive(
  record: { revokedAt: Date | null; expiresAt: Date | null },
  now = new Date(),
): void {
  if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
  if (record.expiresAt && record.expiresAt < now) {
    throw new AuthError('This API key has expired.', 401, 'expired_key');
  }
}

/**
 * Hash an API key for storage and lookup.
 *
 * SHA-256 without a salt is correct here, unlike for passwords: the key is 256
 * bits of machine-generated randomness, so there is no dictionary to attack and
 * lookup must be deterministic. What matters is that the plaintext is never
 * stored, so a database leak yields no working credentials.
 */
export function hashApiKey(key: string): string {
  return createHash('sha256').update(key).digest('hex');
}

/** Constant-time compare, for anywhere a secret is checked directly. */
export function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  // Length alone can leak, so compare hashes of equal length rather than
  // returning early on a length mismatch.
  const ah = createHash('sha256').update(ab).digest();
  const bh = createHash('sha256').update(bb).digest();
  return timingSafeEqual(ah, bh);
}

/** Does this principal belong to the team, at or above the given role? */
export function hasTeamAccess(
  principal: Principal,
  team: Team,
  minimumRole: TeamRole = 'member',
): boolean {
  if (principal.isPlatformAdmin) return true;
  const membership = principal.teams.find((t) => t.team === team);
  if (!membership) return false;
  return roleMeets(membership.role, minimumRole);
}

export function requireScope(principal: Principal, scope: string): void {
  if (principal.scopes.includes(scope)) return;
  throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
}

/**
 * Effective grants include credential scope, not merely the owner's roles.
 *
 * Read and write scopes are filtered separately. Before read capabilities
 * existed a read-only key resolved to no grants at all, which was right then
 * and would now be wrong: it would tell `/api/me` that a read-only agent may
 * not read, and the browser would grey out a page the server happily serves.
 */
export function effectivePermissions(principal: Principal): PermissionGrant[] {
  const grants: PermissionGrant[] = [];
  if (principal.scopes.includes('read')) grants.push(...resolveReadPermissionGrants(principal));
  if (principal.scopes.includes('write')) grants.push(...resolveWritePermissionGrants(principal));
  return grants;
}

export function requireCapability(principal: Principal, capability: GlobalCapability): void;
export function requireCapability(
  principal: Principal,
  capability: TeamCapability,
  team: Team,
): void;
export function requireCapability(
  principal: Principal,
  capability: WriteCapability,
  team?: Team,
): void {
  requireScope(principal, 'write');
  if (permissionGranted(resolveWritePermissionGrants(principal), capability, team)) return;
  throw new AuthError(
    `This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
    403,
    'insufficient_permission',
  );
}

/**
 * "May they do this on *some* team?"
 *
 * Separate from `requireCapability` and deliberately harder to type by
 * accident. Passing no team to the old `requireCapability` silently meant this
 * — which is how a research-team admin could bulk-import demand deals — so the
 * overloads above now refuse it and every remaining any-team check has to say
 * so in its own name. Use it only where no team is knowable yet: listing the
 * spreadsheets in someone's Drive, before an entity has been chosen. The
 * moment the target is known, go back to `requireCapability` with its team.
 */
export function requireAnyTeamCapability(
  principal: Principal,
  capability: TeamCapability,
): void {
  requireScope(principal, 'write');
  const grants = resolveWritePermissionGrants(principal);
  for (const team of TEAM_CAPABILITY_RULES[capability].teams) {
    if (permissionGranted(grants, capability, team)) return;
  }
  throw new AuthError(
    `This principal lacks the '${capability}' capability on any team.`,
    403,
    'insufficient_permission',
  );
}

/**
 * Reads are governed too. The 'read' scope is checked rather than 'write'
 * because a read-only API key is exactly the credential this must admit.
 */
export function requireReadCapability(
  principal: Principal,
  capability: ReadCapability,
): void {
  requireScope(principal, 'read');
  if (permissionGranted(resolveReadPermissionGrants(principal), capability)) return;
  throw new AuthError(
    `This principal lacks the '${capability}' capability.`,
    403,
    'insufficient_permission',
  );
}
