// HMAC-signed share-link tokens for unauthenticated intake submission.
// See spec specs/active/workflow-intake-artifact-ux.md §5.4.
//
// Wire format:    base64url(payloadJSON) + "." + base64url(hmac)
// Payload claims: { c: case_id, s: stage_id, e: expires_at_ms, n: nonce }
//
// Tokens are SINGLE-USE — submitIntake consumes via share_link_consumptions
// (token_hash UNIQUE). Server stores SHA-256 of the token, never the raw
// token, so a DB leak cannot replay outstanding tokens.

import { createHmac, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
import { getDb, newId, nowIso } from './db.js';
import { resolveMasterKey } from './master-key.js';

const TOKEN_VERSION = 1;
const DEFAULT_EXPIRY_DAYS = 14;
const MAX_EXPIRY_DAYS = 90;

function b64urlEncode(buf) {
  return buf
    .toString('base64')
    .replace(/=+$/, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}

function b64urlDecodeToBuf(s) {
  let padded = s.replace(/-/g, '+').replace(/_/g, '/');
  while (padded.length % 4 !== 0) padded += '=';
  return Buffer.from(padded, 'base64');
}

function hmacFor(payloadB64) {
  // Reuse the secrets-vault master key as the HMAC secret. The vault key is
  // already operator-controlled and tied to install lifetime; introducing a
  // separate share-link secret would double the operator config surface for
  // no defense gain — both keys would live in the same .env.local.
  const key = resolveMasterKey();
  return createHmac('sha256', key).update(payloadB64).digest();
}

function tokenHash(token) {
  return createHash('sha256').update(token).digest('hex');
}

// Mint a share-link token AND record an unconsumed row in
// share_link_consumptions. Returns { token, expires_at }.
export function mintShareToken({ case_id, stage_id, expires_at_iso = null, minted_by_user_id = null }) {
  if (!case_id) throw new Error('mintShareToken: case_id required');
  if (!stage_id) throw new Error('mintShareToken: stage_id required');

  let expiresAtMs;
  if (expires_at_iso) {
    expiresAtMs = Date.parse(expires_at_iso);
    if (Number.isNaN(expiresAtMs)) throw new Error('mintShareToken: invalid expires_at_iso');
    const maxMs = Date.now() + MAX_EXPIRY_DAYS * 86400_000;
    if (expiresAtMs > maxMs) throw new Error(`mintShareToken: expiry exceeds ${MAX_EXPIRY_DAYS} days`);
  } else {
    expiresAtMs = Date.now() + DEFAULT_EXPIRY_DAYS * 86400_000;
  }
  if (expiresAtMs <= Date.now()) throw new Error('mintShareToken: expiry must be in the future');

  const claims = {
    v: TOKEN_VERSION,
    c: case_id,
    s: stage_id,
    e: expiresAtMs,
    n: randomBytes(8).toString('hex'),
  };
  const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(claims)));
  const sigB64 = b64urlEncode(hmacFor(payloadB64));
  const token = `${payloadB64}.${sigB64}`;
  const expiresAtIso = new Date(expiresAtMs).toISOString();

  // Record consumption row up-front (unconsumed). consumed_at remains NULL
  // until the first successful submit. UNIQUE(token_hash) protects against
  // a re-mint colliding on the same token, which would also signal a broken
  // RNG.
  getDb().prepare(`
    INSERT INTO share_link_consumptions
      (id, case_id, stage_id, token_hash, expires_at, minted_by_user_id, minted_at)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).run(
    newId('slc'),
    case_id,
    stage_id,
    tokenHash(token),
    expiresAtIso,
    minted_by_user_id,
    nowIso(),
  );

  return { token, expires_at: expiresAtIso };
}

// Parse + verify HMAC + check expiry. Does NOT touch the DB. Returns the
// claims object on success, or null on any failure (don't leak which check
// failed — a remote prober gets one bit either way).
export function verifyShareToken(token) {
  if (typeof token !== 'string') return null;
  const dot = token.indexOf('.');
  if (dot <= 0 || dot === token.length - 1) return null;
  const payloadB64 = token.slice(0, dot);
  const sigB64 = token.slice(dot + 1);
  let suppliedSig;
  try { suppliedSig = b64urlDecodeToBuf(sigB64); } catch { return null; }
  const expectedSig = hmacFor(payloadB64);
  if (suppliedSig.length !== expectedSig.length) return null;
  try {
    if (!timingSafeEqual(suppliedSig, expectedSig)) return null;
  } catch { return null; }
  let claims;
  try { claims = JSON.parse(b64urlDecodeToBuf(payloadB64).toString('utf8')); } catch { return null; }
  if (!claims || claims.v !== TOKEN_VERSION) return null;
  if (typeof claims.c !== 'string' || typeof claims.s !== 'string') return null;
  if (typeof claims.e !== 'number' || claims.e <= Date.now()) return null;
  return claims;
}

// Atomically consume the token: HMAC must verify, the row must exist and be
// unconsumed and unrevoked and unexpired, claims must match the row. Returns
// the claims on success, throws otherwise.
//
// SQLite has no SELECT FOR UPDATE — we wrap in a transaction. The UNIQUE
// constraint on token_hash + the row's consumed_at IS NULL filter on UPDATE
// guarantees only one transaction can flip consumed_at. A racing call sees
// the now-consumed row and gets `share_token: already_consumed`.
export function consumeShareToken({ token, consumer_ip = null }) {
  const claims = verifyShareToken(token);
  if (!claims) throw new Error('share_token: invalid_or_expired');
  const hash = tokenHash(token);
  const db = getDb();
  // node:sqlite DatabaseSync has no .transaction() helper — wrap manually.
  // The UPDATE's `consumed_at IS NULL` filter is the load-bearing race
  // guard: a racing call sees changes === 0 and gets `race_lost`.
  db.prepare('BEGIN').run();
  let committed = false;
  try {
    const row = db.prepare('SELECT * FROM share_link_consumptions WHERE token_hash = ?').get(hash);
    if (!row) throw new Error('share_token: unknown');
    if (row.revoked_at) throw new Error('share_token: revoked');
    if (row.consumed_at) throw new Error('share_token: already_consumed');
    if (row.case_id !== claims.c) throw new Error('share_token: case_mismatch');
    if (row.stage_id !== claims.s) throw new Error('share_token: stage_mismatch');
    if (Date.parse(row.expires_at) <= Date.now()) throw new Error('share_token: expired');
    const res = db.prepare(`
      UPDATE share_link_consumptions
         SET consumed_at = ?, consumer_ip = ?
       WHERE token_hash = ? AND consumed_at IS NULL
    `).run(nowIso(), consumer_ip, hash);
    if (res.changes !== 1) throw new Error('share_token: race_lost');
    db.prepare('COMMIT').run();
    committed = true;
    return claims;
  } finally {
    if (!committed) {
      try { db.prepare('ROLLBACK').run(); } catch { /* observation only */ }
    }
  }
}

// Revoke all outstanding tokens for a case+stage. Used when a case advances
// past intake — see spec §13 open Q1.
export function revokeShareLinksForCaseStage({ case_id, stage_id }) {
  if (!case_id) return 0;
  const params = [nowIso(), case_id];
  let sql = 'UPDATE share_link_consumptions SET revoked_at = ? WHERE case_id = ? AND consumed_at IS NULL AND revoked_at IS NULL';
  if (stage_id) {
    sql += ' AND stage_id = ?';
    params.push(stage_id);
  }
  const res = getDb().prepare(sql).run(...params);
  return res.changes;
}

export function listConsumptions({ case_id, limit = 50 } = {}) {
  if (!case_id) return [];
  return getDb()
    .prepare('SELECT id, case_id, stage_id, expires_at, minted_at, consumed_at, revoked_at, minted_by_user_id FROM share_link_consumptions WHERE case_id = ? ORDER BY minted_at DESC LIMIT ?')
    .all(case_id, limit);
}

export const SHARE_TOKEN_DEFAULT_EXPIRY_DAYS = DEFAULT_EXPIRY_DAYS;
export const SHARE_TOKEN_MAX_EXPIRY_DAYS = MAX_EXPIRY_DAYS;
