/**
 * Tokens (PLANNING.md §8).
 *   access  — short HS256 JWT, returned in the JSON body, held in the SPA's memory
 *   refresh — 32 opaque random bytes; only hashToken(raw) is stored. Rotating,
 *             with reuse detection: presenting an already-rotated token revokes
 *             its whole family_id.
 */
import { randomBytes } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { env } from '../../env';
import { AppError } from '../../lib/AppError';
import { hashToken } from '../../lib/hash';
import { durationToMs } from '../../lib/duration';
import { fromSqlUtc } from '../../lib/dates';
import { newId } from '../../lib/ulid';
import {
  findRefreshByHash,
  insertRefreshToken,
  markRefreshRotated,
  revokeAllRefreshForUser,
  revokeRefreshByHash,
  revokeRefreshFamily,
} from './auth.repository';

export interface TokenCtx {
  userAgent?: string;
  ipHash?: string;
}

export interface AccessClaims {
  sub: string;
  role: 'superadmin' | 'admin';
}

// ─── access JWT ────────────────────────────────────────────────────────────

export function signAccessToken(admin: { id: string; role: 'superadmin' | 'admin' }): string {
  return jwt.sign({ role: admin.role }, env.JWT_ACCESS_SECRET, {
    subject: admin.id,
    expiresIn: env.ACCESS_TOKEN_TTL as jwt.SignOptions['expiresIn'],
  });
}

export function verifyAccessToken(token: string): AccessClaims {
  try {
    const payload = jwt.verify(token, env.JWT_ACCESS_SECRET);
    if (typeof payload === 'string' || !payload.sub || typeof payload.sub !== 'string') {
      throw new Error('malformed claims');
    }
    const role = (payload as jwt.JwtPayload).role;
    if (role !== 'superadmin' && role !== 'admin') throw new Error('bad role claim');
    return { sub: payload.sub, role };
  } catch {
    throw AppError.unauthorized('Invalid or expired token');
  }
}

// ─── refresh token ─────────────────────────────────────────────────────────

interface MintedRefresh {
  id: string;
  raw: string;
  expiresAt: Date;
}

async function mintRefresh(
  adminUserId: string,
  familyId: string,
  ctx: TokenCtx,
): Promise<MintedRefresh> {
  const ttlMs = durationToMs(env.REFRESH_TOKEN_TTL);
  const raw = randomBytes(32).toString('base64url');
  const id = await insertRefreshToken({
    adminUserId,
    familyId,
    tokenHash: hashToken(raw),
    expiresInSeconds: Math.floor(ttlMs / 1000),
    userAgent: ctx.userAgent?.slice(0, 255) ?? null,
    ipHash: ctx.ipHash ?? null,
  });
  // Approximate — for the API return only; the stored value is the DB's own
  // NOW() + INTERVAL and is authoritative.
  return { id, raw, expiresAt: new Date(Date.now() + ttlMs) };
}

/** First refresh token of a new session (its own family). */
export function issueRefreshToken(
  adminUserId: string,
  ctx: TokenCtx,
): Promise<{ raw: string; expiresAt: Date }> {
  return mintRefresh(adminUserId, newId(), ctx);
}

/** Rotate: validate the presented token, issue its successor, revoke the old one. */
export async function rotateRefreshToken(
  raw: string,
  ctx: TokenCtx,
): Promise<{ raw: string; expiresAt: Date; adminUserId: string }> {
  const row = await findRefreshByHash(hashToken(raw));
  if (!row) throw AppError.unauthorized('Invalid session');

  if (row.revoked_at || row.replaced_by) {
    // This token was already rotated (or revoked). A live client would only ever
    // hold the newest one, so this is a replay — burn the whole lineage (§8).
    await revokeRefreshFamily(row.family_id);
    throw AppError.unauthorized('Session reuse detected');
  }

  if (fromSqlUtc(row.expires_at).getTime() < Date.now()) {
    throw AppError.unauthorized('Session expired');
  }

  const successor = await mintRefresh(row.admin_user_id, row.family_id, ctx);
  await markRefreshRotated(row.id, successor.id);
  return { raw: successor.raw, expiresAt: successor.expiresAt, adminUserId: row.admin_user_id };
}

/** Logout — revoke just the presented token. No-op if it is unknown. */
export function revokeRefreshToken(raw: string): Promise<void> {
  return revokeRefreshByHash(hashToken(raw));
}

/** Password reset — drop every session for the user. */
export function revokeAllForUser(adminUserId: string): Promise<void> {
  return revokeAllRefreshForUser(adminUserId);
}
