/**
 * SET 22 — provider desync / degradation / token-ceiling helpers.
 * Pure logic (no fetch) so tests stay deterministic.
 */

export type ProviderEventSink = (message: string) => void;

export function median(nums: number[]): number {
  if (!nums.length) return 0;
  const s = [...nums].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;
}

/** tok/s from a completed call (tokens out preferred; fall back to total). */
export function tokensPerSec(inputTokens: number, outputTokens: number, ms: number): number {
  const toks = (Number(outputTokens) || 0) + (Number(inputTokens) || 0);
  const sec = Math.max(0.001, (Number(ms) || 0) / 1000);
  return toks / sec;
}

export function formatElapsedShort(ms: number): string {
  const s = Math.max(0, Math.round(ms / 1000));
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  const rem = s % 60;
  return rem ? `${m}m ${rem}s` : `${m}m`;
}

export type DegradationState = {
  samples: number[];
  baseline: number | null;
  degraded: boolean;
};

export function createDegradationState(): DegradationState {
  return { samples: [], baseline: null, degraded: false };
}

/**
 * After 5-call warm-up, baseline = median tok/s.
 * Last-3 median < 25% baseline → degraded (once); recover above 60%.
 * Returns event message(s) to emit (0–1).
 */
export function observeThroughput(
  state: DegradationState,
  tokPerSec: number,
): string | null {
  if (!Number.isFinite(tokPerSec) || tokPerSec < 0) return null;
  state.samples.push(tokPerSec);
  // Keep a bounded window for last-3 + baseline stability.
  if (state.samples.length > 32) state.samples.shift();

  if (state.baseline == null) {
    if (state.samples.length < 5) return null;
    state.baseline = median(state.samples.slice(0, 5));
    if (state.baseline <= 0) state.baseline = tokPerSec || 1;
    return null;
  }

  const last3 = state.samples.slice(-3);
  if (last3.length < 3) return null;
  const m3 = median(last3);
  const base = state.baseline;

  if (!state.degraded && m3 < base * 0.25) {
    state.degraded = true;
    return `provider degraded: ${Math.round(m3)} tok/s vs ${Math.round(base)} baseline — check host (GPU fallback?)`;
  }
  if (state.degraded && m3 >= base * 0.6) {
    state.degraded = false;
    return `provider recovered: ${Math.round(m3)} tok/s (baseline ${Math.round(base)})`;
  }
  return null;
}

export type TokenCeilingState = {
  /** Frozen high-water estimate of expected run tokens (0 = not yet set). */
  estimate: number;
  /** Multiplier for pause threshold (default 3). */
  multiplier: number;
  /** Optional explicit estimate for tests / config. */
  forcedEstimate: number | null;
  paused: boolean;
  calls: number;
  tokens: number;
};

export function createTokenCeilingState(opts?: {
  multiplier?: number;
  estimate?: number;
}): TokenCeilingState {
  return {
    estimate: opts?.estimate && opts.estimate > 0 ? opts.estimate : 0,
    multiplier: opts?.multiplier && opts.multiplier > 0 ? opts.multiplier : 3,
    forcedEstimate: opts?.estimate && opts.estimate > 0 ? opts.estimate : null,
    paused: false,
    calls: 0,
    tokens: 0,
  };
}

/**
 * Update ceiling tracker after a completed call.
 * Freezes estimate after 5-call warm-up as tokensSoFar × (budget/calls).
 * Returns true when issuance should pause (does not cancel).
 */
export function observeTokenSpend(
  state: TokenCeilingState,
  callTokens: number,
  estimatedCallBudget = 40,
): { pause: boolean; message: string | null } {
  state.calls += 1;
  state.tokens += Math.max(0, callTokens);

  if (state.forcedEstimate != null) {
    state.estimate = state.forcedEstimate;
  } else if (state.estimate <= 0 && state.calls >= 5) {
    // Freeze a high-water estimate from early healthy spend × remaining budget.
    const perCall = state.tokens / state.calls;
    state.estimate = Math.max(state.tokens, perCall * estimatedCallBudget);
  } else if (state.estimate > 0 && state.forcedEstimate == null) {
    // High-water: allow estimate to ratchet up only modestly with healthy progress
    // (never chase a runaway — ceiling stays meaningful).
    const soft = state.tokens * 1.15;
    if (soft < state.estimate * state.multiplier * 0.5) {
      state.estimate = Math.max(state.estimate, soft);
    }
  }

  if (state.estimate <= 0) return { pause: false, message: null };
  const ceiling = state.estimate * state.multiplier;
  if (!state.paused && state.tokens > ceiling) {
    state.paused = true;
    return {
      pause: true,
      message: 'run paused: runaway token burn — Resume / Cancel',
    };
  }
  return { pause: state.paused, message: null };
}

/** Quiet-threshold probe messaging (SET 22a). */
export function quietProbeMessage(
  status: 'busy' | 'idle' | 'unreachable',
  quietMs: number,
): string {
  const elapsed = formatElapsedShort(quietMs);
  if (status === 'busy') {
    return `still generating (probed host: busy, ${elapsed})`;
  }
  if (status === 'unreachable') {
    return `provider unreachable (${elapsed}) — waiting`;
  }
  return `provider desync: host idle with call in-flight (${elapsed}) — reissuing`;
}

export function connectionLostMessage(n: number, N: number): string {
  return `provider connection lost — retry ${n}/${N}`;
}

export function recoveredAfterMessage(ms: number): string {
  return `provider recovered after ${formatElapsedShort(ms)}`;
}

/** Assert: watchdog paths never request job cancellation. */
export function watchdogMayCancel(): false {
  return false;
}

// ---------------------------------------------------------------------------
// SET 40h — adaptive per-stage quiet probe + hang forensics helpers
// ---------------------------------------------------------------------------

/** Floor for most stages (build/ir/repair/…). */
export const STAGE_QUIET_FLOOR_MS = 45_000;
/** Encode keeps the historical 90s floor (longer prompts). */
export const ENCODE_QUIET_FLOOR_MS = 90_000;
/** Breather before the first HTTP call of a new stage (finalize-race probe). */
export const STAGE_TRANSITION_SPACING_MS = 250;
/** Samples required before hang lines use stage median. */
export const HANG_MEDIAN_MIN_SAMPLES = 3;

export type LlmStageOp =
  | 'encode'
  | 'ir'
  | 'build'
  | 'repair'
  | 'fixStub'
  | 'vanilla'
  | 'referee'
  | 'concept';

export function stageQuietProbeFloor(op: string): number {
  return op === 'encode' ? ENCODE_QUIET_FLOOR_MS : STAGE_QUIET_FLOOR_MS;
}

/**
 * Quiet-probe threshold for a stage: max(floor, 3× stageMedian).
 * No median yet → stage floor (encode 90s, others 45s).
 */
export function adaptiveQuietProbeMs(
  op: string,
  stageMedianMs: number | null | undefined,
): number {
  const floor = stageQuietProbeFloor(op);
  if (
    stageMedianMs == null
    || !Number.isFinite(stageMedianMs)
    || stageMedianMs <= 0
  ) {
    return floor;
  }
  return Math.max(floor, 3 * stageMedianMs);
}

export type HangResolved = 'reissue' | 'completed' | 'cancelled';

/** Bounded hang evidence line — SET 40h / 34 admission. */
export function formatHangEvidenceLine(
  stage: string,
  target: string,
  durationMs: number,
  resolved: HangResolved,
): string {
  const t = Math.max(1, Math.round(Math.max(0, durationMs) / 1000));
  const tgt = (target || stage).replace(/\s+/g, ' ').trim().slice(0, 64);
  return `hang: ${stage}:${tgt} stalled ${t}s (resolved: ${resolved})`;
}
