/**
 * Fleet REST endpoints — agent gigafactory coordinator.
 *
 * POST   /fleet/jobs                  submit a job (idempotent)
 * GET    /fleet/jobs                  list jobs (by stage / idempotencyKey)
 * GET    /fleet/jobs/:id              get one job
 * PATCH  /fleet/jobs/:id              fenced state transition (carries leaseEpoch)
 * POST   /fleet/claim                 atomic claim for a factory
 * POST   /fleet/jobs/:id/lease/renew  renew a held lease
 * POST   /fleet/jobs/:id/lease/release release a held lease
 * POST   /fleet/factories/heartbeat   factory liveness
 * GET    /fleet/jobs/:id/runs         job run history
 * GET    /fleet/jobs/:id/events       append-only event stream
 * GET    /fleet/jobs/:id/events/stream live event stream (SSE, resumable)
 * GET    /fleet/metrics              fleet metrics + alerts (queue depth, utilization)
 * POST   /fleet/jobs/:id/review/request route a building job into the review gate
 * POST   /fleet/jobs/:id/review        submit a reviewer decision (approve/reject)
 * POST   /fleet/jobs/:id/artifacts    upload a run output (base64 body → blob + pointer)
 * GET    /fleet/jobs/:id/artifacts    list a job's artifact pointers
 * GET    /fleet/artifacts/:artifactId pointer + fresh short-lived SAS download URL
 * DELETE /fleet/artifacts/:artifactId delete pointer (and backing blob)
 *
 * All routes require auth + a resolved productId, exactly like the items module.
 */

import type { FastifyInstance } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import * as coordinator from './coordinator.js';
import * as artifactsBlob from './artifacts-blob.js';
import * as enrollment from './enrollment.js';
import * as trackerBridge from './tracker-bridge.js';
import {
  SubmitJobSchema,
  ListJobsQuerySchema,
  PatchJobSchema,
  SubmitReviewSchema,
  ClaimSchema,
  RenewLeaseSchema,
  ReleaseLeaseSchema,
  HeartbeatSchema,
  UploadArtifactSchema,
  EnrollFactorySchema,
  RotateTokenSchema,
  RevokeTokenSchema,
  IngestItemSchema,
  EchoJobSchema,
  SubmitChildrenSchema,
  UpsertBudgetSchema,
} from './types.js';

function badRequest(issues: { message: string }[]): never {
  throw new BadRequestError(issues.map(i => i.message).join('; '));
}

/**
 * Resolve the caller's productId and enforce that any `:productId` path param
 * matches it. Prevents a caller authenticated for product A from reading or
 * mutating another product's fleet budget. Returns the caller's productId.
 */
function requireOwnProduct(req: import('fastify').FastifyRequest): string {
  const pid = getRequestProductId(req);
  const { productId } = req.params as { productId?: string };
  if (productId && productId !== pid) {
    throw new ForbiddenError("Cannot access another product's fleet budget");
  }
  return pid;
}

const delay = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));

/** Parse an integer query param, clamping to [min, max]; fall back to `fallback`. */
function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
  const n = Number.parseInt(raw ?? '', 10);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, n));
}

export async function fleetRoutes(app: FastifyInstance) {
  // ── Submit (idempotent) ──
  app.post('/fleet/jobs', async (req, reply) => {
    await extractAuth(req);
    const parsed = SubmitJobSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = parsed.data.productId || getRequestProductId(req);
    const result = await coordinator.submitJob(pid, parsed.data);
    reply.code(result.outcome === 'created' ? 201 : 200);
    return { outcome: result.outcome, job: result.job };
  });

  // ── List ──
  app.get('/fleet/jobs', async req => {
    await extractAuth(req);
    const parsed = ListJobsQuerySchema.safeParse(req.query);
    if (!parsed.success) badRequest(parsed.error.issues);
    const q = parsed.data;
    const pid = q.productId || getRequestProductId(req);
    const jobs = await repo.listJobs({
      productId: pid,
      stage: q.stage,
      idempotencyKey: q.idempotencyKey,
      limit: q.limit,
      offset: q.offset,
    });
    return { jobs, limit: q.limit, offset: q.offset };
  });

  // ── Get one ──
  app.get('/fleet/jobs/:id', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const job = await repo.getJob(id, pid);
    if (!job) throw new NotFoundError('Job not found');
    return job;
  });

  // ── Fenced state transition ──
  app.patch('/fleet/jobs/:id', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const parsed = PatchJobSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const res = await coordinator.patchJobFenced(id, pid, parsed.data);
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job not found');
      if (res.reason === 'fenced') {
        throw new ConflictError('stale leaseEpoch — transition fenced (job reassigned)');
      }
      throw new ConflictError('concurrent update conflict — retry');
    }
    // §10: opt-in (FLEET_TRACKER_ECHO, default OFF) downstream echo of the new
    // stage to the linked tracker Item. Never blocks/fails the transition.
    await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
    return res.doc;
  });

  // ── Operator actions (requeue / reject / cancel) ──
  // Operator-initiated, lease-free lifecycle control from the fleet control plane.
  // Fences any current factory holder by bumping the lease epoch (§14 Phase 3).
  app.post('/fleet/jobs/:id/actions/:action', async req => {
    await extractAuth(req);
    const { id, action } = req.params as { id: string; action: string };
    const pid = getRequestProductId(req);
    if (!(coordinator.OPERATOR_ACTIONS as readonly string[]).includes(action)) {
      throw new BadRequestError(
        `unknown operator action '${action}' (expected: ${coordinator.OPERATOR_ACTIONS.join(', ')})`
      );
    }
    const res = await coordinator.operatorAction(id, pid, action as coordinator.OperatorActionKind);
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job not found');
      if (res.reason === 'invalid_state') {
        throw new ConflictError('job is shipped (terminal) — operator action not allowed');
      }
      throw new ConflictError('concurrent update conflict — retry');
    }
    await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
    return res.doc;
  });

  // ── Multi-reviewer human gate (§14 Phase 3) ──
  app.post('/fleet/jobs/:id/review/request', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const body = (req.body ?? {}) as { requiredApprovals?: number; reviewers?: string[] };
    const res = await coordinator.requestReview(id, pid, {
      requiredApprovals: body.requiredApprovals,
      reviewers: body.reviewers,
    });
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job not found');
      if (res.reason === 'invalid_state') {
        throw new ConflictError('only a building job can be routed to review');
      }
      if (res.reason === 'invalid_policy') {
        throw new BadRequestError('requiredApprovals exceeds the number of allowed reviewers');
      }
      throw new ConflictError('concurrent update conflict — retry');
    }
    await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
    return res.doc;
  });

  app.post('/fleet/jobs/:id/review', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const parsed = SubmitReviewSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const res = await coordinator.submitReview(id, pid, parsed.data);
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job not found');
      if (res.reason === 'invalid_state') {
        throw new ConflictError('job is not awaiting review');
      }
      if (res.reason === 'not_authorized') {
        throw new BadRequestError('reviewer is not on the allowlist for this job');
      }
      throw new ConflictError('concurrent update conflict — retry');
    }
    await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
    return { ...res.doc, gate: res.gate };
  });

  app.post('/fleet/claim', async req => {
    await extractAuth(req);
    const parsed = ClaimSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = parsed.data.productId || getRequestProductId(req);
    // §12: when enforcement is ON, the token must cover the productId + requested
    // capabilities + factoryId; the claim is then CONSTRAINED to the verified scope
    // (a factory cannot claim outside it). When OFF, behaves exactly as before.
    const scope = await enrollment.enforceFactoryToken(req, {
      productId: pid,
      capabilities: parsed.data.capabilities,
      factoryId: parsed.data.factoryId,
    });
    const claim = await coordinator.claimNextJob({
      productId: scope?.productId ?? pid,
      factoryId: scope?.factoryId ?? parsed.data.factoryId,
      capabilities: scope ? scope.capabilities : parsed.data.capabilities,
      leaseSeconds: parsed.data.leaseSeconds,
    });
    if (!claim) return { claimed: false };
    return { claimed: true, ...claim };
  });

  // ── Lease renew ──
  app.post('/fleet/jobs/:id/lease/renew', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const parsed = RenewLeaseSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const res = await coordinator.renewLease(
      id,
      pid,
      parsed.data.leaseEpoch,
      parsed.data.leaseSeconds
    );
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job or lease not found');
      if (res.reason === 'fenced') throw new ConflictError('stale leaseEpoch — renew fenced');
      throw new ConflictError('lease renew conflict — retry');
    }
    return res.doc;
  });

  // ── Lease release ──
  app.post('/fleet/jobs/:id/lease/release', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const parsed = ReleaseLeaseSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, {
      insights: parsed.data.insights,
      result: parsed.data.result,
    });
    if (!res.ok) {
      if (res.reason === 'not_found') throw new NotFoundError('Job or lease not found');
      if (res.reason === 'fenced') throw new ConflictError('stale leaseEpoch — release fenced');
      throw new ConflictError('lease release conflict — retry');
    }
    // §10: release often carries a terminal stage (shipped/failed) — echo it (opt-in).
    if (parsed.data.stage) await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
    return res.doc;
  });

  // ── Factory heartbeat ──
  app.post('/fleet/factories/heartbeat', async req => {
    await extractAuth(req);
    const parsed = HeartbeatSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pidCandidate = parsed.data.productId || getRequestProductId(req);
    // §12: gated token check (default OFF). When ON the token must cover this
    // product + factory; the heartbeat is then bound to the verified scope.
    const scope = await enrollment.enforceFactoryToken(req, {
      productId: pidCandidate,
      factoryId: parsed.data.factoryId,
    });
    const pid = scope?.productId ?? pidCandidate;
    await coordinator.heartbeat({
      productId: pid,
      factoryId: parsed.data.factoryId,
      descriptor: parsed.data.descriptor,
      capabilities: parsed.data.capabilities,
      health: parsed.data.health,
      load: parsed.data.load,
      seatLimit: parsed.data.seatLimit,
    });
    const factory = await repo.getFactory(parsed.data.factoryId, pid);
    return { ok: true, factory };
  });

  // ── Runs ──
  app.get('/fleet/jobs/:id/runs', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const runs = await repo.listRunsByJob(id);
    return { runs };
  });

  // ── Events ──
  app.get('/fleet/jobs/:id/events', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const events = await repo.listEvents(id);
    return { events };
  });

  // ── Live event stream (SSE, §17 single-stream contract) ──
  // Emits a snapshot of existing events (seq > Last-Event-ID), then long-polls
  // the append-only stream for new events. Closes after `maxMs` so EventSource
  // clients reconnect cleanly (carrying Last-Event-ID to resume). Designed to
  // degrade gracefully: consumers fall back to `GET …/events` polling on error.
  app.get('/fleet/jobs/:id/events/stream', async (req, reply) => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const query = (req.query ?? {}) as Record<string, string | undefined>;

    // Resume cursor: `Last-Event-ID` header (set by EventSource on reconnect)
    // takes precedence over the `lastEventId` query param. `-1` => send all.
    const headerId = req.headers['last-event-id'];
    const rawCursor = (Array.isArray(headerId) ? headerId[0] : headerId) ?? query.lastEventId;
    let cursor = Number.parseInt(rawCursor ?? '', 10);
    if (!Number.isFinite(cursor)) cursor = -1;

    const pollMs = clampInt(query.pollMs, 1000, 50, 10_000);
    const maxMs = clampInt(query.maxMs, 25_000, 0, 60_000);

    reply.hijack();
    const raw = reply.raw;
    raw.writeHead(200, {
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache, no-transform',
      connection: 'keep-alive',
      'x-accel-buffering': 'no',
    });
    // Tell the client how long to wait before reconnecting.
    raw.write('retry: 3000\n\n');

    let open = true;
    const stop = () => {
      open = false;
    };
    req.raw.on('close', stop);
    req.raw.on('error', stop);

    const flushNew = async (): Promise<void> => {
      const all = await repo.listEvents(id);
      for (const evt of all) {
        if (evt.seq <= cursor) continue;
        if (!open) return;
        raw.write(`id: ${evt.seq}\nevent: fleet-event\ndata: ${JSON.stringify(evt)}\n\n`);
        cursor = evt.seq;
      }
    };

    try {
      await flushNew();
      const deadline = Date.now() + maxMs;
      while (open && Date.now() < deadline) {
        await delay(pollMs);
        if (!open) break;
        await flushNew();
        if (open) raw.write(': keepalive\n\n');
      }
    } catch {
      // Surface a terminal error event; the client will reconnect/fall back.
      if (open) raw.write('event: error\ndata: {"message":"stream interrupted"}\n\n');
    } finally {
      req.raw.off('close', stop);
      req.raw.off('error', stop);
      raw.end();
    }
  });

  // ── Scoring explainability — why does this job route where it does? (§7) ──
  app.get('/fleet/jobs/:id/explain', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const explain = await coordinator.explainJob(id, pid);
    if (!explain) throw new NotFoundError('Job not found');
    return explain;
  });

  // ── Fleet metrics + alerting (§17) ──
  app.get('/fleet/metrics', async req => {
    await extractAuth(req);
    const pid = getRequestProductId(req);
    return coordinator.fleetMetrics(pid);
  });

  // ── Artifacts: upload (base64 body → blob + pointer) ──
  app.post('/fleet/jobs/:id/artifacts', async (req, reply) => {
    await extractAuth(req);
    const { id: jobId } = req.params as { id: string };
    const parsed = UploadArtifactSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    // Review fix: the request/auth productId is authoritative — a spoofed
    // body.productId must NOT override it (prevents writing into another product).
    const pid = getRequestProductId(req);
    const bytes = Buffer.from(parsed.data.contentBase64, 'base64');
    if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]);
    const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({
      productId: pid,
      jobId,
      kind: parsed.data.kind,
      bytes,
      contentType: parsed.data.contentType,
      runId: parsed.data.runId,
    });
    reply.code(201);
    return { artifact, downloadUrl };
  });

  // ── Artifacts: list a job's pointers (productId-scoped) ──
  app.get('/fleet/jobs/:id/artifacts', async req => {
    await extractAuth(req);
    const { id: jobId } = req.params as { id: string };
    // Review fix: scope to the request productId so a caller only sees its own
    // product's artifact pointers for this job.
    const pid = getRequestProductId(req);
    const artifacts = await repo.listArtifactsByJob(jobId, pid);
    return { artifacts };
  });

  // ── Artifacts: pointer + fresh SAS download URL ──
  app.get('/fleet/artifacts/:artifactId', async req => {
    await extractAuth(req);
    const { artifactId } = req.params as { artifactId: string };
    const pid = getRequestProductId(req);
    const found = await artifactsBlob.getArtifactDownload(artifactId, pid);
    if (!found) throw new NotFoundError('Artifact not found');
    return found;
  });

  // ── Artifacts: delete pointer (and backing blob) ──
  app.delete('/fleet/artifacts/:artifactId', async req => {
    await extractAuth(req);
    const { artifactId } = req.params as { artifactId: string };
    const pid = getRequestProductId(req);
    const deleted = await artifactsBlob.deleteArtifact(artifactId, pid);
    if (!deleted) throw new NotFoundError('Artifact not found');
    return { deleted: true };
  });

  // ── Enrollment + scoped tokens (§12) ──
  // Management endpoints (user auth + productId). The plaintext token is returned
  // ONCE here; only its hash is persisted.
  app.post('/fleet/factories/enroll', async (req, reply) => {
    await extractAuth(req);
    const parsed = EnrollFactorySchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = parsed.data.productId || getRequestProductId(req);
    const result = await enrollment.enrollFactory({
      productId: pid,
      factoryId: parsed.data.factoryId,
      capabilities: parsed.data.capabilities,
      label: parsed.data.label,
    });
    reply.code(201);
    return {
      factory: result.factory,
      tokenId: result.tokenId,
      token: result.token,
      scope: result.scope,
    };
  });

  // ── Rotate a factory's token (old token honored for a short grace overlap) ──
  app.post('/fleet/factories/:id/token/rotate', async req => {
    await extractAuth(req);
    const { id: factoryId } = req.params as { id: string };
    const parsed = RotateTokenSchema.safeParse(req.body ?? {});
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = parsed.data.productId || getRequestProductId(req);
    const factory = await repo.getFactory(factoryId, pid);
    if (!factory) throw new NotFoundError('Factory not found');
    const issued = await enrollment.rotateToken({
      productId: pid,
      factoryId,
      capabilities: parsed.data.capabilities,
    });
    return { tokenId: issued.tokenId, token: issued.token, scope: issued.scope };
  });

  // ── Revoke a factory's token(s) — immediate rejection ──
  app.post('/fleet/factories/:id/token/revoke', async req => {
    await extractAuth(req);
    const { id: factoryId } = req.params as { id: string };
    const parsed = RevokeTokenSchema.safeParse(req.body ?? {});
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = parsed.data.productId || getRequestProductId(req);
    const revoked = await enrollment.revokeToken({
      productId: pid,
      factoryId,
      tokenId: parsed.data.tokenId,
    });
    return { revoked };
  });

  // ── Phase 3 Budgets: GET/PUT per-product budget ──
  app.get('/fleet/budgets/:productId', async req => {
    await extractAuth(req);
    const productId = requireOwnProduct(req);
    const budget = await coordinator.getBudget(productId);
    if (!budget) throw new NotFoundError('No budget configured for this product');
    return budget;
  });

  // ── Cost burndown — spend-over-time vs ceiling (§14) ──
  app.get('/fleet/budgets/:productId/burndown', async req => {
    await extractAuth(req);
    const productId = requireOwnProduct(req);
    const { days } = req.query as { days?: string };
    const parsedDays = days ? Number.parseInt(days, 10) : undefined;
    return coordinator.costBurndown(
      productId,
      Number.isFinite(parsedDays) ? parsedDays : undefined
    );
  });

  app.put('/fleet/budgets/:productId', async (req, reply) => {
    await extractAuth(req);
    const productId = requireOwnProduct(req);
    const parsed = UpsertBudgetSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const budget = await coordinator.upsertBudget(
      productId,
      parsed.data.ceilingUsd,
      parsed.data.window
    );
    reply.code(200);
    return budget;
  });

  app.post('/fleet/budgets/:productId/pause', async req => {
    await extractAuth(req);
    const productId = requireOwnProduct(req);
    const budget = await coordinator.pauseBudget(productId);
    if (!budget) throw new NotFoundError('No budget configured for this product');
    return budget;
  });

  app.post('/fleet/budgets/:productId/resume', async req => {
    await extractAuth(req);
    const productId = requireOwnProduct(req);
    const budget = await coordinator.resumeBudget(productId);
    if (!budget) throw new NotFoundError('No budget configured for this product');
    return budget;
  });

  // ── Phase 3 DAG: submit children for an existing parent ──
  app.post('/fleet/jobs/:id/children', async (req, reply) => {
    await extractAuth(req);
    const { id: parentId } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const parsed = SubmitChildrenSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const parent = await repo.getJob(parentId, pid);
    if (!parent) throw new NotFoundError('Parent job not found');
    const result = await coordinator.submitChildren(parentId, pid, parsed.data.children);
    reply.code(201);
    return { parent: result.parent, children: result.childJobs };
  });

  // ── Phase 3 DAG: get the subtree rooted at a job ──
  app.get('/fleet/jobs/:id/dag', async req => {
    await extractAuth(req);
    const { id } = req.params as { id: string };
    const pid = getRequestProductId(req);
    const dag = await coordinator.getDagSubtree(id, pid);
    if (!dag) throw new NotFoundError('Job not found');
    return { dag };
  });

  // ── Tracker bridge (§10): ingest an Item as a job (idempotent, scheduled by §7) ──
  app.post('/fleet/tracker/ingest', async (req, reply) => {
    await extractAuth(req);
    const parsed = IngestItemSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = getRequestProductId(req);
    const result = await trackerBridge.ingestItemAsJob(pid, parsed.data.itemId, {
      priority: parsed.data.priority,
    });
    reply.code(result.outcome === 'created' ? 201 : 200);
    return { outcome: result.outcome, job: result.job };
  });

  // ── Tracker bridge (§10): manually echo a job's current outcome to its Item ──
  app.post('/fleet/tracker/echo', async req => {
    await extractAuth(req);
    const parsed = EchoJobSchema.safeParse(req.body);
    if (!parsed.success) badRequest(parsed.error.issues);
    const pid = getRequestProductId(req);
    return trackerBridge.echoJobToItem(pid, parsed.data.jobId, req.log);
  });
}
