/**
 * Signed session cookies for the venue host dashboard (`pr_host`) and the platform admin
 * (`pr_admin`): HS256 JWTs via jose, keyed with the shared AUTH_SECRET.
 *
 * Each kind has its own audience, so a host cookie can never verify as an admin session (or the
 * other way round), and neither verifies as a game-server token (audience `pintrush-game`).
 * Pure functions (secret and clock are parameters) so they are unit-testable without Next.js.
 */
import { createHmac } from 'node:crypto';
import { SignJWT, jwtVerify } from 'jose';
import { z } from 'zod';
import { VenueRoleSchema } from '@pintrush/protocol';

/** Both sessions last 12 hours (same as the host socket token minted from them). */
export const SESSION_TTL_SECONDS = 12 * 60 * 60;

export type SessionKind = 'host' | 'admin';

const ISSUER = 'pintrush-web';
const AUDIENCE: Record<SessionKind, string> = {
  host: 'pintrush-host-session',
  admin: 'pintrush-admin-session',
};

const Uuid = z.uuid();

/**
 * `ver` is VenueUser.version when the cookie was issued. Every request re-reads the user and
 * requires the same version, so a password reset or role change (which bump it) signs that user
 * out everywhere at once.
 */
export const HostSessionSchema = z.object({
  sub: Uuid,
  venueId: Uuid,
  role: VenueRoleSchema,
  ver: z.int().min(0),
});
/**
 * PlatformAdmin has no version column, so `pwd` is a keyed fingerprint of the password hash
 * (see passwordFingerprint): changing an admin's password ends their existing sessions.
 */
export const AdminSessionSchema = z.object({ sub: Uuid, pwd: z.string().regex(/^[A-Za-z0-9_-]{22}$/) });

export type HostSession = z.output<typeof HostSessionSchema>;
export type AdminSession = z.output<typeof AdminSessionSchema>;

type ClaimsFor<K extends SessionKind> = K extends 'host' ? HostSession : AdminSession;

const SCHEMAS: { [K in SessionKind]: z.ZodType<ClaimsFor<K>> } = {
  host: HostSessionSchema,
  admin: AdminSessionSchema,
};

/**
 * Short keyed digest of a stored password hash, carried in the admin cookie. It changes whenever
 * the password does and reveals nothing about it (HMAC with the server secret over a salted scrypt
 * hash, truncated to 128 bits).
 */
export function passwordFingerprint(passwordHash: string, secret: string): string {
  return createHmac('sha256', keyFrom(secret)).update(`admin-session:${passwordHash}`).digest().subarray(0, 16).toString('base64url');
}

function keyFrom(secret: string): Uint8Array {
  if (secret.length < 32) throw new Error('Session secret must be at least 32 characters');
  return new TextEncoder().encode(secret);
}

/** Sign a session token. `nowMs` is injectable for tests. */
export async function signSession<K extends SessionKind>(
  kind: K,
  claims: ClaimsFor<K>,
  secret: string,
  nowMs: number = Date.now(),
): Promise<string> {
  const parsed = SCHEMAS[kind].parse(claims) as ClaimsFor<K>;
  const iat = Math.floor(nowMs / 1000);
  const { sub, ...rest } = parsed as ClaimsFor<K> & { sub: string };
  return new SignJWT({ ...rest, kind })
    .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE[kind])
    .setSubject(sub)
    .setIssuedAt(iat)
    .setExpirationTime(iat + SESSION_TTL_SECONDS)
    .sign(keyFrom(secret));
}

/**
 * Verify a session token. Returns null for anything that is missing, malformed, tampered with,
 * expired, signed with another key, or meant for the other kind — callers never need to care which.
 */
export async function verifySession<K extends SessionKind>(
  kind: K,
  token: string | undefined | null,
  secret: string,
  nowMs: number = Date.now(),
): Promise<ClaimsFor<K> | null> {
  if (!token || token.length > 4096) return null;
  try {
    const { payload } = await jwtVerify(token, keyFrom(secret), {
      algorithms: ['HS256'],
      issuer: ISSUER,
      audience: AUDIENCE[kind],
      currentDate: new Date(nowMs),
      requiredClaims: ['sub', 'exp', 'iat'],
    });
    if (payload.kind !== kind) return null;
    const result = SCHEMAS[kind].safeParse(payload);
    return result.success ? (result.data as ClaimsFor<K>) : null;
  } catch {
    return null;
  }
}
