/**
 * Fleet coordinator — the concurrency core (Phase 2 §4/§7/§8/§18/§25).
 *
 * Responsibilities:
 *  - submitJob       idempotent submit + submit-time dependency cycle detection
 *  - claimNextJob    atomic, single-winner claim (priority+age, deps + capability
 *                    gated) via a rev compare-and-swap, then lease acquisition
 *  - patchJobFenced  fenced state transition (rejects a stale leaseEpoch)
 *  - renewLease / releaseLease
 *  - heartbeat       factory liveness
 *  - reapExpiredLeases  reclaim dead-worker jobs (bumps leaseEpoch to fence the
 *                    zombie, returns the job to queued/blocked, preserves checkpoint)
 *
 * Concurrency model: `rev` is an optimistic-concurrency token on jobs + leases.
 * Every contended mutation goes through repository.revUpdate*, which writes only
 * if the stored `rev` still matches — so under contention EXACTLY ONE caller wins
 * and losers get a `conflict` and retry. Maps to Cosmos `_etag` / If-Match in prod.
 */

import { createHash } from 'node:crypto';
import { BadRequestError, ConflictError } from '../../lib/errors.js';
import * as repo from './repository.js';
import {
  selectJob,
  selectPreemptionVictim,
  scoreCandidate,
  capabilitiesSubset,
  type RunningJobView,
  type ScoreBreakdown,
  type SchedulerContext,
  type SchedulerFactory,
  type SchedulerWeights,
  type FleetWeightRegistry,
  resolveWeights,
} from './scheduler.js';
import {
  ACTIVE_STAGES,
  FLEET_STAGES,
  DEP_DONE_HARD,
  DEP_DONE_SOFT,
  PRIORITY_ORDER,
  type FleetJobDoc,
  type FleetLeaseDoc,
  type FleetRunDoc,
  type FleetStage,
  type FleetBudgetDoc,
  type BudgetWindow,
  type SubmitJobInput,
  type ReviewPolicy,
  type ReviewDecision,
  type ReviewDecisionKind,
} from './types.js';

const CLAIM_MAX_RETRIES = 8;

export function contentHash(bodyMd: string): string {
  return createHash('sha256').update(bodyMd).digest('hex');
}

// The capability-subset predicate + the §7 scoring/selection engine live in the
// pure, unit-tested scheduler module. Re-export the predicate here to preserve the
// coordinator's public surface (claimNextJob now ranks candidates via selectJob).
export { capabilitiesSubset } from './scheduler.js';

// ── Feature flags (Phase 3) ──────────────────────────────────────────────────

/** FLEET_PREEMPTION env gate — default OFF (byte-for-byte Phase 2 behavior). */
export function isPreemptionEnabled(): boolean {
  const v = (process.env.FLEET_PREEMPTION ?? '').trim().toLowerCase();
  return v === '1' || v === 'true' || v === 'on';
}

/** FLEET_BUDGETS env gate — default OFF (no budget enforcement). */
export function isBudgetsEnabled(): boolean {
  const v = (process.env.FLEET_BUDGETS ?? '').trim().toLowerCase();
  return v === '1' || v === 'true' || v === 'on';
}

/** Weight registry — in Phase 3 loaded from env/config; in-memory for now. */
let weightRegistry: FleetWeightRegistry = {};

export function setWeightRegistry(reg: FleetWeightRegistry): void {
  weightRegistry = reg;
}

export function getWeightRegistry(): FleetWeightRegistry {
  return weightRegistry;
}

// ── Dependency evaluation (§5) ────────────────────────────────────────────────

/** Unmet dependency keys for a job given the current store state. */
export async function unmetDeps(job: FleetJobDoc): Promise<string[]> {
  if (!job.deps || job.deps.length === 0) return [];
  const done = job.depsMode === 'soft' ? DEP_DONE_SOFT : DEP_DONE_HARD;
  const unmet: string[] = [];
  for (const depKey of job.deps) {
    const matches = await repo.findJobsByIdempotencyKey(job.productId, depKey);
    const satisfied = matches.some(m => done.includes(m.stage));
    if (!satisfied) unmet.push(depKey);
  }
  return unmet;
}

async function stageForDeps(job: FleetJobDoc): Promise<{ stage: FleetStage; unmet: string[] }> {
  const unmet = await unmetDeps(job);
  return { stage: unmet.length > 0 ? 'blocked' : 'queued', unmet };
}

/**
 * Submit-time cycle detection: would a job with `newKey` depending on `newDeps`
 * create a cycle in the idempotency-key dependency graph (existing jobs + new)?
 */
export async function wouldCreateCycle(
  productId: string,
  newKey: string,
  newDeps: string[]
): Promise<boolean> {
  if (newDeps.includes(newKey)) return true; // self-dependency
  const visited = new Set<string>();
  let frontier = [...newDeps];
  while (frontier.length > 0) {
    const next: string[] = [];
    for (const key of frontier) {
      if (key === newKey) return true; // reached back to the new node
      if (visited.has(key)) continue;
      visited.add(key);
      const matches = await repo.findJobsByIdempotencyKey(productId, key);
      for (const m of matches) next.push(...m.deps);
    }
    frontier = next;
  }
  return false;
}

// ── Submit (idempotent) ───────────────────────────────────────────────────────

export interface SubmitResult {
  job: FleetJobDoc;
  outcome: 'created' | 'deduplicated' | 'superseded';
}

export async function submitJob(productId: string, input: SubmitJobInput): Promise<SubmitResult> {
  const hash = contentHash(input.bodyMd);
  const existingForKey = await repo.findJobsByIdempotencyKey(productId, input.idempotencyKey);

  // Idempotency (§4): same key + identical content => return existing (no dup).
  const identical = existingForKey.find(j => j.contentHash === hash);
  if (identical) return { job: identical, outcome: 'deduplicated' };

  if (existingForKey.length > 0) {
    // same key, different content
    const supersedable = existingForKey.find(j => j.stage === 'queued' || j.stage === 'blocked');
    if (!supersedable) {
      throw new ConflictError(
        `idempotency-key '${input.idempotencyKey}' already in use by an in-flight/terminal job with different content`
      );
    }
    // supersede the still-queued/blocked job in place
    if (await wouldCreateCycle(productId, input.idempotencyKey, input.deps)) {
      throw new BadRequestError('dependency cycle detected — submission rejected');
    }
    const refreshed = applyInputToJob(supersedable, input, hash);
    const { stage } = await stageForDeps(refreshed);
    const updated = await repo.updateJob(supersedable.id, productId, {
      ...stripIdentity(refreshed),
      stage,
    });
    await repo.appendEvent({
      jobId: supersedable.id,
      productId,
      type: 'superseded',
      data: { idempotencyKey: input.idempotencyKey },
    });
    return { job: updated ?? supersedable, outcome: 'superseded' };
  }

  // brand-new job
  if (await wouldCreateCycle(productId, input.idempotencyKey, input.deps)) {
    throw new BadRequestError('dependency cycle detected — submission rejected');
  }
  const now = new Date().toISOString();
  const id = `fjob_${crypto.randomUUID()}`;
  const base: FleetJobDoc = {
    id,
    productId,
    stage: 'queued',
    idempotencyKey: input.idempotencyKey,
    contentHash: hash,
    bodyMd: input.bodyMd,
    manifestSnapshot: {
      priority: input.priority,
      capabilities: input.capabilities,
      engineClass: input.engineClass,
      profile: input.profile,
      prefersEngine: input.prefersEngine,
      allowedScope: input.allowedScope,
      deps: input.deps,
      depsMode: input.depsMode,
      budget: input.budget,
      retry: input.retry,
    },
    priority: input.priority,
    priorityOrder: PRIORITY_ORDER[input.priority],
    capabilities: input.capabilities,
    engineClass: input.engineClass,
    profile: input.profile,
    deps: input.deps,
    depsMode: input.depsMode,
    budget: input.budget,
    retry: input.retry,
    kind: input.kind,
    parentId: input.parentId,
    trackerItemId: input.trackerItemId,
    attempts: 0,
    leaseEpoch: 0,
    rev: 0,
    createdAt: now,
    updatedAt: now,
  };
  const { stage } = await stageForDeps(base);
  base.stage = stage;

  // Phase 3 DAG: if children are specified, create them atomically and block parent
  const children = (input as { children?: SubmitJobInput['children'] }).children ?? [];
  if (children.length > 0) {
    base.kind = 'composite';
    // Parent is blocked until all children complete
    const childKeys = children.map(c => c.idempotencyKey);
    base.deps = [...base.deps, ...childKeys];
    base.stage = 'blocked';
    base.blockedReason = `waiting on children: ${childKeys.join(', ')}`;
  }

  const created = await repo.createJob(base);

  // Create child jobs
  for (const child of children) {
    const childId = `fjob_${crypto.randomUUID()}`;
    const childNow = new Date().toISOString();
    const childHash = contentHash(child.bodyMd);
    const childPriority = child.priority ?? input.priority;
    const childJob: FleetJobDoc = {
      id: childId,
      productId,
      stage: 'queued',
      idempotencyKey: child.idempotencyKey,
      contentHash: childHash,
      bodyMd: child.bodyMd,
      manifestSnapshot: {
        priority: childPriority,
        capabilities: child.capabilities,
        engineClass: child.engineClass,
        profile: child.profile,
        prefersEngine: child.prefersEngine,
        allowedScope: child.allowedScope,
        deps: child.deps,
        depsMode: child.depsMode,
        budget: child.budget,
        retry: child.retry,
      },
      priority: childPriority,
      priorityOrder: PRIORITY_ORDER[childPriority],
      capabilities: child.capabilities,
      engineClass: child.engineClass,
      profile: child.profile,
      deps: child.deps,
      depsMode: child.depsMode,
      budget: child.budget,
      retry: child.retry,
      kind: 'leaf',
      parentId: id,
      attempts: 0,
      leaseEpoch: 0,
      rev: 0,
      createdAt: childNow,
      updatedAt: childNow,
    };
    const { stage: childStage } = await stageForDeps(childJob);
    childJob.stage = childStage;
    await repo.createJob(childJob);
    await repo.appendEvent({
      jobId: childId,
      productId,
      type: 'submitted',
      data: { stage: childStage, idempotencyKey: child.idempotencyKey, parentId: id },
    });
  }

  await repo.appendEvent({
    jobId: id,
    productId,
    type: 'submitted',
    data: { stage: base.stage, idempotencyKey: input.idempotencyKey, childCount: children.length },
  });
  return { job: created, outcome: 'created' };
}

function applyInputToJob(job: FleetJobDoc, input: SubmitJobInput, hash: string): FleetJobDoc {
  return {
    ...job,
    contentHash: hash,
    bodyMd: input.bodyMd,
    priority: input.priority,
    priorityOrder: PRIORITY_ORDER[input.priority],
    capabilities: input.capabilities,
    engineClass: input.engineClass,
    profile: input.profile,
    deps: input.deps,
    depsMode: input.depsMode,
    budget: input.budget,
    retry: input.retry,
    manifestSnapshot: {
      priority: input.priority,
      capabilities: input.capabilities,
      engineClass: input.engineClass,
      profile: input.profile,
      prefersEngine: input.prefersEngine,
      allowedScope: input.allowedScope,
      deps: input.deps,
      depsMode: input.depsMode,
      budget: input.budget,
      retry: input.retry,
    },
  };
}

function stripIdentity(job: FleetJobDoc): Partial<FleetJobDoc> {
  const { id: _id, productId: _pid, createdAt: _c, rev: _r, ...rest } = job;
  return rest;
}

// ── Atomic claim (§4/§7) ────────────────────────────────────────────────────

export interface ClaimContext {
  productId: string;
  factoryId: string;
  capabilities: string[];
  leaseSeconds: number;
  // ── §7 scheduler inputs (additive, optional — sane defaults below) ──
  /** Factory health; below the floor (`down`) the factory claims nothing. */
  health?: 'ok' | 'degraded' | 'down';
  /** Current factory load (busier ⇒ lower score). */
  load?: number;
  /** Per-engine seat limit (carried for scoring/future seat-aware routing). */
  seatLimit?: number;
  /** Engines this factory runs (prefers-engine affinity). */
  factoryEngines?: string[];
  /** Scopes (repos/locks) the factory has warm (repo-stickiness affinity). */
  warmScopes?: string[];
  /** Factory/budget cost ceiling in USD (cost-fit). */
  costCeilingUsd?: number;
  // ── Phase 3 additions ──
  /** Per-request weight overrides (Phase 3 tunability). */
  weights?: Partial<SchedulerWeights>;
}

export interface ClaimResult {
  job: FleetJobDoc;
  lease: FleetLeaseDoc;
  run: FleetRunDoc;
}

/**
 * Resolve which stage-eligible (queued/blocked) jobs currently have their deps
 * satisfied. This is the store-backed (async) half of eligibility; the pure
 * capability-subset filter + §7 scoring + tie-break are applied by `selectJob`.
 */
async function depsSatisfiedIds(jobs: FleetJobDoc[]): Promise<Set<string>> {
  const satisfied = new Set<string>();
  for (const job of jobs) {
    if (job.stage !== 'queued' && job.stage !== 'blocked') continue;
    if ((await unmetDeps(job)).length === 0) satisfied.add(job.id);
  }
  return satisfied;
}

/**
 * Try to claim ONE specific job version. The rev compare-and-swap is the single
 * point at which exactly one contender wins; a stale `rev` => conflict (no write,
 * no double-assignment).
 */
export async function tryClaimJob(
  job: FleetJobDoc,
  ctx: ClaimContext
): Promise<repo.RevResult<ClaimResult>> {
  const newEpoch = job.leaseEpoch + 1;
  const attempt = job.attempts + 1;
  const claimed = await repo.revUpdateJob(job.id, ctx.productId, job.rev, {
    stage: 'assigned',
    leaseEpoch: newEpoch,
    attempts: attempt,
  });
  if (!claimed.ok) return claimed;

  const now = Date.now();
  const expiresAt = new Date(now + ctx.leaseSeconds * 1000).toISOString();
  const nowIso = new Date(now).toISOString();

  const existingLease = await repo.getLease(job.id);
  let lease: FleetLeaseDoc;
  if (existingLease) {
    const updated = await repo.revUpdateLease(job.id, existingLease.rev, {
      holderFactoryId: ctx.factoryId,
      expiresAt,
      leaseEpoch: newEpoch,
      renewals: 0,
      status: 'held',
    });
    lease = updated.ok ? updated.doc : existingLease;
  } else {
    lease = await repo.createLease({
      id: job.id,
      productId: ctx.productId,
      jobId: job.id,
      holderFactoryId: ctx.factoryId,
      expiresAt,
      leaseEpoch: newEpoch,
      renewals: 0,
      status: 'held',
      rev: 0,
      updatedAt: nowIso,
    });
  }

  const run = await repo.createRun({
    id: `${job.id}:run:${attempt}`,
    productId: ctx.productId,
    jobId: job.id,
    attempt,
    factoryId: ctx.factoryId,
    engine: job.engineClass ?? 'unknown',
    startedAt: nowIso,
    insights: {},
  });

  await repo.appendEvent({
    jobId: job.id,
    productId: ctx.productId,
    type: 'assigned',
    actor: ctx.factoryId,
    data: { leaseEpoch: newEpoch, attempt },
  });

  return { ok: true, doc: { job: claimed.doc, lease, run } };
}

/**
 * Select the best eligible job via the §7 scoring engine and atomically claim it.
 *
 * The coordinator owns all I/O: it lists candidates, resolves deps (store-backed)
 * into a pure predicate, and builds the factory view + authoritative `now`. The
 * pure `selectJob` then applies the capability hard-filter + fixed-weight scoring
 * + deterministic tie-break (priority → age → cost class). The atomic
 * single-winner guarantee remains entirely in `tryClaimJob`'s rev compare-and-swap,
 * which is unchanged — on conflict we re-select and retry.
 *
 * Phase 3: weights are resolved via resolveWeights (per-product tunable).
 * Phase 3: when FLEET_PREEMPTION=1 and selectJob returns null, attempt preemption
 * of a strictly-lower-priority running job for any CRITICAL queued job.
 */
export async function claimNextJob(ctx: ClaimContext): Promise<ClaimResult | null> {
  const factory: SchedulerFactory = {
    capabilities: ctx.capabilities,
    health: ctx.health ?? 'ok',
    load: ctx.load ?? 0,
    seatLimit: ctx.seatLimit ?? 1,
  };
  const weights = resolveWeights(weightRegistry, ctx.productId, ctx.weights);
  const seatLimit = ctx.seatLimit ?? 1;

  for (let i = 0; i < CLAIM_MAX_RETRIES; i++) {
    // Phase 3: budget enforcement (FLEET_BUDGETS flag)
    if (isBudgetsEnabled()) {
      const budget = await repo.getBudget(ctx.productId);
      if (budget) {
        if (budget.status === 'paused') return null;
        if (budget.spentUsd >= budget.ceilingUsd) return null;
      }
    }

    const candidates = await repo.listJobs({ productId: ctx.productId });
    const satisfied = await depsSatisfiedIds(candidates);

    // Phase 3: seat-limit enforcement (when preemption is ON).
    // If this factory is at its seat limit, skip normal selection and try preemption.
    let atSeatLimit = false;
    if (isPreemptionEnabled()) {
      const activeJobs = candidates.filter(j => ACTIVE_STAGES.includes(j.stage));
      let heldCount = 0;
      for (const aj of activeJobs) {
        const lease = await repo.getLease(aj.id);
        if (lease && lease.holderFactoryId === ctx.factoryId && lease.status === 'held') {
          heldCount++;
        }
      }
      atSeatLimit = heldCount >= seatLimit;
    }

    let pick: FleetJobDoc | null = null;
    if (!atSeatLimit) {
      const schedulerCtx: SchedulerContext = {
        now: Date.now(),
        isDepsSatisfied: job => satisfied.has(job.id),
        factoryEngines: ctx.factoryEngines,
        warmScopes: ctx.warmScopes,
        costCeilingUsd: ctx.costCeilingUsd,
      };
      pick = selectJob(candidates, factory, schedulerCtx, weights);
    }

    if (pick) {
      const result = await tryClaimJob(pick, ctx);
      if (result.ok) return result.doc;
      if (result.reason === 'not_found') continue;
      continue;
    }

    // No eligible job found (or at seat limit) — attempt preemption if enabled
    if (!isPreemptionEnabled()) return null;

    // Find critical queued/blocked jobs that are deps-satisfied
    const criticalCandidates = candidates.filter(
      j =>
        (j.stage === 'queued' || j.stage === 'blocked') &&
        j.priorityOrder === 0 && // critical
        satisfied.has(j.id)
    );
    if (criticalCandidates.length === 0) return null;

    // Get running jobs on this factory
    const allJobs = candidates.filter(j => ACTIVE_STAGES.includes(j.stage));
    const runningOnFactory: RunningJobView[] = [];
    for (const rj of allJobs) {
      const lease = await repo.getLease(rj.id);
      if (lease && lease.holderFactoryId === ctx.factoryId && lease.status === 'held') {
        runningOnFactory.push({
          id: rj.id,
          productId: rj.productId,
          priority: rj.priority,
          priorityOrder: rj.priorityOrder,
          leaseEpoch: rj.leaseEpoch,
          checkpoint: rj.checkpoint,
        });
      }
    }
    if (runningOnFactory.length === 0) return null;

    // Try to preempt for the highest-priority critical candidate
    for (const critJob of criticalCandidates) {
      const decision = selectPreemptionVictim(critJob, runningOnFactory, factory);
      if (!decision) continue;

      // Execute eviction: bump epoch (fence zombie), requeue with preserved checkpoint
      const victimJob = await repo.getJob(decision.evict, ctx.productId);
      if (!victimJob) continue;
      const newEpoch = victimJob.leaseEpoch + 1;
      const victimUnmet = await unmetDeps(victimJob);
      const returnStage: FleetStage = victimUnmet.length > 0 ? 'blocked' : 'queued';
      const evictRes = await repo.revUpdateJob(decision.evict, ctx.productId, victimJob.rev, {
        stage: returnStage,
        leaseEpoch: newEpoch,
        blockedReason: victimUnmet.length > 0 ? `waiting on: ${victimUnmet.join(', ')}` : undefined,
      });
      if (!evictRes.ok) continue;

      // Mark lease as expired (fence the zombie's stale reports)
      const victimLease = await repo.getLease(decision.evict);
      if (victimLease) {
        await repo.revUpdateLease(decision.evict, victimLease.rev, {
          status: 'expired',
          leaseEpoch: newEpoch,
          holderFactoryId: undefined,
        });
      }

      await repo.appendEvent({
        jobId: decision.evict,
        productId: ctx.productId,
        type: 'preempted',
        actor: ctx.factoryId,
        data: { reason: decision.reason, preemptedBy: critJob.id, returnedTo: returnStage },
      });

      // Now claim the critical job
      const claimResult = await tryClaimJob(critJob, ctx);
      if (claimResult.ok) return claimResult.doc;
      break;
    }
  }
  return null;
}

// ── Fenced transitions + leases (§4/§8) ──────────────────────────────────────

export type FenceResult<T> =
  | { ok: true; doc: T }
  | { ok: false; reason: 'not_found' | 'fenced' | 'conflict' };

function fenced(job: FleetJobDoc, leaseEpoch: number): boolean {
  // a call older than the current epoch is a stale/zombie worker — reject it
  return leaseEpoch < job.leaseEpoch;
}

export interface PatchJobInputInternal {
  leaseEpoch: number;
  stage?: FleetStage;
  checkpoint?: FleetJobDoc['checkpoint'];
  blockedReason?: string;
}

export async function patchJobFenced(
  jobId: string,
  productId: string,
  patch: PatchJobInputInternal
): Promise<FenceResult<FleetJobDoc>> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return { ok: false, reason: 'not_found' };
  if (fenced(job, patch.leaseEpoch)) return { ok: false, reason: 'fenced' };

  const updates: Partial<FleetJobDoc> = {};
  if (patch.stage) updates.stage = patch.stage;
  if (patch.checkpoint) updates.checkpoint = patch.checkpoint;
  if (patch.blockedReason !== undefined) updates.blockedReason = patch.blockedReason;

  const res = await repo.revUpdateJob(jobId, productId, job.rev, updates);
  if (!res.ok) return { ok: false, reason: res.reason === 'not_found' ? 'not_found' : 'conflict' };
  await repo.appendEvent({
    jobId,
    productId,
    type: 'transition',
    data: { stage: patch.stage ?? job.stage, leaseEpoch: patch.leaseEpoch },
  });

  // Phase 3 DAG: when a child reaches a terminal dep-satisfying stage,
  // check if its parent should be unblocked.
  if (patch.stage && job.parentId) {
    await maybeUnblockParent(job.parentId, productId);
  }

  // Phase 3 budgets: accrue spend when a job ships (terminal success).
  // Flag-gated + best-effort — an accrual failure NEVER fails the transition.
  // Idempotent per run via `<jobId>:<leaseEpoch>` so duplicate ship transitions do
  // not double-count. Accrue the run's ACTUAL cost (insights.costUsd) so spentUsd
  // and the costBurndown (which also reads insights.costUsd) agree.
  if (patch.stage === 'shipped' && isBudgetsEnabled()) {
    try {
      const runId = `${jobId}:${job.leaseEpoch}`;
      const runs = await repo.listRunsByJob(jobId);
      const latest = runs.reduce<FleetRunDoc | undefined>(
        (acc, r) => (!acc || r.attempt > acc.attempt ? r : acc),
        undefined
      );
      await accrueSpend(productId, latest?.insights?.costUsd ?? 0, runId);
    } catch {
      // swallow — budget accounting is downstream of the job lifecycle
    }
  }

  return { ok: true, doc: res.doc };
}

// ── Phase 3 DAG: parent unblocking + child submission + subtree query ─────────

/**
 * Check if a parent job's deps are all satisfied and unblock it if so.
 * Called after a child job transitions to a terminal stage.
 */
export async function maybeUnblockParent(parentId: string, productId: string): Promise<void> {
  const parent = await repo.getJob(parentId, productId);
  if (!parent) return;
  if (parent.stage !== 'blocked') return;
  const unmet = await unmetDeps(parent);
  if (unmet.length === 0) {
    await repo.revUpdateJob(parentId, productId, parent.rev, {
      stage: 'queued',
      blockedReason: undefined,
    });
    await repo.appendEvent({
      jobId: parentId,
      productId,
      type: 'unblocked',
      data: { reason: 'all children/deps completed' },
    });
  }
}

/** Submit children for an existing parent (POST /fleet/jobs/:id/children). */
export async function submitChildren(
  parentId: string,
  productId: string,
  children: Array<{
    idempotencyKey: string;
    bodyMd: string;
    priority?: 'critical' | 'high' | 'medium' | 'low';
    capabilities?: string[];
    engineClass?: string;
    profile?: string;
    prefersEngine?: string[];
    allowedScope?: string[];
    deps?: string[];
    depsMode?: 'hard' | 'soft';
    budget?: { usd?: number; tokens?: number; wall?: number };
    retry?: { max: number; backoff?: string; on?: string[] };
  }>
): Promise<{ parent: FleetJobDoc; childJobs: FleetJobDoc[] }> {
  const parent = await repo.getJob(parentId, productId);
  if (!parent) throw new BadRequestError('Parent job not found');

  // Cycle check (batch-aware): after submit the parent depends on EVERY child key,
  // so a cycle exists if any child — through its own declared deps, resolved across
  // both this unpersisted batch and existing jobs — can reach the parent or itself.
  // `wouldCreateCycle` alone only walks persisted jobs, so it misses sibling/declared
  // edges within the batch; this resolver covers both.
  const childKeys = children.map(c => c.idempotencyKey);
  const seenKeys = new Set<string>();
  for (const ck of childKeys) {
    if (seenKeys.has(ck)) throw new BadRequestError(`duplicate child idempotency-key '${ck}'`);
    seenKeys.add(ck);
  }
  const batchDeps = new Map<string, string[]>();
  for (const c of children) batchDeps.set(c.idempotencyKey, c.deps ?? []);
  const depsOf = async (key: string): Promise<string[]> => {
    if (batchDeps.has(key)) return batchDeps.get(key) ?? [];
    const matches = await repo.findJobsByIdempotencyKey(productId, key);
    return matches.flatMap(m => m.deps);
  };
  for (const ck of childKeys) {
    const visited = new Set<string>();
    let frontier = await depsOf(ck);
    while (frontier.length > 0) {
      const nextFrontier: string[] = [];
      for (const k of frontier) {
        if (k === parent.idempotencyKey || k === ck) {
          throw new BadRequestError(`dependency cycle detected for child '${ck}'`);
        }
        if (visited.has(k)) continue;
        visited.add(k);
        nextFrontier.push(...(await depsOf(k)));
      }
      frontier = nextFrontier;
    }
  }

  const createdChildren: FleetJobDoc[] = [];
  for (const child of children) {
    const childId = `fjob_${crypto.randomUUID()}`;
    const now = new Date().toISOString();
    const childPriority = child.priority ?? parent.priority;
    const childJob: FleetJobDoc = {
      id: childId,
      productId,
      stage: 'queued',
      idempotencyKey: child.idempotencyKey,
      contentHash: contentHash(child.bodyMd),
      bodyMd: child.bodyMd,
      manifestSnapshot: {
        priority: childPriority,
        capabilities: child.capabilities ?? [],
        engineClass: child.engineClass as FleetJobDoc['engineClass'],
        profile: child.profile,
        prefersEngine: child.prefersEngine ?? [],
        allowedScope: child.allowedScope ?? [],
        deps: child.deps ?? [],
        depsMode: child.depsMode as FleetJobDoc['depsMode'],
        budget: child.budget,
        retry: child.retry as FleetJobDoc['retry'],
      },
      priority: childPriority,
      priorityOrder: PRIORITY_ORDER[childPriority],
      capabilities: child.capabilities ?? [],
      engineClass: child.engineClass as FleetJobDoc['engineClass'],
      profile: child.profile,
      deps: child.deps ?? [],
      depsMode: child.depsMode as FleetJobDoc['depsMode'],
      budget: child.budget,
      retry: child.retry as FleetJobDoc['retry'],
      kind: 'leaf',
      parentId,
      attempts: 0,
      leaseEpoch: 0,
      rev: 0,
      createdAt: now,
      updatedAt: now,
    };
    const { stage } = await stageForDeps(childJob);
    childJob.stage = stage;
    const created = await repo.createJob(childJob);
    createdChildren.push(created);
    await repo.appendEvent({
      jobId: childId,
      productId,
      type: 'submitted',
      data: { stage, idempotencyKey: child.idempotencyKey, parentId },
    });
  }

  // Block parent on new children
  const existingDeps = parent.deps ?? [];
  const newDeps = [...existingDeps, ...childKeys];
  const updatedParent = await repo.revUpdateJob(parentId, productId, parent.rev, {
    kind: 'composite',
    deps: newDeps,
    stage: 'blocked',
    blockedReason: `waiting on children: ${childKeys.join(', ')}`,
  });

  return { parent: updatedParent.ok ? updatedParent.doc : parent, childJobs: createdChildren };
}

/** DAG node representation for the subtree endpoint. */
export interface DagNode {
  id: string;
  idempotencyKey: string;
  stage: string;
  priority: string;
  kind: string;
  parentId?: string;
  children: DagNode[];
}

/** Get the DAG subtree rooted at jobId. */
export async function getDagSubtree(jobId: string, productId: string): Promise<DagNode | null> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return null;

  async function buildNode(j: FleetJobDoc): Promise<DagNode> {
    const children = await repo.listChildrenByParent(productId, j.id);
    const childNodes = await Promise.all(children.map(buildNode));
    return {
      id: j.id,
      idempotencyKey: j.idempotencyKey,
      stage: j.stage,
      priority: j.priority,
      kind: j.kind,
      parentId: j.parentId,
      children: childNodes,
    };
  }

  return buildNode(job);
}

export async function renewLease(
  jobId: string,
  productId: string,
  leaseEpoch: number,
  leaseSeconds: number
): Promise<FenceResult<FleetLeaseDoc>> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return { ok: false, reason: 'not_found' };
  if (fenced(job, leaseEpoch)) return { ok: false, reason: 'fenced' };
  const lease = await repo.getLease(jobId);
  if (!lease) return { ok: false, reason: 'not_found' };
  const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
  const res = await repo.revUpdateLease(jobId, lease.rev, {
    expiresAt,
    renewals: lease.renewals + 1,
    status: 'held',
  });
  if (!res.ok) return { ok: false, reason: 'conflict' };
  await repo.appendEvent({ jobId, productId, type: 'lease_renewed', data: { leaseEpoch } });
  return { ok: true, doc: res.doc };
}

export async function releaseLease(
  jobId: string,
  productId: string,
  leaseEpoch: number,
  stage?: FleetStage,
  report?: { insights?: FleetRunDoc['insights']; result?: FleetRunDoc['result'] }
): Promise<FenceResult<FleetLeaseDoc>> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return { ok: false, reason: 'not_found' };
  if (fenced(job, leaseEpoch)) return { ok: false, reason: 'fenced' };
  const lease = await repo.getLease(jobId);
  if (!lease) return { ok: false, reason: 'not_found' };
  const res = await repo.revUpdateLease(jobId, lease.rev, { status: 'released' });
  if (!res.ok) return { ok: false, reason: 'conflict' };
  if (stage) await repo.revUpdateJob(jobId, productId, job.rev, { stage });
  // Record the factory's reported cost/token metrics + outcome on the current run.
  if (report?.insights || report?.result) {
    const runId = `${jobId}:run:${job.attempts}`;
    await repo.updateRun(runId, jobId, {
      ...(report.insights ? { insights: report.insights } : {}),
      ...(report.result ? { result: report.result } : {}),
      endedAt: new Date().toISOString(),
    });
  }
  await repo.appendEvent({ jobId, productId, type: 'lease_released', data: { leaseEpoch, stage } });
  return { ok: true, doc: res.doc };
}

// ── Operator actions (§14 Phase 3 — approve/ship/reject/requeue) ──────────────

export const OPERATOR_ACTIONS = ['requeue', 'reject', 'cancel', 'ship'] as const;
export type OperatorActionKind = (typeof OPERATOR_ACTIONS)[number];

/** Bounded retries for the operator-action optimistic-concurrency loop. */
const OPERATOR_ACTION_MAX_RETRIES = 5;

export type OperatorActionResult =
  | { ok: true; doc: FleetJobDoc }
  | { ok: false; reason: 'not_found' | 'conflict' | 'invalid_state' };

/**
 * Operator-initiated lifecycle action from the control plane. Unlike
 * `patchJobFenced`, the operator does NOT hold a lease — so this bumps the
 * `leaseEpoch` to FENCE any current factory holder (a late report from the
 * displaced worker is rejected by `fenced()`), mirroring the reaper (§25.3).
 *
 * - `requeue`: return the job to `queued` (or `blocked` if deps unmet) for a
 *    fresh claim. The checkpoint pointer is preserved (resume-friendly).
 * - `reject`:  move the job to `dead_letter` (operator gives up on it).
 * - `cancel`:  move the job to `failed` (stop work, not a dead letter).
 *
 * A successfully shipped job is terminal and cannot be acted on.
 */
export async function operatorAction(
  jobId: string,
  productId: string,
  action: OperatorActionKind
): Promise<OperatorActionResult> {
  // Retry on optimistic-concurrency conflict (real Cosmos read-after-write lag),
  // mirroring submitReview — a single attempt can spuriously 409 right after a
  // prior transition (e.g. review->testing) has not yet settled.
  for (let attempt = 0; attempt < OPERATOR_ACTION_MAX_RETRIES; attempt++) {
    const job = await repo.getJob(jobId, productId);
    if (!job) return { ok: false, reason: 'not_found' };

    // Idempotent terminal actions: re-shipping/rejecting/cancelling a job already
    // in that terminal state is a no-op (don't bump the epoch or append a dup event).
    if (action === 'ship' && job.stage === 'shipped') return { ok: true, doc: job };
    if (job.stage === 'shipped') return { ok: false, reason: 'invalid_state' };
    if (action === 'reject' && job.stage === 'dead_letter') return { ok: true, doc: job };
    if (action === 'cancel' && job.stage === 'failed') return { ok: true, doc: job };

    const newEpoch = job.leaseEpoch + 1; // fence any current holder

    let stage: FleetStage;
    let blockedReason: string | undefined;
    if (action === 'requeue') {
      const unmet = await unmetDeps(job);
      stage = unmet.length > 0 ? 'blocked' : 'queued';
      blockedReason = unmet.length > 0 ? `waiting on: ${unmet.join(', ')}` : undefined;
    } else if (action === 'reject') {
      stage = 'dead_letter';
    } else if (action === 'ship') {
      // Operator/controller marks the job done (e.g. after the review gate routes
      // it to `testing`, where no factory holds a lease to release it).
      stage = 'shipped';
    } else {
      stage = 'failed';
    }

    const res = await repo.revUpdateJob(jobId, productId, job.rev, {
      stage,
      leaseEpoch: newEpoch,
      blockedReason,
    });
    if (!res.ok) {
      if (res.reason === 'not_found') return { ok: false, reason: 'not_found' };
      // conflict -> brief backoff so a read-after-write (real Cosmos) settles, then re-read
      await new Promise(r => globalThis.setTimeout(r, 40 * (attempt + 1)));
      continue;
    }

    // Free the seat: release any still-held lease, fencing it with the new epoch so
    // a stale renewal cannot resurrect it (mirrors the reaper, §25.3).
    const lease = await repo.getLease(jobId);
    if (lease && lease.status === 'held') {
      await repo.revUpdateLease(jobId, lease.rev, {
        status: 'released',
        leaseEpoch: newEpoch,
        holderFactoryId: undefined,
      });
    }

    await repo.appendEvent({
      jobId,
      productId,
      type: 'operator_action',
      actor: 'operator',
      data: { action, leaseEpoch: newEpoch, returnedTo: stage },
    });

    return { ok: true, doc: res.doc };
  }
  return { ok: false, reason: 'conflict' };
}

// ── Multi-reviewer human gate (§14 Phase 3 — review-policy routing) ────────────

/** Canonicalize a reviewer identity so allowlist + dedupe are case/space stable. */
function normalizeReviewer(id: string): string {
  return id.trim().toLowerCase();
}

export type RequestReviewResult =
  | { ok: true; doc: FleetJobDoc }
  | { ok: false; reason: 'not_found' | 'conflict' | 'invalid_state' | 'invalid_policy' };

export type SubmitReviewResult =
  | { ok: true; doc: FleetJobDoc; gate: 'pending' | 'approved' | 'rejected' }
  | {
      ok: false;
      reason: 'not_found' | 'conflict' | 'invalid_state' | 'not_authorized';
    };

/**
 * Route a job into the `review` human gate (§14). Only a `building` job can enter
 * review (its work product is ready to inspect). Like `operatorAction`, the
 * control plane holds no lease, so this bumps `leaseEpoch` to fence the building
 * worker and releases its lease. The policy is normalized + validated, and any
 * prior decisions are cleared so each review round starts fresh.
 */
export async function requestReview(
  jobId: string,
  productId: string,
  policy?: { requiredApprovals?: number; reviewers?: string[] }
): Promise<RequestReviewResult> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return { ok: false, reason: 'not_found' };
  if (job.stage !== 'building') return { ok: false, reason: 'invalid_state' };

  const requiredApprovals = Math.max(1, Math.floor(policy?.requiredApprovals ?? 1));
  const reviewers = Array.from(
    new Set((policy?.reviewers ?? []).map(normalizeReviewer).filter(Boolean))
  );
  // An allowlist smaller than the approval quorum can never be satisfied.
  if (reviewers.length > 0 && requiredApprovals > reviewers.length) {
    return { ok: false, reason: 'invalid_policy' };
  }
  const reviewPolicy: ReviewPolicy = { requiredApprovals, reviewers };

  const newEpoch = job.leaseEpoch + 1; // fence the building worker
  const res = await repo.revUpdateJob(jobId, productId, job.rev, {
    stage: 'review',
    leaseEpoch: newEpoch,
    reviewPolicy,
    reviewDecisions: [],
    blockedReason: undefined,
  });
  if (!res.ok) {
    return { ok: false, reason: res.reason === 'not_found' ? 'not_found' : 'conflict' };
  }

  const lease = await repo.getLease(jobId);
  if (lease && lease.status === 'held') {
    await repo.revUpdateLease(jobId, lease.rev, {
      status: 'released',
      leaseEpoch: newEpoch,
      holderFactoryId: undefined,
    });
  }

  await repo.appendEvent({
    jobId,
    productId,
    type: 'review_requested',
    actor: 'operator',
    data: { requiredApprovals, reviewers, leaseEpoch: newEpoch },
  });

  return { ok: true, doc: res.doc };
}

const REVIEW_MAX_RETRIES = 4;

/**
 * Record one reviewer's decision against a job in the `review` gate (§14):
 *  - any `reject` is a veto → the job returns to `queued` for rework (fenced),
 *  - once distinct approvals reach `requiredApprovals` → the job advances to
 *    `testing`,
 *  - otherwise the decision is recorded and the job stays in `review`.
 *
 * Decisions are upserted per reviewer (last-write-wins). The read-modify-write is
 * guarded by the job `rev` CAS; on a concurrent-writer conflict we re-read and
 * retry a bounded number of times before surfacing `conflict`.
 */
export async function submitReview(
  jobId: string,
  productId: string,
  input: { reviewer: string; decision: ReviewDecisionKind; note?: string }
): Promise<SubmitReviewResult> {
  const reviewer = normalizeReviewer(input.reviewer);

  for (let attempt = 0; attempt < REVIEW_MAX_RETRIES; attempt++) {
    const job = await repo.getJob(jobId, productId);
    if (!job) return { ok: false, reason: 'not_found' };
    if (job.stage !== 'review') return { ok: false, reason: 'invalid_state' };

    const allowlist = job.reviewPolicy?.reviewers ?? [];
    if (allowlist.length > 0 && !allowlist.includes(reviewer)) {
      return { ok: false, reason: 'not_authorized' };
    }

    const decision: ReviewDecision = {
      reviewer,
      decision: input.decision,
      at: new Date().toISOString(),
      ...(input.note ? { note: input.note } : {}),
    };
    // Upsert: one current decision per reviewer (last-write-wins).
    const decisions = (job.reviewDecisions ?? [])
      .filter(d => d.reviewer !== reviewer)
      .concat(decision);

    if (input.decision === 'reject') {
      const newEpoch = job.leaseEpoch + 1; // fence any holder before rework
      const res = await repo.revUpdateJob(jobId, productId, job.rev, {
        stage: 'queued',
        leaseEpoch: newEpoch,
        reviewDecisions: decisions,
        blockedReason: `review rejected by ${reviewer}${input.note ? `: ${input.note}` : ''}`,
      });
      if (!res.ok) {
        if (res.reason === 'not_found') return { ok: false, reason: 'not_found' };
        continue; // conflict → re-read + retry
      }
      const lease = await repo.getLease(jobId);
      if (lease && lease.status === 'held') {
        await repo.revUpdateLease(jobId, lease.rev, {
          status: 'released',
          leaseEpoch: newEpoch,
          holderFactoryId: undefined,
        });
      }
      await repo.appendEvent({
        jobId,
        productId,
        type: 'review_rejected',
        actor: reviewer,
        data: { decisions, leaseEpoch: newEpoch, note: input.note },
      });
      return { ok: true, doc: res.doc, gate: 'rejected' };
    }

    // Approve: count DISTINCT approving reviewers against the quorum.
    const approvals = new Set(decisions.filter(d => d.decision === 'approve').map(d => d.reviewer))
      .size;
    const required = job.reviewPolicy?.requiredApprovals ?? 1;
    const satisfied = approvals >= required;

    const res = await repo.revUpdateJob(jobId, productId, job.rev, {
      stage: satisfied ? 'testing' : 'review',
      reviewDecisions: decisions,
    });
    if (!res.ok) {
      if (res.reason === 'not_found') return { ok: false, reason: 'not_found' };
      continue; // conflict → re-read + retry
    }
    await repo.appendEvent({
      jobId,
      productId,
      type: satisfied ? 'review_approved' : 'review_decision',
      actor: reviewer,
      data: { decision: 'approve', approvals, required, decisions },
    });
    return { ok: true, doc: res.doc, gate: satisfied ? 'approved' : 'pending' };
  }

  return { ok: false, reason: 'conflict' };
}

/** One factory's scored explanation for a job (already-weighted breakdown). */
export interface FactoryScoreExplain {
  factoryId: string;
  eligible: boolean;
  ineligibleReasons: string[];
  score: number;
  breakdown: ScoreBreakdown;
}

/** Full explainability payload for a job against the current fleet. */
export interface JobExplain {
  jobId: string;
  stage: FleetStage;
  weights: SchedulerWeights;
  depsSatisfied: boolean;
  unmetDeps: string[];
  factories: FactoryScoreExplain[];
  bestFactoryId: string | null;
}

/**
 * Explain how a job would be scored against every live factory for its product
 * (§7 scoring surfaced for the control plane). Read-only and side-effect free:
 * it re-runs the same `scoreCandidate` the scheduler uses, against persisted
 * factory state, so operators can see WHY a job routes (or fails to route).
 *
 * Affinity (prefers-engine) and cost-fit depend on claim-time hints that are not
 * persisted on the factory doc, so they score as neutral here — the breakdown
 * reflects the structural signals available from stored state.
 */
export async function explainJob(jobId: string, productId: string): Promise<JobExplain | null> {
  const job = await repo.getJob(jobId, productId);
  if (!job) return null;

  const weights = resolveWeights(weightRegistry, productId);
  const unmet = await unmetDeps(job);
  const depsSatisfied = unmet.length === 0;
  const factories = await repo.listFactories(productId);
  const ctx: SchedulerContext = { now: Date.now() };

  const scored: FactoryScoreExplain[] = factories.map(f => {
    const sf: SchedulerFactory = {
      capabilities: f.capabilities,
      health: f.health,
      load: f.load,
      seatLimit: f.seatLimit,
    };
    const { score, breakdown } = scoreCandidate(job, sf, ctx, weights);
    const reasons: string[] = [];
    if ((f.health ?? 'ok') === 'down') reasons.push('factory health is down');
    if (!capabilitiesSubset(job.capabilities ?? [], f.capabilities)) {
      reasons.push('missing required capabilities');
    }
    if (!depsSatisfied) reasons.push(`unmet deps: ${unmet.join(', ')}`);
    return {
      factoryId: f.factoryId,
      eligible: reasons.length === 0,
      ineligibleReasons: reasons,
      score,
      breakdown,
    };
  });
  scored.sort((a, b) => b.score - a.score);
  const best = scored.find(s => s.eligible) ?? null;

  return {
    jobId,
    stage: job.stage,
    weights,
    depsSatisfied,
    unmetDeps: unmet,
    factories: scored,
    bestFactoryId: best?.factoryId ?? null,
  };
}

export interface HeartbeatContext {
  productId: string;
  factoryId: string;
  descriptor?: Record<string, unknown>;
  capabilities?: string[];
  health?: FleetFactoryHealthInput;
  load?: number;
  seatLimit?: number;
}
type FleetFactoryHealthInput = 'ok' | 'degraded' | 'down';

export async function heartbeat(ctx: HeartbeatContext): Promise<repo.RevResult<true>> {
  const now = new Date().toISOString();
  const existing = await repo.getFactory(ctx.factoryId, ctx.productId);
  await repo.upsertFactory({
    id: ctx.factoryId,
    productId: ctx.productId,
    factoryId: ctx.factoryId,
    descriptor: ctx.descriptor ?? existing?.descriptor ?? {},
    capabilities: ctx.capabilities ?? existing?.capabilities ?? [],
    health: ctx.health ?? existing?.health ?? 'ok',
    load: ctx.load ?? existing?.load ?? 0,
    seatLimit: ctx.seatLimit ?? existing?.seatLimit ?? 1,
    lastHeartbeatAt: now,
  });
  return { ok: true, doc: true };
}

/** A factory is stale if its last heartbeat is older than `maxAgeMs`. */
export function isFactoryStale(
  factory: { lastHeartbeatAt: string },
  nowMs: number,
  maxAgeMs: number
): boolean {
  return nowMs - new Date(factory.lastHeartbeatAt).getTime() > maxAgeMs;
}

// ── Phase 3: Per-product budgets (§11/§13) ────────────────────────────────────

/**
 * Get or create a product's budget. Returns existing budget or null if none set.
 */
export async function getBudget(productId: string): Promise<FleetBudgetDoc | null> {
  return repo.getBudget(productId);
}

/** Upsert a product's budget (ceiling + window). */
export async function upsertBudget(
  productId: string,
  ceilingUsd: number,
  window: BudgetWindow = 'monthly'
): Promise<FleetBudgetDoc> {
  const now = new Date().toISOString();
  const existing = await repo.getBudget(productId);
  const doc: FleetBudgetDoc = {
    id: productId,
    productId,
    ceilingUsd,
    window,
    spentUsd: existing?.spentUsd ?? 0,
    status: existing?.status ?? 'active',
    windowStart: existing?.windowStart ?? now,
    accruedRunIds: existing?.accruedRunIds ?? [],
    updatedAt: now,
  };
  return repo.upsertBudget(doc);
}

/** Pause a product's budget — claims for this product are blocked. */
export async function pauseBudget(productId: string): Promise<FleetBudgetDoc | null> {
  return repo.updateBudget(productId, { status: 'paused' });
}

/** Resume a product's budget — claims for this product proceed again. */
export async function resumeBudget(productId: string): Promise<FleetBudgetDoc | null> {
  return repo.updateBudget(productId, { status: 'active' });
}

/**
 * Accrue spend for a product after a run completes.
 *
 * Idempotent per run: `runId` identifies the completed run (e.g. `<jobId>:<leaseEpoch>`).
 * If that runId was already accrued, this is a no-op — re-delivering the same completion
 * (retries, duplicate transitions) never double-counts. Spend is monotonic. Auto-pauses
 * the product when the ceiling is reached.
 */
export async function accrueSpend(
  productId: string,
  costUsd: number,
  runId?: string
): Promise<FleetBudgetDoc | null> {
  const budget = await repo.getBudget(productId);
  if (!budget) return null;
  // Idempotency guard: skip if this run was already accrued (retries / duplicate
  // ship transitions never double-count; spend stays monotonic).
  if (runId && (budget.accruedRunIds ?? []).includes(runId)) return budget;
  const updates: Partial<FleetBudgetDoc> = {};
  if (runId) updates.accruedRunIds = [...(budget.accruedRunIds ?? []), runId];
  if (costUsd > 0) {
    const newSpent = budget.spentUsd + costUsd;
    updates.spentUsd = newSpent;
    // Auto-pause when ceiling exceeded
    if (newSpent >= budget.ceilingUsd && budget.status === 'active') {
      updates.status = 'paused';
    }
  }
  if (Object.keys(updates).length === 0) return budget;
  return repo.updateBudget(productId, updates);
}

// ── Cost burndown (§14 Phase 3 — spend-over-time vs ceiling) ──────────────────

/** One day of the burndown series. */
export interface BurndownPoint {
  date: string; // YYYY-MM-DD (UTC)
  costUsd: number; // spend attributed to that day
  cumulativeUsd: number; // running total up to and including that day
}

export interface CostBurndown {
  productId: string;
  ceilingUsd: number | null; // budget ceiling, if configured
  window: BudgetWindow | null;
  totalUsd: number;
  days: BurndownPoint[];
}

/**
 * Aggregate completed run cost by UTC day for the last `days` days (default 30),
 * returning a cumulative burndown series plus the budget ceiling for overlay.
 * Read-only. Costs come from each run's `insights.costUsd`; a run is attributed
 * to the day it ended (or started, if still in flight without an end time).
 */
export async function costBurndown(productId: string, days = 30): Promise<CostBurndown> {
  const budget = await repo.getBudget(productId);
  const runs = await repo.listRunsByProduct(productId);

  const windowDays = Math.max(1, Math.min(days, 365));
  const dayMs = 86_400_000;
  const todayUtc = Math.floor(Date.now() / dayMs) * dayMs;
  const startUtc = todayUtc - (windowDays - 1) * dayMs;

  // Initialise every day in the window to zero so the series has no gaps.
  const byDay = new Map<string, number>();
  for (let t = startUtc; t <= todayUtc; t += dayMs) {
    byDay.set(new Date(t).toISOString().slice(0, 10), 0);
  }

  for (const run of runs) {
    const cost = run.insights?.costUsd;
    if (!cost || cost <= 0) continue;
    const when = Date.parse(run.endedAt ?? run.startedAt);
    if (Number.isNaN(when) || when < startUtc) continue;
    const key = new Date(when).toISOString().slice(0, 10);
    byDay.set(key, (byDay.get(key) ?? 0) + cost);
  }

  const sortedKeys = [...byDay.keys()].sort();
  let cumulative = 0;
  const series: BurndownPoint[] = sortedKeys.map(date => {
    const costUsd = byDay.get(date) ?? 0;
    cumulative += costUsd;
    return { date, costUsd, cumulativeUsd: cumulative };
  });

  return {
    productId,
    ceilingUsd: budget?.ceilingUsd ?? null,
    window: budget?.window ?? null,
    totalUsd: cumulative,
    days: series,
  };
}

// ── Phase 3: Fleet metrics + alerting (§17) ───────────────────────────────────

export interface FleetAlert {
  level: 'warning' | 'critical';
  code: string;
  message: string;
}

export interface FleetMetrics {
  productId: string;
  generatedAt: string;
  jobs: {
    total: number;
    byStage: Record<FleetStage, number>;
    queueDepth: number;
    blocked: number;
    active: number;
    oldestQueuedAgeMs: number | null;
  };
  factories: {
    total: number;
    live: number;
    stale: number;
    byHealth: { ok: number; degraded: number; down: number };
    seatsUsed: number;
    seatsTotal: number;
    utilizationPct: number;
  };
  alerts: FleetAlert[];
}

/** A factory is considered stale after this long without a heartbeat. */
const DEFAULT_STALE_FACTORY_MS = 90_000;
/** A queued job waiting longer than this raises a starvation alert. */
const DEFAULT_STARVATION_MS = 900_000;

/**
 * Compute point-in-time operational metrics for a product's fleet (§17):
 * queue depth, stage histogram, oldest-queued age (starvation), factory health
 * and seat utilization — plus derived alerts. Read-only: a single per-partition
 * job + factory scan, no writes. Thresholds are injectable for deterministic tests.
 */
export async function fleetMetrics(
  productId: string,
  opts?: { nowMs?: number; staleMaxAgeMs?: number; starvationMs?: number }
): Promise<FleetMetrics> {
  const nowMs = opts?.nowMs ?? Date.now();
  const staleMaxAgeMs = opts?.staleMaxAgeMs ?? DEFAULT_STALE_FACTORY_MS;
  const starvationMs = opts?.starvationMs ?? DEFAULT_STARVATION_MS;

  const [allJobs, factories] = await Promise.all([
    repo.listJobs({ productId }),
    repo.listFactories(productId),
  ]);

  const byStage = Object.fromEntries(FLEET_STAGES.map(s => [s, 0])) as Record<FleetStage, number>;
  let oldestQueuedAgeMs: number | null = null;
  for (const j of allJobs) {
    byStage[j.stage] = (byStage[j.stage] ?? 0) + 1;
    if (j.stage === 'queued') {
      const age = nowMs - Date.parse(j.createdAt);
      if (Number.isFinite(age) && (oldestQueuedAgeMs === null || age > oldestQueuedAgeMs)) {
        oldestQueuedAgeMs = age;
      }
    }
  }
  const queueDepth = byStage.queued ?? 0;
  const blocked = byStage.blocked ?? 0;
  const active = ACTIVE_STAGES.reduce((n, s) => n + (byStage[s] ?? 0), 0);

  const byHealth = { ok: 0, degraded: 0, down: 0 };
  let live = 0;
  let stale = 0;
  let seatsUsed = 0;
  let seatsTotal = 0;
  for (const f of factories) {
    if (isFactoryStale(f, nowMs, staleMaxAgeMs)) stale++;
    else live++;
    if (f.health === 'ok' || f.health === 'degraded' || f.health === 'down') byHealth[f.health]++;
    seatsUsed += f.load ?? 0;
    seatsTotal += f.seatLimit ?? 0;
  }
  const utilizationPct = seatsTotal > 0 ? Math.round((seatsUsed / seatsTotal) * 1000) / 10 : 0;

  const alerts: FleetAlert[] = [];
  const liveHealthy = factories.filter(
    f => !isFactoryStale(f, nowMs, staleMaxAgeMs) && f.health !== 'down'
  ).length;
  if (queueDepth > 0 && liveHealthy === 0) {
    alerts.push({
      level: 'critical',
      code: 'no_live_capacity',
      message: `${queueDepth} job(s) queued but no live, healthy factory is available.`,
    });
  }
  if (factories.length > 0 && byHealth.down === factories.length) {
    alerts.push({
      level: 'critical',
      code: 'all_factories_down',
      message: 'All registered factories report health "down".',
    });
  }
  if (oldestQueuedAgeMs !== null && oldestQueuedAgeMs > starvationMs) {
    alerts.push({
      level: 'warning',
      code: 'queue_starvation',
      message: `Oldest queued job has waited ${Math.round(oldestQueuedAgeMs / 1000)}s (threshold ${Math.round(starvationMs / 1000)}s).`,
    });
  }
  if (seatsTotal > 0 && seatsUsed >= seatsTotal && queueDepth > 0) {
    alerts.push({
      level: 'warning',
      code: 'saturated',
      message: `All ${seatsTotal} factory seat(s) are in use with ${queueDepth} job(s) still queued.`,
    });
  }
  if (stale > 0) {
    alerts.push({
      level: 'warning',
      code: 'stale_factories',
      message: `${stale} factory(ies) have not sent a heartbeat within ${Math.round(staleMaxAgeMs / 1000)}s.`,
    });
  }

  return {
    productId,
    generatedAt: new Date(nowMs).toISOString(),
    jobs: { total: allJobs.length, byStage, queueDepth, blocked, active, oldestQueuedAgeMs },
    factories: {
      total: factories.length,
      live,
      stale,
      byHealth,
      seatsUsed,
      seatsTotal,
      utilizationPct,
    },
    alerts,
  };
}

export interface ReapResult {
  reaped: number;
  jobIds: string[];
}

/**
 * Reclaim jobs whose lease has expired. Cosmos TTL would only DELETE the lease
 * doc — it cannot return the job to `queued`, bump the fencing epoch, or preserve
 * the checkpoint — so the reaper (not TTL) owns recovery. Idempotent: a reaped
 * lease becomes `expired` and is no longer selected by listExpiredLeases.
 */
export async function reapExpiredLeases(nowIso: string): Promise<ReapResult> {
  const expired = await repo.listExpiredLeases(nowIso);
  const jobIds: string[] = [];
  for (const lease of expired) {
    const job = await repo.getJob(lease.jobId, lease.productId);
    if (!job) continue;
    if (!ACTIVE_STAGES.includes(job.stage)) continue; // already resting; nothing to reclaim
    const newEpoch = job.leaseEpoch + 1; // fence the zombie holder
    const unmet = await unmetDeps(job);
    const stage: FleetStage = unmet.length > 0 ? 'blocked' : 'queued';
    // checkpoint pointer is intentionally preserved on the job (resume-friendly)
    const jobRes = await repo.revUpdateJob(job.id, lease.productId, job.rev, {
      stage,
      leaseEpoch: newEpoch,
      blockedReason: unmet.length > 0 ? `waiting on: ${unmet.join(', ')}` : undefined,
    });
    if (!jobRes.ok) continue;
    await repo.revUpdateLease(lease.jobId, lease.rev, {
      status: 'expired',
      leaseEpoch: newEpoch,
      holderFactoryId: undefined,
    });
    await repo.appendEvent({
      jobId: job.id,
      productId: lease.productId,
      type: 'lease_expired',
      data: { leaseEpoch: newEpoch, returnedTo: stage },
    });
    jobIds.push(job.id);
  }
  return { reaped: jobIds.length, jobIds };
}
