/**
 * Fleet scheduler / router core — the deterministic, fixed-weight scoring engine
 * that decides WHICH job a claiming factory gets (Phase 2, §7 of the gigafactory
 * roadmap).
 *
 * This module is PURE and SYNCHRONOUS: no datastore calls, no clock reads, no env.
 * Everything it needs is passed in — health/load/seatLimit from the factory view,
 * age from `job.createdAt` vs `ctx.now` (coordinator-authoritative time), and the
 * deps-satisfied predicate (the coordinator resolves deps asynchronously and hands
 * us a pure predicate). That keeps the scoring fully unit-testable and lets the
 * coordinator own all I/O and the atomic compare-and-swap claim.
 *
 * Phasing (§7): Phase 2 ships the deterministic filter + fixed-weight scoring.
 * Phase 3 adds tunable weights, preemption, and the explainability UI; Phase 5
 * learns the weights. We deliberately do NOT build tunable weights or preemption
 * here — only the fixed-weight core + a per-term breakdown for explainability.
 *
 * Scoring formula (§7):
 *   score = w1·capabilityFit
 *         + w2·affinity(prefersEngine / repo-stickiness)
 *         + w3·(1 / (1 + load))
 *         + w4·costFit(budget)
 *         + w5·health
 *         − w6·starvationPenalty(age)
 *
 * Selection: filter to deps-satisfied + capability-subset (+ health floor), rank
 * by score, then a deterministic tie-break: higher priority → older createdAt →
 * lower cost class.
 */

import type { FactoryHealth, FleetJobDoc, FleetPriority } from './types.js';

// ── Weights (fixed this phase; overridable via a passed-in object, NOT env) ──

/** Fixed-weight config for the §7 scoring terms. Phase 3 makes these tunable. */
export interface SchedulerWeights {
  /** w1 — hard capability fit (satisfied requirement ratio). */
  capabilityFit: number;
  /** w2 — affinity: prefers-engine match + warm-scope (repo) stickiness. */
  affinity: number;
  /** w3 — inverse load `1/(1+load)`; a busier factory scores lower. */
  load: number;
  /** w4 — cost fit: penalize jobs whose budget exceeds the factory cost ceiling. */
  costFit: number;
  /** w5 — factory health (ok=1, degraded=0.5; `down` is filtered out, not scored). */
  health: number;
  /** w6 — starvation: subtracts a freshness penalty that decays as a job ages,
   *  so an aged job outranks an equally-prioritised fresh one (anti-starvation). */
  starvation: number;
  /** w7 — engine quality: a SOFT preference for the historically cheaper/faster
   *  engine among eligible candidates (§2). Contributes 0 unless the caller injects
   *  a `ctx.engineQuality` lookup, so it never alters default scoring. */
  engineQuality: number;
}

/**
 * Phase-2 fixed defaults. Rationale: capability + health are hard signals (1.0);
 * load matters strongly (1.0); cost is a moderate guard (0.75); affinity is a
 * gentle nudge (0.5); starvation is weighted high enough (1.5) to lift an aged
 * job above a same-priority fresh peer. Tunable per-product weights are Phase 3.
 */
export const DEFAULT_WEIGHTS: SchedulerWeights = {
  capabilityFit: 1.0,
  affinity: 0.5,
  load: 1.0,
  costFit: 0.75,
  health: 1.0,
  starvation: 1.5,
  // A gentle nudge (0.4) — below affinity — so cost/latency history only breaks
  // near-ties between otherwise-comparable engines; it never overrides hard signals.
  // Inert (contributes 0) unless `ctx.engineQuality` is supplied.
  engineQuality: 0.4,
};

/** Aging config for the starvation term (fixed this phase). */
export interface StarvationConfig {
  /** Width of one aging bucket, in ms. Ages within a bucket score identically,
   *  so jobs submitted close together tie (and fall through to the priority
   *  tie-break) rather than being separated by sub-second noise. */
  intervalMs: number;
  /** Number of buckets after which the freshness penalty fully decays to 0. */
  buckets: number;
}

export const DEFAULT_STARVATION: StarvationConfig = {
  intervalMs: 60_000, // 1 minute
  buckets: 30, // fully de-penalised after ~30 minutes of waiting
};

// ── Factory view + context the engine consumes ──────────────────────────────

/**
 * The factory fields the scorer needs — a structural subset of FleetFactoryDoc
 * (so a real factory doc is assignable), but also satisfiable from the claim
 * context so the coordinator need not always load the full doc.
 */
export interface SchedulerFactory {
  capabilities: string[];
  health?: FactoryHealth;
  load?: number;
  seatLimit?: number;
}

/** Pure context: authoritative time + injected deps predicate + affinity/cost hints. */
export interface SchedulerContext {
  /** Coordinator-authoritative now (ms epoch). Drives the starvation/age term. */
  now: number;
  /** Deps gate (the coordinator resolves deps async and passes a pure predicate).
   *  Omitted ⇒ treated as satisfied. */
  isDepsSatisfied?: (job: FleetJobDoc) => boolean;
  /** Engines the claiming factory runs (for prefers-engine affinity). */
  factoryEngines?: string[];
  /** Scopes (repos/locks) the factory has warm (for repo-stickiness affinity). */
  warmScopes?: string[];
  /** The factory/budget cost ceiling in USD for cost-fit. Omitted ⇒ unconstrained. */
  costCeilingUsd?: number;
  /** Override the starvation aging config (fixed defaults otherwise). */
  starvation?: StarvationConfig;
  /**
   * SOFT engine-quality lookup (§2): maps a candidate's engine to a [0,1] score
   * where 1 = historically cheapest/fastest and lower = more expensive/slower.
   * Omitted ⇒ the engineQuality term contributes 0 (default scoring is unchanged).
   * The coordinator derives this from per-engine run history (cost + duration).
   */
  engineQuality?: (engine: string | undefined) => number;
  /**
   * HARD engine-availability gate (§2): when supplied, a job pinned to a concrete
   * `engine` is eligible only if this returns true for that engine. The coordinator
   * composes the per-engine circuit breaker and per-engine budget here. It can only
   * RESTRICT routing (never force one). Omitted ⇒ all engines available.
   */
  isEngineAvailable?: (engine: string) => boolean;
}

/** Per-term, already-weighted contributions. Sums to `score` (starvation signed −). */
export interface ScoreBreakdown {
  capabilityFit: number;
  affinity: number;
  load: number;
  costFit: number;
  health: number;
  starvation: number;
  engineQuality: number;
}

export interface ScoredCandidate {
  score: number;
  breakdown: ScoreBreakdown;
}

// ── Pure predicates / helpers ────────────────────────────────────────────────

/** Every required capability token must be advertised by the factory (hard gate). */
export function capabilitiesSubset(required: string[], available: string[]): boolean {
  const set = new Set(available);
  return required.every(token => set.has(token));
}

/** True when the factory advertises any concrete `engine:*` capability — i.e. it is
 *  "engine-aware" and its advertised engine set is authoritative. A factory that
 *  advertises none is treated as engine-agnostic/legacy (no engine gating). */
function advertisesAnyEngine(available: string[]): boolean {
  return available.some(cap => cap.startsWith('engine:'));
}

/**
 * Engine routing gate (hard): a job pinned to a concrete `engine` may only run on
 * a factory that advertises the matching `engine:<e>` capability — but only when
 * that factory is engine-aware (advertises at least one `engine:*` token). A
 * factory advertising no engine caps is engine-agnostic/legacy and stays eligible
 * (behavior-preserving — mirrors the UI picker's "engine set unknown ⇒ offer all"
 * fallback). A job WITHOUT a concrete `engine` is never constrained here: its
 * abstract `engineClass`/`prefersEngine` resolve on the runner, which does its own
 * availability check + fallback (`resolve_engine`).
 */
export function engineEligible(job: FleetJobDoc, available: string[]): boolean {
  if (!job.engine) return true;
  if (!advertisesAnyEngine(available)) return true;
  return available.includes(`engine:${job.engine}`);
}

/** True while a job is serving a retry backoff (`retryNotBefore` in the future):
 *  it is queued but must not be claimed yet. No `retryNotBefore` ⇒ never backing off. */
export function isAwaitingRetryBackoff(job: FleetJobDoc, now: number): boolean {
  if (!job.retryNotBefore) return false;
  const t = Date.parse(job.retryNotBefore);
  return !Number.isNaN(t) && t > now;
}

function overlaps(a: readonly string[], b: readonly string[]): boolean {
  if (a.length === 0 || b.length === 0) return false;
  const set = new Set(b);
  return a.some(x => set.has(x));
}

const HEALTH_SCORE: Record<FactoryHealth, number> = { ok: 1, degraded: 0.5, down: 0 };

function clamp01(n: number): number {
  if (n < 0) return 0;
  if (n > 1) return 1;
  return n;
}

/** w1 term — satisfied-requirement ratio (1 when the hard subset holds). */
function capabilityFitTerm(job: FleetJobDoc, factory: SchedulerFactory): number {
  const required = job.capabilities ?? [];
  if (required.length === 0) return 1;
  const have = new Set(factory.capabilities);
  const matched = required.reduce((n, cap) => (have.has(cap) ? n + 1 : n), 0);
  return matched / required.length;
}

/** w2 term — prefers-engine match + warm-scope stickiness, each contributing half. */
function affinityTerm(job: FleetJobDoc, ctx: SchedulerContext): number {
  const prefers = job.manifestSnapshot?.prefersEngine ?? [];
  const scopes = job.manifestSnapshot?.allowedScope ?? [];
  const prefersScore = prefers.length > 0 && overlaps(prefers, ctx.factoryEngines ?? []) ? 1 : 0;
  const stickyScore = scopes.length > 0 && overlaps(scopes, ctx.warmScopes ?? []) ? 1 : 0;
  return clamp01((prefersScore + stickyScore) / 2);
}

/** w3 term — inverse load. */
function loadTerm(factory: SchedulerFactory): number {
  const load = factory.load ?? 0;
  return 1 / (1 + Math.max(0, load));
}

/** w4 term — 1 when within the cost ceiling (or unconstrained), decays toward 0
 *  the further a job's budget exceeds the ceiling. */
function costFitTerm(job: FleetJobDoc, ctx: SchedulerContext): number {
  const budget = job.budget?.usd;
  const ceiling = ctx.costCeilingUsd;
  if (budget === undefined || ceiling === undefined) return 1;
  if (ceiling <= 0) return budget <= 0 ? 1 : 0;
  if (budget <= ceiling) return 1;
  return clamp01(ceiling / budget);
}

/** w5 term — factory health as a [0,1] score. */
function healthTerm(factory: SchedulerFactory): number {
  return HEALTH_SCORE[factory.health ?? 'ok'];
}

/** Freshness penalty in [0,1]: 1 for a brand-new job, decaying to 0 as it ages
 *  past `buckets` aging intervals. Subtracted from the score, so an aged job
 *  loses less and rises above an equally-prioritised fresh peer (anti-starvation).
 *  Bucketing makes near-simultaneous submissions tie (→ priority tie-break). */
function starvationPenaltyTerm(job: FleetJobDoc, ctx: SchedulerContext): number {
  const cfg = ctx.starvation ?? DEFAULT_STARVATION;
  const ageMs = Math.max(0, ctx.now - Date.parse(job.createdAt));
  const aged = Math.floor(ageMs / cfg.intervalMs);
  return clamp01(1 - aged / cfg.buckets);
}

/** The engine a candidate will (most likely) run on: an explicit concrete engine,
 *  else the first prefers-engine hint, else the abstract class. Used by the soft
 *  engine-quality term and the hard availability gate. */
export function resolveJobEngine(job: FleetJobDoc): string | undefined {
  return job.engine ?? job.manifestSnapshot?.prefersEngine?.[0] ?? job.engineClass;
}

/** w7 term — soft engine-quality preference in [0,1]. Inert (0) unless the caller
 *  injects `ctx.engineQuality`; an unknown engine returns whatever the lookup says
 *  (the coordinator's lookup treats no-history engines as neutral = 1, so history
 *  can only DEMOTE demonstrably expensive/slow engines, never penalise new ones). */
function engineQualityTerm(job: FleetJobDoc, ctx: SchedulerContext): number {
  if (!ctx.engineQuality) return 0;
  return clamp01(ctx.engineQuality(resolveJobEngine(job)));
}

/** Hard availability gate: a concrete-engine job is eligible only when the injected
 *  predicate allows that engine. Jobs without a concrete engine resolve on the
 *  runner and are never gated here (mirrors `engineEligible`). No predicate ⇒ all. */
export function engineAvailable(job: FleetJobDoc, ctx: SchedulerContext): boolean {
  if (!ctx.isEngineAvailable) return true;
  if (!job.engine) return true;
  return ctx.isEngineAvailable(job.engine);
}

// ── Public scoring API ────────────────────────────────────────────────────────

/**
 * Score one (job, factory) pair. Returns the total `score` plus the per-term,
 * already-weighted `breakdown` (the six terms sum to `score`) for explainability
 * (§7 / Phase-3 readiness). Pure + synchronous.
 */
export function scoreCandidate(
  job: FleetJobDoc,
  factory: SchedulerFactory,
  ctx: SchedulerContext,
  weights: SchedulerWeights = DEFAULT_WEIGHTS
): ScoredCandidate {
  const breakdown: ScoreBreakdown = {
    capabilityFit: weights.capabilityFit * capabilityFitTerm(job, factory),
    affinity: weights.affinity * affinityTerm(job, ctx),
    load: weights.load * loadTerm(factory),
    costFit: weights.costFit * costFitTerm(job, ctx),
    health: weights.health * healthTerm(factory),
    starvation: -weights.starvation * starvationPenaltyTerm(job, ctx),
    engineQuality: weights.engineQuality * engineQualityTerm(job, ctx),
  };
  const score =
    breakdown.capabilityFit +
    breakdown.affinity +
    breakdown.load +
    breakdown.costFit +
    breakdown.health +
    breakdown.starvation +
    breakdown.engineQuality;
  return { score, breakdown };
}

// ── Phase 3: Tunable weight config resolver ──────────────────────────────────

/**
 * Per-product weight overrides. Only LISTED fields override the defaults;
 * missing fields fall through to DEFAULT_WEIGHTS. This is the Phase-3 tunability
 * hook: callers pass a product's config; the resolver merges with defaults.
 */
export type FleetWeightOverrides = Partial<SchedulerWeights>;

/** Registry type: productId → weight overrides (only the tuned fields). */
export type FleetWeightRegistry = Record<string, FleetWeightOverrides>;

/**
 * Resolve the effective weights for a given product. Lookup order:
 *   1. per-request override (passed explicitly)
 *   2. product registry entry (if registered)
 *   3. DEFAULT_WEIGHTS (always the fallback)
 * All fields fallback individually — partial overrides are merged, not replaced.
 */
export function resolveWeights(
  registry: FleetWeightRegistry,
  productId?: string,
  requestOverride?: FleetWeightOverrides
): SchedulerWeights {
  const productEntry = productId ? registry[productId] : undefined;
  return {
    capabilityFit:
      requestOverride?.capabilityFit ??
      productEntry?.capabilityFit ??
      DEFAULT_WEIGHTS.capabilityFit,
    affinity: requestOverride?.affinity ?? productEntry?.affinity ?? DEFAULT_WEIGHTS.affinity,
    load: requestOverride?.load ?? productEntry?.load ?? DEFAULT_WEIGHTS.load,
    costFit: requestOverride?.costFit ?? productEntry?.costFit ?? DEFAULT_WEIGHTS.costFit,
    health: requestOverride?.health ?? productEntry?.health ?? DEFAULT_WEIGHTS.health,
    starvation:
      requestOverride?.starvation ?? productEntry?.starvation ?? DEFAULT_WEIGHTS.starvation,
    engineQuality:
      requestOverride?.engineQuality ??
      productEntry?.engineQuality ??
      DEFAULT_WEIGHTS.engineQuality,
  };
}

// ── Phase 3: Preemption (pure, no I/O) ────────────────────────────────────────

/** A running job visible to the preemption engine — minimal view. */
export interface RunningJobView {
  id: string;
  productId: string;
  priority: FleetPriority;
  priorityOrder: number;
  leaseEpoch: number;
  checkpoint?: FleetJobDoc['checkpoint'];
}

/** The pure preemption decision: evict a specific running job for a critical newcomer. */
export interface PreemptionDecision {
  evict: string; // jobId to evict
  evictPriorityOrder: number;
  reason: string;
}

/**
 * Pure preemption logic (Phase 3, §7):
 * When a CRITICAL-priority job cannot be placed (i.e., `selectJob` returns null because
 * all capable factories are busy), this function decides whether to preempt a
 * STRICTLY lower-priority running job on the given factory.
 *
 * Rules:
 *  - Only critical jobs may trigger preemption.
 *  - Only strictly lower-priority running jobs are evictable.
 *  - Among evictable jobs, pick the LOWEST priority (highest priorityOrder), then
 *    the one with the fewest attempts (least work invested), then lexicographic id.
 *  - Never evict an equal-or-higher-priority job.
 *  - Returns null when no preemption is possible.
 *
 * This is PURE — no I/O, no side effects. The coordinator wires the eviction
 * (checkpoint + requeue) and is responsible for checking the feature flag.
 */
export function selectPreemptionVictim(
  candidate: FleetJobDoc,
  runningJobs: RunningJobView[],
  factory: SchedulerFactory
): PreemptionDecision | null {
  // Only critical jobs may preempt
  if (candidate.priorityOrder !== 0) return null; // 0 = critical

  // Candidate must be runnable on this factory (capability subset + engine gate)
  if (!capabilitiesSubset(candidate.capabilities ?? [], factory.capabilities)) return null;
  if (!engineEligible(candidate, factory.capabilities)) return null;

  // Find strictly-lower-priority running jobs
  const evictable = runningJobs.filter(r => r.priorityOrder > candidate.priorityOrder);
  if (evictable.length === 0) return null;

  // Pick the best victim: lowest priority first, then fewest attempts, then stable id sort
  evictable.sort((a, b) => {
    if (a.priorityOrder !== b.priorityOrder) return b.priorityOrder - a.priorityOrder; // lowest prio first
    return a.id.localeCompare(b.id); // stable
  });

  const victim = evictable[0];
  return {
    evict: victim.id,
    evictPriorityOrder: victim.priorityOrder,
    reason: `critical job '${candidate.id}' preempts lower-priority job '${victim.id}' (priority order ${victim.priorityOrder})`,
  };
}

/** Cost class used as the final tie-break (lower USD budget = lower class first). */
function costClass(job: FleetJobDoc): number {
  return job.budget?.usd ?? 0;
}

/** Scores within EPSILON are treated as a tie and fall through to the §7 tie-break. */
const SCORE_EPSILON = 1e-9;

/**
 * Pick the best job for `factory` from `candidates`:
 *  1. filter to stage queued/blocked + deps-satisfied (ctx predicate) +
 *     hard capability-subset, and drop everything if the factory is `down`
 *     (health floor — filtered, not merely down-weighted, per §7);
 *  2. rank by `scoreCandidate` (descending);
 *  3. deterministic tie-break: higher priority → older createdAt → lower cost class.
 * Returns `null` when nothing is eligible. Pure + synchronous + deterministic.
 */
export function selectJob(
  candidates: FleetJobDoc[],
  factory: SchedulerFactory,
  ctx: SchedulerContext,
  weights: SchedulerWeights = DEFAULT_WEIGHTS
): FleetJobDoc | null {
  // Health floor: a factory below the floor is excluded entirely (§7).
  if ((factory.health ?? 'ok') === 'down') return null;

  const depsSatisfied = ctx.isDepsSatisfied ?? (() => true);
  const eligible = candidates.filter(
    job =>
      (job.stage === 'queued' || job.stage === 'blocked') &&
      !isAwaitingRetryBackoff(job, ctx.now) &&
      capabilitiesSubset(job.capabilities ?? [], factory.capabilities) &&
      engineEligible(job, factory.capabilities) &&
      engineAvailable(job, ctx) &&
      depsSatisfied(job)
  );
  if (eligible.length === 0) return null;

  const scored = eligible.map(job => ({
    job,
    score: scoreCandidate(job, factory, ctx, weights).score,
  }));
  scored.sort((a, b) => {
    if (Math.abs(b.score - a.score) > SCORE_EPSILON) return b.score - a.score; // higher score first
    if (a.job.priorityOrder !== b.job.priorityOrder)
      return a.job.priorityOrder - b.job.priorityOrder; // higher priority (lower order) first
    const ageCmp = a.job.createdAt.localeCompare(b.job.createdAt); // older (earlier ISO) first
    if (ageCmp !== 0) return ageCmp;
    return costClass(a.job) - costClass(b.job); // lower cost class first
  });
  return scored[0].job;
}
