/**
 * Fleet factory enrollment + scoped rotatable tokens (§12 / §18 trust boundary).
 *
 * A factory authenticates to the coordinator with a SCOPED bearer credential
 * (scope = {productId, factoryId, capabilities[]}). Tokens are stored HASHED at
 * rest (sha256 — the same primitive the auth module uses for verify/magic-link
 * tokens); the high-entropy plaintext is returned exactly once at enroll/rotate
 * and is NEVER persisted. Rotation keeps the previous token valid for a short
 * grace overlap so an in-flight worker isn't cut off; revocation is immediate.
 *
 * Token-auth on the fleet endpoints is GATED by FLEET_REQUIRE_FACTORY_TOKEN
 * (default OFF) so existing tests/behavior are unaffected until enforcement is on.
 */

import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { FastifyRequest } from 'fastify';
import { ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
import * as repo from './repository.js';
import {
  FleetFactoryTokenDocSchema,
  type FactoryTokenStatus,
  type FleetFactoryDoc,
} from './types.js';

/** Header carrying the factory's scoped token (distinct from the user JWT). */
export const FACTORY_TOKEN_HEADER = 'x-factory-token';
const TOKEN_PREFIX = 'flt_';
const DEFAULT_ROTATION_OVERLAP_SEC = 300;
const DEFAULT_TOKEN_TTL_DAYS = 90;

// ── Crypto helpers (reuse the auth module's sha256 token pattern; no new schemes) ──

function hashToken(plaintext: string): string {
  return createHash('sha256').update(plaintext).digest('hex');
}

function newPlaintextToken(): string {
  return `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
}

/** Constant-time compare of two hex digests of equal length. */
function constantTimeEqualHex(a: string, b: string): boolean {
  if (a.length !== b.length || a.length === 0) return false;
  return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}

function rotationOverlapSeconds(): number {
  const v = Number(process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC);
  return Number.isFinite(v) && v >= 0 ? v : DEFAULT_ROTATION_OVERLAP_SEC;
}

/** Token lifetime in ms from `FLEET_TOKEN_TTL_DAYS` (default 90 days). 0 ⇒ no
 *  expiry (returns null). Negative/garbage ⇒ the default. */
function tokenTtlMs(): number | null {
  const raw = process.env.FLEET_TOKEN_TTL_DAYS;
  if (raw === undefined || raw.trim() === '') return DEFAULT_TOKEN_TTL_DAYS * 86_400_000;
  const days = Number(raw);
  if (!Number.isFinite(days) || days < 0) return DEFAULT_TOKEN_TTL_DAYS * 86_400_000;
  if (days === 0) return null; // explicitly disabled
  return days * 86_400_000;
}

/**
 * Whether factory-token auth is enforced on heartbeat/claim. An explicit
 * `FLEET_REQUIRE_FACTORY_TOKEN` (1/true/on or 0/false/off) always wins. With no
 * explicit value the default is ON in production and OFF elsewhere (development /
 * test) — so real deployments are secure by default while the local prototype and
 * the test suite keep working without enrollment. Read per-request so tests can toggle.
 */
export function factoryTokenEnforcementEnabled(): boolean {
  const v = (process.env.FLEET_REQUIRE_FACTORY_TOKEN ?? '').trim().toLowerCase();
  if (v === '1' || v === 'true' || v === 'yes' || v === 'on') return true;
  if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false;
  return (process.env.NODE_ENV ?? '').toLowerCase() === 'production';
}

// ── Scope + result shapes ───────────────────────────────────────────────────

export interface TokenScope {
  tokenId: string;
  factoryId: string;
  productId: string;
  capabilities: string[];
  status: FactoryTokenStatus;
}

export interface IssuedToken {
  tokenId: string;
  /** Plaintext — returned ONCE, never persisted. */
  token: string;
  scope: { productId: string; factoryId: string; capabilities: string[] };
}

export interface EnrollResult extends IssuedToken {
  factory: FleetFactoryDoc;
}

// ── Lifecycle ────────────────────────────────────────────────────────────────

export interface EnrollFactoryArgs {
  productId: string;
  factoryId?: string;
  capabilities?: string[];
  label?: string;
}

/**
 * Enroll a factory (create/link its FleetFactoryDoc) and issue its first scoped
 * token. Returns the plaintext token ONCE; only the hash is persisted.
 */
export async function enrollFactory(args: EnrollFactoryArgs): Promise<EnrollResult> {
  const productId = args.productId;
  const factoryId = args.factoryId ?? `fac_${randomUUID()}`;
  const capabilities = args.capabilities ?? [];
  const now = new Date().toISOString();

  let factory = await repo.getFactory(factoryId, productId);
  if (!factory) {
    factory = await repo.upsertFactory({
      id: factoryId,
      productId,
      factoryId,
      descriptor: {},
      capabilities,
      health: 'ok',
      load: 0,
      seatLimit: 1,
      lastHeartbeatAt: now,
    });
  }

  const issued = await issueToken({ productId, factoryId, capabilities, label: args.label });
  return { ...issued, factory };
}

interface IssueTokenArgs {
  productId: string;
  factoryId: string;
  capabilities: string[];
  label?: string;
}

async function issueToken(args: IssueTokenArgs): Promise<IssuedToken> {
  const tokenId = `fltk_${randomUUID()}`;
  const plaintext = newPlaintextToken();
  const ttlMs = tokenTtlMs();
  const doc = FleetFactoryTokenDocSchema.parse({
    id: tokenId,
    productId: args.productId,
    factoryId: args.factoryId,
    tokenHash: hashToken(plaintext),
    capabilities: args.capabilities,
    label: args.label,
    status: 'active',
    createdAt: new Date().toISOString(),
    ...(ttlMs !== null ? { expiresAt: new Date(Date.now() + ttlMs).toISOString() } : {}),
  });
  await repo.createFactoryToken(doc);
  return {
    tokenId,
    token: plaintext,
    scope: {
      productId: args.productId,
      factoryId: args.factoryId,
      capabilities: args.capabilities,
    },
  };
}

export interface RotateTokenArgs {
  productId: string;
  factoryId: string;
  capabilities?: string[];
}

/**
 * Issue a new active token for a factory and mark its current active token(s)
 * `rotating` with a short grace overlap (so an in-flight worker keeps working
 * until `rotatingUntil`). Capabilities default to the prior scope.
 */
export async function rotateToken(args: RotateTokenArgs): Promise<IssuedToken> {
  const { productId, factoryId } = args;
  const existing = await repo.listFactoryTokens(productId, factoryId);
  const active = existing.filter(t => t.status === 'active');
  const capabilities = args.capabilities ?? active[0]?.capabilities ?? [];
  const rotatingUntil = new Date(Date.now() + rotationOverlapSeconds() * 1000).toISOString();
  for (const t of active) {
    await repo.updateFactoryToken(t.id, productId, { status: 'rotating', rotatingUntil });
  }
  return issueToken({ productId, factoryId, capabilities });
}

export interface RevokeTokenArgs {
  productId: string;
  factoryId?: string;
  tokenId?: string;
}

/** Revoke a factory's token(s) — immediate rejection. Returns how many were revoked. */
export async function revokeToken(args: RevokeTokenArgs): Promise<number> {
  const { productId } = args;
  let targets = [] as Awaited<ReturnType<typeof repo.listFactoryTokens>>;
  if (args.tokenId) {
    const one = await repo.getFactoryToken(args.tokenId, productId);
    if (one) targets = [one];
  } else if (args.factoryId) {
    targets = await repo.listFactoryTokens(productId, args.factoryId);
  }
  let revoked = 0;
  for (const t of targets) {
    if (t.status !== 'revoked') {
      await repo.updateFactoryToken(t.id, productId, { status: 'revoked' });
      revoked += 1;
    }
  }
  return revoked;
}

/**
 * Resolve a plaintext token to its scope, or null. Constant-time hash compare;
 * revoked ⇒ null; `rotating` accepted only within the grace window. Updates
 * `lastUsedAt` best-effort on success.
 */
export async function verifyToken(plaintext: string | undefined): Promise<TokenScope | null> {
  if (!plaintext) return null;
  const hash = hashToken(plaintext);
  const doc = await repo.findFactoryTokenByHash(hash);
  if (!doc) return null;
  if (!constantTimeEqualHex(doc.tokenHash, hash)) return null;
  if (doc.status === 'revoked') return null;
  // Absolute expiry bounds a leaked credential regardless of status.
  if (doc.expiresAt && new Date(doc.expiresAt).getTime() < Date.now()) return null;
  if (doc.status === 'rotating') {
    const until = doc.rotatingUntil ? new Date(doc.rotatingUntil).getTime() : 0;
    if (!until || until < Date.now()) return null;
  }
  await repo
    .updateFactoryToken(doc.id, doc.productId, { lastUsedAt: new Date().toISOString() })
    .catch(() => null);
  return {
    tokenId: doc.id,
    factoryId: doc.factoryId,
    productId: doc.productId,
    capabilities: doc.capabilities,
    status: doc.status,
  };
}

/**
 * Delete factory tokens that can never authenticate again — revoked, past their
 * `expiresAt`, or `rotating` past the grace window — so the credential store does
 * not grow without bound. `active` (and within-grace `rotating`) tokens are kept.
 * Returns how many were removed. Best-effort: a single delete failure is skipped.
 */
export async function pruneInvalidatedTokens(nowMs = Date.now()): Promise<number> {
  let deleted = 0;
  for (const t of await repo.listAllFactoryTokens()) {
    const expired = !!t.expiresAt && new Date(t.expiresAt).getTime() < nowMs;
    const rotatingExpired =
      t.status === 'rotating' && (!t.rotatingUntil || new Date(t.rotatingUntil).getTime() < nowMs);
    if (t.status === 'revoked' || expired || rotatingExpired) {
      try {
        await repo.deleteFactoryToken(t.id, t.productId);
        deleted += 1;
      } catch {
        /* best-effort */
      }
    }
  }
  return deleted;
}

// ── Gated request enforcement (heartbeat / claim) ─────────────────────────────

export function extractFactoryToken(req: FastifyRequest): string | undefined {
  const h = req.headers[FACTORY_TOKEN_HEADER];
  if (typeof h === 'string') return h;
  if (Array.isArray(h)) return h[0];
  return undefined;
}

export interface EnforceOptions {
  productId: string;
  /** Capabilities the request wants to use; must be a subset of the token scope. */
  capabilities?: string[];
  /** When set, the token must belong to this factory. */
  factoryId?: string;
}

/**
 * When enforcement is ON: verify the factory token and confirm it covers the
 * requested product (+ capabilities + factory). Returns the verified scope so the
 * caller can constrain the operation to it. Throws 401 (missing/invalid/revoked)
 * or 403 (out of scope). When enforcement is OFF: returns null (behaves as today).
 */
export async function enforceFactoryToken(
  req: FastifyRequest,
  opts: EnforceOptions
): Promise<TokenScope | null> {
  if (!factoryTokenEnforcementEnabled()) return null;
  const scope = await verifyToken(extractFactoryToken(req));
  if (!scope) throw new UnauthorizedError('factory token required (missing, invalid, or revoked)');
  if (scope.productId !== opts.productId) {
    throw new ForbiddenError('factory token productId is out of scope');
  }
  if (opts.factoryId && scope.factoryId !== opts.factoryId) {
    throw new ForbiddenError('factory token does not match the requested factoryId');
  }
  if (opts.capabilities && opts.capabilities.length > 0) {
    const granted = new Set(scope.capabilities);
    const missing = opts.capabilities.filter(c => !granted.has(c));
    if (missing.length > 0) {
      throw new ForbiddenError(
        `factory token scope is missing capabilities: ${missing.join(', ')}`
      );
    }
  }
  return scope;
}
