/**
 * Fleet scheduler / router core (§7) — pure, deterministic unit tests.
 * No datastore, no real clock: time is injected via ctx.now.
 */

import { describe, expect, it } from 'vitest';
import {
  DEFAULT_WEIGHTS,
  scoreCandidate,
  selectJob,
  capabilitiesSubset,
  engineEligible,
  isAwaitingRetryBackoff,
  type SchedulerContext,
  type SchedulerFactory,
} from './scheduler.js';
import { PRIORITY_ORDER, type FleetJobDoc, type FleetPriority } from './types.js';

const NOW = Date.parse('2026-05-29T12:00:00.000Z');
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();

/** Build a minimal valid FleetJobDoc for scoring. */
function job(over: Partial<FleetJobDoc> & { id: string }): FleetJobDoc {
  const priority: FleetPriority = over.priority ?? 'medium';
  const manifest: FleetJobDoc['manifestSnapshot'] = {
    priority,
    capabilities: over.capabilities ?? [],
    prefersEngine: [],
    allowedScope: [],
    deps: [],
    ...(over.manifestSnapshot ?? {}),
  };
  return {
    productId: 'lysnrai',
    stage: 'queued',
    idempotencyKey: over.id,
    contentHash: 'h',
    bodyMd: '# task',
    capabilities: [],
    deps: [],
    kind: 'leaf',
    attempts: 0,
    leaseEpoch: 0,
    rev: 0,
    createdAt: iso(0),
    updatedAt: iso(0),
    ...over,
    priority,
    priorityOrder: over.priorityOrder ?? PRIORITY_ORDER[priority],
    manifestSnapshot: manifest,
  };
}

const fac = (over: Partial<SchedulerFactory> = {}): SchedulerFactory => ({
  capabilities: [],
  health: 'ok',
  load: 0,
  ...over,
});

const ctx = (over: Partial<SchedulerContext> = {}): SchedulerContext => ({ now: NOW, ...over });

describe('scheduler §7 — capability hard filter', () => {
  it('a factory missing a required capability never selects that job', () => {
    const needsMac = job({ id: 'needs-mac', capabilities: ['os:mac'] });
    expect(selectJob([needsMac], fac({ capabilities: [] }), ctx())).toBeNull();
    expect(selectJob([needsMac], fac({ capabilities: ['os:mac', 'has:git'] }), ctx())?.id).toBe(
      'needs-mac'
    );
  });

  it('among candidates, only capability-subset jobs are eligible', () => {
    const a = job({ id: 'a', capabilities: ['os:mac'] });
    const b = job({ id: 'b', capabilities: ['os:linux'] }); // factory cannot run this
    const pick = selectJob([a, b], fac({ capabilities: ['os:mac'] }), ctx());
    expect(pick?.id).toBe('a');
  });

  it('capabilitiesSubset predicate', () => {
    expect(capabilitiesSubset(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
    expect(capabilitiesSubset(['a', 'z'], ['a', 'b'])).toBe(false);
    expect(capabilitiesSubset([], ['a'])).toBe(true);
  });
});

describe('scheduler §7 — concrete-engine routing gate', () => {
  it('engineEligible: no concrete engine ⇒ never constrained', () => {
    expect(engineEligible(job({ id: 'j' }), ['engine:devin'])).toBe(true);
    expect(engineEligible(job({ id: 'j' }), [])).toBe(true);
  });

  it('engineEligible: engine-aware factory must advertise the matching engine', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    expect(engineEligible(codexJob, ['engine:devin', 'engine:claude'])).toBe(false);
    expect(engineEligible(codexJob, ['engine:devin', 'engine:codex'])).toBe(true);
  });

  it('engineEligible: engine-agnostic/legacy factory (no engine:* caps) stays eligible', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    expect(engineEligible(codexJob, ['os:mac', 'has:git'])).toBe(true);
    expect(engineEligible(codexJob, [])).toBe(true);
  });

  it('selectJob: a codex job is not claimed by a factory advertising only devin/claude', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    const devinOnly = fac({ capabilities: ['engine:devin', 'engine:claude'] });
    expect(selectJob([codexJob], devinOnly, ctx())).toBeNull();
  });

  it('selectJob: a codex job IS claimed by a factory advertising codex', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    const codexFac = fac({ capabilities: ['engine:devin', 'engine:codex'] });
    expect(selectJob([codexJob], codexFac, ctx())?.id).toBe('codex');
  });

  it('selectJob: routes a codex job to the codex factory, not the devin-only peer', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    const devinOnly = fac({ capabilities: ['engine:devin'] });
    const codexFac = fac({ capabilities: ['engine:codex'] });
    expect(selectJob([codexJob], devinOnly, ctx())).toBeNull();
    expect(selectJob([codexJob], codexFac, ctx())?.id).toBe('codex');
  });

  it('selectJob: engine-agnostic legacy factory still claims an engine-pinned job', () => {
    const codexJob = job({ id: 'codex', engine: 'codex', capabilities: ['os:mac'] });
    const legacy = fac({ capabilities: ['os:mac', 'has:git'] }); // advertises no engine:* caps
    expect(selectJob([codexJob], legacy, ctx())?.id).toBe('codex');
  });
});

describe('scheduler §7 — retry backoff gate', () => {
  it('isAwaitingRetryBackoff: future retryNotBefore is backing off, past/absent is not', () => {
    expect(isAwaitingRetryBackoff(job({ id: 'j' }), NOW)).toBe(false);
    expect(isAwaitingRetryBackoff(job({ id: 'j', retryNotBefore: iso(-60_000) }), NOW)).toBe(true);
    expect(isAwaitingRetryBackoff(job({ id: 'j', retryNotBefore: iso(60_000) }), NOW)).toBe(false);
  });

  it('selectJob skips a queued job still serving its retry backoff', () => {
    const backingOff = job({ id: 'boff', retryNotBefore: iso(-5 * 60_000) }); // 5 min in the future
    expect(selectJob([backingOff], fac(), ctx())).toBeNull();
    // Once the backoff has elapsed it becomes claimable again.
    const elapsedCtx = ctx({ now: NOW + 10 * 60_000 });
    expect(selectJob([backingOff], fac(), elapsedCtx)?.id).toBe('boff');
  });
});

describe('scheduler §7 — priority + age tie-breaks (all else equal)', () => {
  it('priority dominates when scores tie', () => {
    const low = job({ id: 'low', priority: 'low' });
    const med = job({ id: 'med', priority: 'medium' });
    const high = job({ id: 'high', priority: 'high' });
    const crit = job({ id: 'crit', priority: 'critical' });
    expect(selectJob([low, med, high, crit], fac(), ctx())?.id).toBe('crit');
  });

  it('age breaks ties deterministically — older wins among equal priority', () => {
    // both within the same aging bucket ⇒ equal starvation ⇒ score tie ⇒ age tie-break
    const older = job({ id: 'older', priority: 'medium', createdAt: iso(5_000) });
    const newer = job({ id: 'newer', priority: 'medium', createdAt: iso(1_000) });
    expect(selectJob([newer, older], fac(), ctx())?.id).toBe('older');
  });
});

describe('scheduler §7 — load & health', () => {
  it('a higher-load factory scores lower (1/(1+load))', () => {
    const j = job({ id: 'j' });
    const idle = scoreCandidate(j, fac({ load: 0 }), ctx()).score;
    const busy = scoreCandidate(j, fac({ load: 5 }), ctx()).score;
    expect(idle).toBeGreaterThan(busy);
  });

  it('degraded health scores lower than ok', () => {
    const j = job({ id: 'j' });
    const ok = scoreCandidate(j, fac({ health: 'ok' }), ctx()).score;
    const degraded = scoreCandidate(j, fac({ health: 'degraded' }), ctx()).score;
    expect(ok).toBeGreaterThan(degraded);
  });

  it('a down factory is filtered out entirely (health floor)', () => {
    const j = job({ id: 'j' });
    expect(selectJob([j], fac({ health: 'down' }), ctx())).toBeNull();
  });
});

describe('scheduler §7 — starvation (anti-starvation aging)', () => {
  it('an aged low-priority job outranks a fresh low-priority one', () => {
    const fresh = job({ id: 'fresh', priority: 'low', createdAt: iso(0) });
    const aged = job({ id: 'aged', priority: 'low', createdAt: iso(40 * 60_000) });
    expect(selectJob([fresh, aged], fac(), ctx())?.id).toBe('aged');
    // and the aged job's standalone score is higher
    expect(scoreCandidate(aged, fac(), ctx()).score).toBeGreaterThan(
      scoreCandidate(fresh, fac(), ctx()).score
    );
  });
});

describe('scheduler §7 — cost fit', () => {
  it('a job exceeding the cost ceiling is penalized and ranked last', () => {
    const within = job({ id: 'within', budget: { usd: 5 } });
    const over = job({ id: 'over', budget: { usd: 100 } });
    const c = ctx({ costCeilingUsd: 10 });
    expect(selectJob([over, within], fac(), c)?.id).toBe('within');
    expect(scoreCandidate(over, fac(), c).score).toBeLessThan(
      scoreCandidate(within, fac(), c).score
    );
  });

  it('no ceiling ⇒ cost is neutral (full costFit)', () => {
    const j = job({ id: 'j', budget: { usd: 999 } });
    expect(scoreCandidate(j, fac(), ctx()).breakdown.costFit).toBeCloseTo(DEFAULT_WEIGHTS.costFit);
  });
});

describe('scheduler §7 — affinity', () => {
  it('prefers-engine match raises affinity', () => {
    const j = job({
      id: 'j',
      manifestSnapshot: { prefersEngine: ['devin'] } as FleetJobDoc['manifestSnapshot'],
    });
    const hit = scoreCandidate(j, fac(), ctx({ factoryEngines: ['devin'] })).breakdown.affinity;
    const miss = scoreCandidate(j, fac(), ctx({ factoryEngines: ['claude'] })).breakdown.affinity;
    expect(hit).toBeGreaterThan(miss);
  });
});

describe('scheduler §2 — soft engine-quality term', () => {
  it('is inert (0) when no engineQuality lookup is provided', () => {
    const j = job({ id: 'j', engine: 'codex' });
    expect(scoreCandidate(j, fac(), ctx()).breakdown.engineQuality).toBe(0);
  });

  it('prefers the historically cheaper/faster engine among eligible candidates', () => {
    const cheap = job({ id: 'cheap', engine: 'devin' });
    const pricey = job({ id: 'pricey', engine: 'codex' });
    // devin scores high (1), codex low (0.2) — same factory, all else equal.
    const quality = (e: string | undefined) => (e === 'devin' ? 1 : e === 'codex' ? 0.2 : 1);
    const c = ctx({ engineQuality: quality });
    expect(selectJob([pricey, cheap], fac(), c)?.id).toBe('cheap');
    expect(scoreCandidate(cheap, fac(), c).breakdown.engineQuality).toBeGreaterThan(
      scoreCandidate(pricey, fac(), c).breakdown.engineQuality
    );
  });

  it('is only a gentle nudge — a real signal (starvation) dominates it', () => {
    // The cheap engine is fresh; the pricey engine has aged a long time. The
    // anti-starvation boost (weight 1.5) outweighs the engine-quality nudge (0.4),
    // so the aged pricey-engine job still wins — the nudge never overpowers a
    // genuine routing signal.
    const cheapFresh = job({ id: 'cheapFresh', engine: 'devin', createdAt: iso(0) });
    const priceyAged = job({ id: 'priceyAged', engine: 'codex', createdAt: iso(40 * 60_000) });
    const quality = (e: string | undefined) => (e === 'devin' ? 1 : 0);
    const c = ctx({ engineQuality: quality });
    expect(selectJob([cheapFresh, priceyAged], fac(), c)?.id).toBe('priceyAged');
  });
});

describe('scheduler §2 — hard engine-availability gate', () => {
  it('excludes a concrete-engine job when the engine is unavailable (e.g. breaker open)', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    const blocked = ctx({ isEngineAvailable: e => e !== 'codex' });
    expect(selectJob([codexJob], fac(), blocked)).toBeNull();
    expect(selectJob([codexJob], fac(), ctx())?.id).toBe('codex'); // available by default
  });

  it('routes around an unavailable engine to an available candidate', () => {
    const codexJob = job({ id: 'codex', engine: 'codex' });
    const devinJob = job({ id: 'devin', engine: 'devin' });
    const c = ctx({ isEngineAvailable: e => e !== 'codex' });
    expect(selectJob([codexJob, devinJob], fac(), c)?.id).toBe('devin');
  });

  it('never gates a job without a concrete engine (resolves on the runner)', () => {
    const abstract = job({ id: 'abs' }); // no concrete engine
    const c = ctx({ isEngineAvailable: () => false });
    expect(selectJob([abstract], fac(), c)?.id).toBe('abs');
  });
});

describe('scheduler §7 — breakdown & determinism', () => {
  it('breakdown is per-weighted-term and sums to the score', () => {
    const j = job({ id: 'j', priority: 'high', budget: { usd: 3 }, createdAt: iso(90_000) });
    const c = ctx({ costCeilingUsd: 10, factoryEngines: ['devin'] });
    const { score, breakdown } = scoreCandidate(j, fac({ load: 2, health: 'degraded' }), c);
    const sum =
      breakdown.capabilityFit +
      breakdown.affinity +
      breakdown.load +
      breakdown.costFit +
      breakdown.health +
      breakdown.starvation;
    expect(sum).toBeCloseTo(score, 12);
    expect(breakdown.starvation).toBeLessThanOrEqual(0); // signed penalty
  });

  it('selectJob is deterministic — same inputs ⇒ same pick across runs', () => {
    const cands = [
      job({ id: 'a', priority: 'medium', createdAt: iso(1_000) }),
      job({ id: 'b', priority: 'high', createdAt: iso(2_000) }),
      job({ id: 'c', priority: 'high', createdAt: iso(3_000) }),
    ];
    const picks = Array.from({ length: 5 }, () => selectJob(cands, fac(), ctx())?.id);
    expect(new Set(picks).size).toBe(1);
    // highest priority (b/c are 'high' > a's 'medium'); b vs c tie on score+priority,
    // so the age tie-break wins → c is older (created 3s ago vs b's 2s ago).
    expect(picks[0]).toBe('c');
  });

  it('empty candidates ⇒ null; no eligible (none queued/blocked) ⇒ null', () => {
    expect(selectJob([], fac(), ctx())).toBeNull();
    const assigned = job({ id: 'x', stage: 'assigned' });
    expect(selectJob([assigned], fac(), ctx())).toBeNull();
  });

  it('blocked jobs are eligible only when the deps predicate says so', () => {
    const blocked = job({ id: 'blk', stage: 'blocked' });
    expect(selectJob([blocked], fac(), ctx({ isDepsSatisfied: () => false }))).toBeNull();
    expect(selectJob([blocked], fac(), ctx({ isDepsSatisfied: () => true }))?.id).toBe('blk');
  });
});

// ── Phase 3 tests: tunable weights + preemption ──────────────────────────────

import {
  resolveWeights,
  selectPreemptionVictim,
  type FleetWeightRegistry,
  type RunningJobView,
} from './scheduler.js';

describe('scheduler §7 Phase 3 — resolveWeights', () => {
  it('returns defaults when no registry/override', () => {
    const w = resolveWeights({}, 'lysnrai');
    expect(w).toEqual(DEFAULT_WEIGHTS);
  });

  it('merges product override with defaults (partial override)', () => {
    const registry: FleetWeightRegistry = {
      lysnrai: { starvation: 3.0, affinity: 0.9 },
    };
    const w = resolveWeights(registry, 'lysnrai');
    expect(w.starvation).toBe(3.0);
    expect(w.affinity).toBe(0.9);
    expect(w.capabilityFit).toBe(DEFAULT_WEIGHTS.capabilityFit);
    expect(w.load).toBe(DEFAULT_WEIGHTS.load);
  });

  it('per-request override takes precedence over product registry', () => {
    const registry: FleetWeightRegistry = { lysnrai: { starvation: 3.0 } };
    const w = resolveWeights(registry, 'lysnrai', { starvation: 5.0 });
    expect(w.starvation).toBe(5.0);
  });

  it('unknown productId falls through to defaults', () => {
    const registry: FleetWeightRegistry = { lysnrai: { starvation: 3.0 } };
    const w = resolveWeights(registry, 'unknown-product');
    expect(w).toEqual(DEFAULT_WEIGHTS);
  });

  it('weight override changes ranking', () => {
    const fresh = job({ id: 'fresh', priority: 'low', createdAt: iso(0) });
    const aged = job({ id: 'aged', priority: 'low', createdAt: iso(40 * 60_000) });
    const highStarvation = { ...DEFAULT_WEIGHTS, starvation: 10 };
    const result = selectJob([fresh, aged], fac(), ctx(), highStarvation);
    expect(result?.id).toBe('aged');
  });

  it('defaults reproduce all prior picks (backward compat)', () => {
    const cands = [
      job({ id: 'a', priority: 'medium', createdAt: iso(1_000) }),
      job({ id: 'b', priority: 'high', createdAt: iso(2_000) }),
      job({ id: 'c', priority: 'high', createdAt: iso(3_000) }),
    ];
    expect(selectJob(cands, fac(), ctx(), DEFAULT_WEIGHTS)?.id).toBe('c');
  });
});

describe('scheduler §7 Phase 3 — selectPreemptionVictim (pure)', () => {
  const running = (over: Partial<RunningJobView> & { id: string }): RunningJobView => ({
    productId: 'lysnrai',
    priority: 'medium',
    priorityOrder: PRIORITY_ORDER[over.priority ?? 'medium'],
    leaseEpoch: 1,
    ...over,
  });

  it('critical job evicts a strictly-lower-priority running job', () => {
    const critJob = job({ id: 'crit', priority: 'critical' });
    const victims = [running({ id: 'r1', priority: 'low' })];
    const decision = selectPreemptionVictim(critJob, victims, fac());
    expect(decision).not.toBeNull();
    expect(decision!.evict).toBe('r1');
    expect(decision!.reason).toContain('preempts');
  });

  it('never evicts an equal-priority running job', () => {
    const critJob = job({ id: 'crit', priority: 'critical' });
    const victims = [running({ id: 'r1', priority: 'critical' })];
    const decision = selectPreemptionVictim(critJob, victims, fac());
    expect(decision).toBeNull();
  });

  it('never evicts a higher-priority running job', () => {
    const highJob = job({ id: 'high', priority: 'high' });
    const victims = [running({ id: 'r1', priority: 'critical' })];
    const decision = selectPreemptionVictim(highJob, victims, fac());
    expect(decision).toBeNull();
  });

  it('only critical jobs may trigger preemption', () => {
    const highJob = job({ id: 'high', priority: 'high' });
    const victims = [running({ id: 'r1', priority: 'low' })];
    const decision = selectPreemptionVictim(highJob, victims, fac());
    expect(decision).toBeNull();
  });

  it('picks the lowest priority victim among multiple', () => {
    const critJob = job({ id: 'crit', priority: 'critical' });
    const victims = [
      running({ id: 'r1', priority: 'high' }),
      running({ id: 'r2', priority: 'low' }),
      running({ id: 'r3', priority: 'medium' }),
    ];
    const decision = selectPreemptionVictim(critJob, victims, fac());
    expect(decision!.evict).toBe('r2');
  });

  it('returns null when no running jobs', () => {
    const critJob = job({ id: 'crit', priority: 'critical' });
    expect(selectPreemptionVictim(critJob, [], fac())).toBeNull();
  });

  it('capability mismatch → no preemption', () => {
    const critJob = job({ id: 'crit', priority: 'critical', capabilities: ['os:mac'] });
    const victims = [running({ id: 'r1', priority: 'low' })];
    const decision = selectPreemptionVictim(critJob, victims, fac({ capabilities: [] }));
    expect(decision).toBeNull();
  });

  it('capability match succeeds', () => {
    const critJob = job({ id: 'crit', priority: 'critical', capabilities: ['os:mac'] });
    const victims = [running({ id: 'r1', priority: 'low' })];
    const decision = selectPreemptionVictim(critJob, victims, fac({ capabilities: ['os:mac'] }));
    expect(decision!.evict).toBe('r1');
  });
});
