/**
 * Monte Carlo forecast, v0. The LLM never does this — it's plain, seeded,
 * reproducible code (evidence-based scheduling). It samples an actual duration
 * per open issue from a lognormal prior, walks the scheduler's deterministic
 * order on a single serial worker, and reads percentiles off the resulting
 * completion-day distribution.
 *
 * v0 simplifications (each a later slice, not a hack):
 *  - single serial worker; per-person capacity + parallelism is #8.
 *  - cold-start priors only; the team's empirical calibration fit lands at
 *    n >= 20 closed-with-estimate issues (#5 supplies the actuals).
 *  - forecast covers remaining (open) scope from today forward; the historical
 *    burn-up "actual" polyline needs lifecycle event dates (#5).
 */

import {
  type DependencyEdge,
  schedule,
  type SchedulableIssue,
} from '../scheduler/scheduler-v0.js'

export interface LognormalPrior {
  /** Median log-ratio: sampled median duration = estimate * e^mu. */
  mu: number
  /** Spread of log(actual / estimate). */
  sigma: number
}

/**
 * Code-resident cold-start priors (D3). Lognormal on log(actual / estimate):
 * sampled actual = estimateDays * exp(N(mu, sigma)). mu > 0 encodes the honest
 * fact that actuals run long; sigma shrinks as tickets grow (a snag doubles a
 * 1-day task but barely dents an 8-day one). The team's fitted model replaces
 * these at n >= 20 (see calibration model in pm-state.md).
 */
export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
  1: { mu: 0.25, sigma: 0.55 },
  2: { mu: 0.22, sigma: 0.48 },
  3: { mu: 0.2, sigma: 0.44 },
  5: { mu: 0.18, sigma: 0.4 },
  8: { mu: 0.16, sigma: 0.36 },
}

const PRIOR_BUCKETS = [1, 2, 3, 5, 8]

/** Nearest estimate bucket (ties resolve to the smaller bucket). */
export function priorForEstimate(days: number): LognormalPrior {
  let best = PRIOR_BUCKETS[0]
  for (const b of PRIOR_BUCKETS) {
    if (Math.abs(b - days) < Math.abs(best - days)) best = b
  }
  return COLD_START_PRIORS[best]
}

export interface ForecastOptions {
  /** Simulation trials. More = smoother tails, linear cost. */
  trials?: number
  /** PRNG seed. Fixed by default so a forecast is reproducible. */
  seed?: number
}

export interface BurnUpPoint {
  /** Cumulative fraction of remaining scope complete (0 < f <= 1]. */
  fraction: number
  /** Working-day offset from today at the p10 / p50 / p90 of reaching it. */
  p10Day: number
  p50Day: number
  p90Day: number
}

export interface Forecast {
  /** Open issues in scope (== scheduled count). */
  scope: number
  trials: number
  /** true while code priors drive the sim; false once the empirical fit is in (v1). */
  coldStart: boolean
  /** Working-day offsets from today for the whole scope landing. */
  p50Day: number
  p80Day: number
  p95Day: number
  /** Burn-up cone, one point per scheduled issue, fraction ascending. */
  curve: BurnUpPoint[]
}

const DEFAULT_TRIALS = 2000
const DEFAULT_SEED = 0x9e3779b9

/** mulberry32 — small, fast, seedable PRNG (no reliance on Math.random). */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0
  return () => {
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

/** Box–Muller standard normal from a uniform PRNG. */
function standardNormal(rng: () => number): number {
  let u1 = rng()
  const u2 = rng()
  if (u1 < 1e-12) u1 = 1e-12
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
}

function percentile(sortedAsc: number[], q: number): number {
  const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(q * (sortedAsc.length - 1))))
  return sortedAsc[idx]
}

/**
 * Monte Carlo over the deterministic schedule order. Order is fixed (it's
 * derived from estimate labels + dependencies, not sampled), so only durations
 * vary across trials — the cone stretches, it never reorders.
 */
export function forecast(
  issues: SchedulableIssue[],
  edges: DependencyEdge[],
  options: ForecastOptions = {},
): Forecast {
  const trials = options.trials ?? DEFAULT_TRIALS
  const seed = options.seed ?? DEFAULT_SEED
  const order = schedule(issues, edges).items // empty when a dependency cycle exists
  const n = order.length
  if (n === 0) {
    return { scope: 0, trials, coldStart: true, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
  }

  const priors = order.map((it) => priorForEstimate(it.durationDays))
  const rng = mulberry32(seed)

  // endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
  const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
  for (let t = 0; t < trials; t++) {
    let cursor = 0
    for (let k = 0; k < n; k++) {
      const p = priors[k]
      const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
      cursor += sampled
      endByRank[k][t] = cursor
    }
  }

  const curve: BurnUpPoint[] = endByRank.map((row, k) => {
    const sorted = [...row].sort((a, b) => a - b)
    return {
      fraction: (k + 1) / n,
      p10Day: percentile(sorted, 0.1),
      p50Day: percentile(sorted, 0.5),
      p90Day: percentile(sorted, 0.9),
    }
  })

  const total = [...endByRank[n - 1]].sort((a, b) => a - b)
  return {
    scope: n,
    trials,
    coldStart: true,
    p50Day: percentile(total, 0.5),
    p80Day: percentile(total, 0.8),
    p95Day: percentile(total, 0.95),
    curve,
  }
}
