import crypto from 'node:crypto';
import { eq, desc, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import { config } from '../config.js';
import type {
  InviteLinkSummary,
  CreateInviteRequest,
  InviteRedemption,
  UpdateInviteRequest,
  ReinstateInviteRequest,
  ReinstateInviteResponse,
} from '@backspace/shared';

/**
 * Derived status of an invite link. Mirrors the `InviteStatus` union exported
 * from `@backspace/shared` (kept side-by-side intentionally — the shared union
 * defines the API contract, this local one drives internal service logic, and
 * keeping them independent lets a drift surface as a real type error).
 */
export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked';

/**
 * Minimal row shape needed to derive an invite's status. Matches the relevant
 * columns of `invite_links` (revokedAt, expiresAt, maxUses, usedCount).
 */
export interface InviteStatusInput {
  revokedAt: number | null;
  expiresAt: number | null;
  maxUses: number | null;
  usedCount: number;
}

/**
 * Derive the current status of an invite from its row. Precedence order:
 * revoked > expired > exhausted > active. A `maxUses` of `null` means
 * unlimited; an `expiresAt` of `null` means no expiry.
 */
export function inviteStatus(row: InviteStatusInput): InviteStatus {
  if (row.revokedAt !== null) return 'revoked';
  if (row.expiresAt !== null && row.expiresAt < Date.now()) return 'expired';
  if (row.maxUses !== null && row.usedCount >= row.maxUses) return 'exhausted';
  return 'active';
}

/**
 * Generate a fresh invite token: 16 random bytes encoded as base64url, which
 * yields a 22-character URL-safe string (16 * 8 / 6 = 21.33, rounded up; no
 * `=` padding because base64url omits it).
 */
export function generateInviteToken(): string {
  return crypto.randomBytes(16).toString('base64url');
}

/**
 * Thrown by invite-service mutations when caller-supplied input violates a
 * field rule (length, sign, ordering, etc.). Caller (HTTP route) maps this to
 * a 400 Bad Request with the message as `error`.
 */
export class InviteValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'InviteValidationError';
  }
}

/**
 * Build the public-facing invite URL embedded in API responses. Production
 * deployments always set `DOMAIN`; the localhost fallback is only used in
 * local dev (where `config.host` is typically `0.0.0.0` and unusable as a
 * URL host). Per spec §1.2 + §5.4 the server owns URL construction so
 * clients never have to assemble it.
 */
function buildInviteUrl(token: string): string {
  if (config.domain) return `https://${config.domain}/register?invite=${token}`;
  return `http://localhost:${config.port}/register?invite=${token}`;
}

/**
 * Validate the invite name (1–64 chars after trim). Trimming is part of
 * normalization so `"  foo  "` is stored as `"foo"`.
 */
function validateName(name: string): string {
  const trimmed = (name ?? '').trim();
  if (trimmed.length < 1 || trimmed.length > 64) {
    throw new InviteValidationError('Name must be 1-64 characters');
  }
  return trimmed;
}

/**
 * Validate `maxUses`: `null` means unlimited; otherwise a positive integer.
 * Zero is rejected because an invite that can never be used is meaningless
 * (use revoke for that).
 */
function validateMaxUses(maxUses: number | null): number | null {
  if (maxUses === null) return null;
  if (!Number.isInteger(maxUses) || maxUses < 1) {
    throw new InviteValidationError('maxUses must be a positive integer or null');
  }
  return maxUses;
}

/**
 * Validate `expiresAt` (epoch ms). On create, must be in the future; on
 * patch/reinstate, `allowPast` lets admins keep an unchanged past value or
 * deliberately set a past expiry to soft-shut. `Date.now()` exactly is not
 * "in the future" and is rejected when `allowPast` is false.
 */
function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number | null {
  if (expiresAt === null) return null;
  if (!Number.isInteger(expiresAt)) {
    throw new InviteValidationError('expiresAt must be an integer epoch ms or null');
  }
  if (!allowPast && expiresAt <= Date.now()) {
    throw new InviteValidationError('expiresAt must be in the future');
  }
  return expiresAt;
}

/**
 * Folds a (username, isDeleted) pair into the display string used by
 * InviteLinkSummary.createdByUsername / InviteRedemption.currentUsername.
 *
 * - null username → null (FK unresolvable; should be rare, defensive)
 * - isDeleted=1   → 'Deleted User' (matches sanitizeUser convention)
 * - else          → username
 */
function foldUsername(username: string | null, isDeleted: number | null): string | null {
  if (username === null) return null;
  return isDeleted === 1 ? 'Deleted User' : username;
}

/**
 * Project an `invite_links` row plus the resolved creator-username and the
 * most-recent redemption timestamp into the shared `InviteLinkSummary` shape.
 * Centralized so list/create/patch/reinstate all return identically-shaped
 * rows. Status is derived (never stored) per spec §2.1.
 *
 * `lastRedeemedAt` is `null` when the invite has zero redemptions. Callers
 * that run inside a transaction pass the `MAX(redeemed_at)` they read inside
 * the same transaction handle; `listInvites` bakes this into the SELECT
 * projection as a correlated subquery.
 */
function rowToSummary(
  row: typeof schema.inviteLinks.$inferSelect,
  createdByUsername: string | null,
  lastRedeemedAt: number | null,
): InviteLinkSummary {
  return {
    id: row.id,
    token: row.token,
    name: row.name,
    status: inviteStatus(row),
    maxUses: row.maxUses,
    usedCount: row.usedCount,
    expiresAt: row.expiresAt,
    revokedAt: row.revokedAt,
    createdBy: row.createdBy,
    createdByUsername,
    createdAt: row.createdAt,
    lastRedeemedAt,
    url: buildInviteUrl(row.token),
  };
}

/**
 * Query `MAX(redeemed_at)` for a single invite from the `invite_redemptions`
 * table. Returns `null` when there are no redemption rows for this invite.
 *
 * Accepts an optional Drizzle handle so mutation callers inside a transaction
 * body can read the max from the same logical transaction as their surrounding
 * writes. Defaults to the outer `getDb()` for non-txn callers.
 */
function resolveLastRedeemedAt(
  inviteId: string,
  dbHandle: ReturnType<typeof getDb> = getDb(),
): number | null {
  const result = dbHandle
    .select({ maxAt: sql<number | null>`MAX(${schema.inviteRedemptions.redeemedAt})` })
    .from(schema.inviteRedemptions)
    .where(eq(schema.inviteRedemptions.inviteId, inviteId))
    .get();
  return result?.maxAt ?? null;
}

/**
 * Resolve the username to display for an invite's creator. Returns the live
 * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or
 * `null` if the FK is unresolvable (defensive — should not happen in practice).
 *
 * Accepts an optional Drizzle handle so callers inside a `db.transaction`
 * body can pass the `tx` proxy and keep the read on the same logical txn as
 * surrounding writes. Defaults to the outer `getDb()` for non-txn callers.
 */
function resolveCreatorUsername(
  creatorId: string,
  dbHandle: ReturnType<typeof getDb> = getDb(),
): string | null {
  const u = dbHandle.select({ username: schema.users.username, isDeleted: schema.users.isDeleted })
    .from(schema.users)
    .where(eq(schema.users.id, creatorId))
    .get();
  return foldUsername(u?.username ?? null, u?.isDeleted ?? null);
}

/**
 * Create a new invite link. Validates input, generates id + token, inserts the
 * row, and returns the projected summary. Throws `InviteValidationError` on
 * bad input (caller maps to 400).
 */
export function createInvite(req: CreateInviteRequest, creatorId: string): InviteLinkSummary {
  const name = validateName(req.name);
  const maxUses = validateMaxUses(req.maxUses);
  const expiresAt = validateExpiresAt(req.expiresAt, false);

  const db = getDb();
  const id = generateSnowflake();
  const token = generateInviteToken();
  const now = Date.now();

  db.insert(schema.inviteLinks).values({
    id,
    token,
    name,
    createdBy: creatorId,
    createdAt: now,
    maxUses,
    usedCount: 0,
    expiresAt,
    revokedAt: null,
  }).run();

  const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
  if (!row) throw new Error('Failed to insert invite');
  // Freshly created invite: zero redemptions, so lastRedeemedAt is always null.
  return rowToSummary(row, resolveCreatorUsername(creatorId), null);
}

/**
 * Look up the raw `invite_links` row by token. Used by the registration flow
 * (check-invite, register) — those sites do their own derived-status checks.
 * The format guard short-circuits before hitting the DB to keep malformed
 * tokens cheap.
 */
export function getInviteByToken(token: string): typeof schema.inviteLinks.$inferSelect | null {
  if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(token)) return null;
  const db = getDb();
  const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get();
  return row ?? null;
}

/**
 * List invites filtered by lifecycle state. `'active'` returns only rows whose
 * derived status is `active`; `'archived'` returns rows in `expired`,
 * `exhausted`, or `revoked`. The status is derived in TS (single source of
 * truth: `inviteStatus()`), so we fetch all rows then filter — see spec §6.3
 * (no per-instance invite policy / janitor) for why this is acceptable at v1
 * scale; switch to a SQL-side filter only if instances accumulate thousands of
 * invites. The LEFT JOIN against `users` resolves `createdByUsername` in a
 * single query, avoiding the N+1 the spec calls out (§3.1).
 */
export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] {
  const db = getDb();
  const rows = db.select({
    invite: schema.inviteLinks,
    creatorUsername: schema.users.username,
    creatorIsDeleted: schema.users.isDeleted,
    lastRedeemedAt: sql<number | null>`(SELECT MAX(${schema.inviteRedemptions.redeemedAt}) FROM ${schema.inviteRedemptions} WHERE ${schema.inviteRedemptions.inviteId} = ${schema.inviteLinks.id})`,
  })
    .from(schema.inviteLinks)
    .leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id))
    .orderBy(desc(schema.inviteLinks.createdAt))
    .all();

  const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted, lastRedeemedAt }) => {
    const username = foldUsername(creatorUsername, creatorIsDeleted);
    return rowToSummary(invite, username, lastRedeemedAt);
  });

  if (filter === 'active') return summaries.filter(s => s.status === 'active');
  return summaries.filter(s => s.status !== 'active');
}

/**
 * List redemptions for one invite, newest first. The LEFT JOIN against `users`
 * via `userId` surfaces the live username so the UI can render
 * "registered as alice (now Anastasia)" — the snapshot in `registrantUsername`
 * stays forensically stable while `currentUsername` reflects the live state.
 *
 * Three null-handling branches per spec §3.1:
 *   - live user           → `currentUsername = users.username`, `isDeleted = false`
 *   - tombstoned user     → `currentUsername = 'Deleted User'`,  `isDeleted = true`
 *   - hard-deleted user   → `userId = null`, `currentUsername = null`,
 *                           `isDeleted = false` (the row is genuinely gone, not
 *                           soft-deleted; "Deleted User" would be misleading)
 */
export function listRedemptions(inviteId: string): InviteRedemption[] {
  const db = getDb();
  const rows = db.select({
    redemption: schema.inviteRedemptions,
    currentUsername: schema.users.username,
    currentIsDeleted: schema.users.isDeleted,
  })
    .from(schema.inviteRedemptions)
    .leftJoin(schema.users, eq(schema.inviteRedemptions.userId, schema.users.id))
    .where(eq(schema.inviteRedemptions.inviteId, inviteId))
    .orderBy(desc(schema.inviteRedemptions.redeemedAt))
    .all();

  return rows.map(({ redemption, currentUsername, currentIsDeleted }) => ({
    id: redemption.id,
    userId: redemption.userId,
    registrantUsername: redemption.registrantUsername,
    currentUsername: redemption.userId === null ? null : foldUsername(currentUsername, currentIsDeleted),
    isDeleted: currentIsDeleted === 1,
    redeemedAt: redemption.redeemedAt,
  }));
}

/**
 * Thrown when a mutation targets an invite id that does not exist. Caller
 * (HTTP route) maps this to 404 Not Found.
 */
export class InviteNotFoundError extends Error {
  constructor() {
    super('Invite not found');
    this.name = 'InviteNotFoundError';
  }
}

/**
 * Thrown when a mutation is rejected because the invite's current state
 * forbids it (e.g. patching a revoked invite, double-revoking). Caller
 * (HTTP route) maps this to 409 Conflict; the message is the user-facing
 * copy that surfaces in the toast.
 */
export class InviteStateConflictError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'InviteStateConflictError';
  }
}

/**
 * Patch an existing invite's mutable fields. Wrapped in a SQLite transaction
 * with an in-txn re-fetch so concurrent admin edits are serialized: the
 * second writer sees the first writer's committed state and either applies
 * its own delta on top or rejects (e.g. observed-revoked).
 *
 * Validation rules per spec §3.1:
 *   - 404 if id not found.
 *   - 409 if invite is currently revoked (must reinstate first to modify).
 *   - 400 if maxUses would drop below current usedCount (would retroactively
 *     exhaust — confusing; admin should use revoke instead).
 *   - expiresAt may be moved into the past (effective soft-shut → status
 *     flips to 'expired' on next read).
 *
 * An empty patch body is a no-op that returns the current summary unchanged.
 */
export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSummary {
  const db = getDb();
  return db.transaction((tx) => {
    const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!row) throw new InviteNotFoundError();
    if (row.revokedAt !== null) {
      throw new InviteStateConflictError('Invite is revoked. Reinstate first to modify.');
    }

    const updates: Partial<typeof schema.inviteLinks.$inferInsert> = {};
    if (req.name !== undefined) updates.name = validateName(req.name);
    if (req.maxUses !== undefined) {
      const v = validateMaxUses(req.maxUses);
      if (v !== null && v < row.usedCount) {
        throw new InviteValidationError(
          `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`,
        );
      }
      updates.maxUses = v;
    }
    if (req.expiresAt !== undefined) {
      updates.expiresAt = validateExpiresAt(req.expiresAt, true);
    }

    if (Object.keys(updates).length === 0) {
      // No-op: just return current summary
      return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx), resolveLastRedeemedAt(row.id, tx));
    }

    tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
    const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!updated) throw new Error('Failed to read updated invite');
    return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx));
  });
}

/**
 * Revoke an invite. Wrapped in a SQLite transaction with an in-txn re-fetch
 * so concurrent revokes are serialized: the first wins, the second sees
 * `revokedAt !== null` and throws `InviteStateConflictError` (mapped to 409
 * by the route — explicit rejection rather than silent no-op, per spec §3.1).
 *
 * Token is preserved on revoke; reinstate-from-revoked rotates the token as
 * a security boundary (handled in `reinstateInvite`, not here).
 */
export function revokeInvite(id: string): InviteLinkSummary {
  const db = getDb();
  return db.transaction((tx) => {
    const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!row) throw new InviteNotFoundError();
    if (row.revokedAt !== null) {
      throw new InviteStateConflictError('Invite is already revoked');
    }
    tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run();
    const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!updated) throw new Error('Failed to read updated invite');
    return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx));
  });
}

/**
 * Reinstate a non-active invite back to `active`. Three branches per spec §3.1:
 *
 *   - **Path A (revoked)**: rotates the token (security boundary — old shared
 *     links must stop working) and clears `revokedAt`. Caller may also bump
 *     `maxUses` / `expiresAt` in the same call.
 *   - **Path B (expired/exhausted)**: preserves the token. Caller MUST supply
 *     bumps that push the row back into derived `active` state, otherwise the
 *     txn rolls back with `InviteValidationError` (we never leave an invite
 *     half-reinstated, e.g. exhausted-and-still-exhausted with no token rotation
 *     and no state change).
 *   - **Path C (already active)**: rejected with `InviteStateConflictError`
 *     (mapped to 409). Reinstating an active invite is meaningless and would
 *     surprise an admin who clicked the wrong row.
 *
 * Wrapped in a SQLite transaction with an in-txn re-read so the post-update
 * status check sees the row as the next reader would. If the post-state isn't
 * `active`, the throw aborts the txn and the row reverts.
 */
export function reinstateInvite(id: string, req: ReinstateInviteRequest): ReinstateInviteResponse {
  const db = getDb();
  return db.transaction((tx) => {
    const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!row) throw new InviteNotFoundError();

    const currentStatus = inviteStatus(row);
    if (currentStatus === 'active') {
      throw new InviteStateConflictError('Invite is already active');
    }

    const updates: Partial<typeof schema.inviteLinks.$inferInsert> = {};
    let tokenRotated = false;

    if (currentStatus === 'revoked') {
      updates.revokedAt = null;
      updates.token = generateInviteToken();
      tokenRotated = true;
    }

    if (req.maxUses !== undefined) {
      const v = validateMaxUses(req.maxUses);
      if (v !== null && v < row.usedCount) {
        throw new InviteValidationError(
          `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`,
        );
      }
      updates.maxUses = v;
    }
    if (req.expiresAt !== undefined) {
      updates.expiresAt = validateExpiresAt(req.expiresAt, true);
    }

    // Skip the UPDATE entirely when there's nothing to set — Drizzle throws
    // 'No values to set' before our post-state validator can produce the
    // user-facing InviteValidationError. Path C (already active) handles its
    // rejection above, so an empty updates map only reaches here when the
    // caller didn't provide bumps for an expired/exhausted invite — the
    // post-state check below will throw the correct error in that case.
    if (Object.keys(updates).length > 0) {
      tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
    }

    const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
    if (!updated) throw new Error('Failed to read updated invite');

    if (inviteStatus(updated) !== 'active') {
      // Caller did not bump enough — abort the txn so nothing is half-applied
      throw new InviteValidationError(
        'Reinstate would leave invite in non-active state. Bump maxUses and/or expiresAt.',
      );
    }

    return {
      invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)),
      tokenRotated,
    };
  });
}

/**
 * Discriminant union of reasons an invite cannot be redeemed. Surfaced as a
 * typed public field on `InviteUnavailableError` so the HTTP register route
 * can switch on it to produce user-facing copy without parsing the message
 * string. Mirrors the non-active subset of `InviteStatus` plus `'not found'`
 * for the missing-token case.
 */
export type InviteUnavailableReason = 'not found' | 'revoked' | 'expired' | 'exhausted';

/**
 * Thrown when an invite cannot be redeemed because its current state forbids
 * it (token not found, revoked, expired, exhausted). Caller (HTTP register
 * route) maps this to 403 Forbidden. The `reason` field is the structured
 * discriminant; the message string is preserved for debugging/logging.
 */
export class InviteUnavailableError extends Error {
  constructor(public readonly reason: InviteUnavailableReason) {
    super(`Invite unavailable: ${reason}`);
    this.name = 'InviteUnavailableError';
  }
}

/**
 * Result returned by the `insertUser` callback to `redeemInvite`. Captures
 * just the fields needed to write the redemption row (id for the FK,
 * username for the forensic snapshot in `registrant_username`).
 */
export interface RedemptionUserResult {
  id: string;
  username: string;
}

/**
 * Atomically redeem an invite token. The caller-supplied `insertUser` callback
 * runs inside the same SQLite transaction as the usedCount increment + redemption
 * insert. If insertUser throws, the entire transaction rolls back — the invite
 * is NOT consumed for failed registrations (e.g. username uniqueness collisions).
 *
 * Re-derives status under the transaction to close the TOCTOU window between
 * `/api/auth/check-invite` (which the client may call seconds before submit)
 * and the actual register POST: another user could have consumed the last slot
 * in between. Re-checking inside the txn ensures the slot we increment is the
 * one we observed available.
 */
export function redeemInvite(
  token: string,
  insertUser: () => RedemptionUserResult,
): RedemptionUserResult {
  const db = getDb();
  return db.transaction((tx) => {
    const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get();
    if (!row) throw new InviteUnavailableError('not found');
    const status = inviteStatus(row);
    if (status !== 'active') {
      // 'active' is excluded by the guard above, so `status` is necessarily
      // one of 'revoked' | 'expired' | 'exhausted' — all valid
      // InviteUnavailableReason values. TS narrows the union here.
      throw new InviteUnavailableError(status);
    }

    // The insertUser callback runs inside the transaction. The caller's INSERT
    // statement uses the outer `db` connection, but better-sqlite3 serializes
    // all writes regardless of which Drizzle handle issued them, so the user
    // insert joins the same atomic unit. If insertUser throws, the entire
    // transaction rolls back including the usedCount bump and redemption row.
    const userResult = insertUser();

    tx.update(schema.inviteLinks)
      .set({ usedCount: row.usedCount + 1 })
      .where(eq(schema.inviteLinks.id, row.id))
      .run();

    tx.insert(schema.inviteRedemptions).values({
      id: generateSnowflake(),
      inviteId: row.id,
      userId: userResult.id,
      registrantUsername: userResult.username,
      redeemedAt: Date.now(),
    }).run();

    return userResult;
  });
}

/**
 * Permanently delete an invite. Redemption rows for this invite are removed
 * via `ON DELETE CASCADE` on `invite_redemptions.invite_id` — this is the
 * documented destructive intent of "delete the invite and its history".
 *
 * No transaction needed: deleteInvite has no read-modify-write state semantics
 * that other concurrent mutators would race against. The existence check is
 * for the 404 response only; if a concurrent process deletes the row between
 * the SELECT and the DELETE, the DELETE is a harmless no-op and the caller
 * still observes the row gone afterwards.
 */
export function deleteInvite(id: string): void {
  const db = getDb();
  const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
  if (!row) throw new InviteNotFoundError();
  db.delete(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).run();
}
