import { describe, expect, it } from 'vitest'

import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast-v0.js'

function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
  return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
}

describe('priorForEstimate', () => {
  it('returns the exact prior for a bucket day count', () => {
    expect(priorForEstimate(3)).toBe(COLD_START_PRIORS[3])
    expect(priorForEstimate(8)).toBe(COLD_START_PRIORS[8])
  })

  it('snaps a non-bucket estimate to the nearest bucket', () => {
    expect(priorForEstimate(4)).toBe(COLD_START_PRIORS[3]) // tie → smaller bucket
    expect(priorForEstimate(6)).toBe(COLD_START_PRIORS[5])
    expect(priorForEstimate(100)).toBe(COLD_START_PRIORS[8])
  })

  it('every prior is pessimistic (median actual runs longer than the estimate)', () => {
    for (const p of Object.values(COLD_START_PRIORS)) expect(p.mu).toBeGreaterThan(0)
  })
})

describe('forecast', () => {
  const scope = [
    issue(1, { estimateDays: 2 }),
    issue(2, { estimateDays: 3 }),
    issue(3, { estimateDays: 1 }),
  ]

  it('is reproducible: same seed → identical result', () => {
    const a = forecast(scope, [], { trials: 500, seed: 42 })
    const b = forecast(scope, [], { trials: 500, seed: 42 })
    expect(a).toEqual(b)
  })

  it('percentiles are ordered p50 <= p80 <= p95', () => {
    const f = forecast(scope, [], { trials: 3000 })
    expect(f.p50Day).toBeLessThanOrEqual(f.p80Day)
    expect(f.p80Day).toBeLessThanOrEqual(f.p95Day)
  })

  it('the burn-up curve is monotonic in both fraction and day', () => {
    const f = forecast(scope, [], { trials: 3000 })
    expect(f.curve).toHaveLength(3)
    for (let i = 1; i < f.curve.length; i++) {
      expect(f.curve[i].fraction).toBeGreaterThan(f.curve[i - 1].fraction)
      expect(f.curve[i].p50Day).toBeGreaterThan(f.curve[i - 1].p50Day)
    }
    expect(f.curve.at(-1)!.fraction).toBeCloseTo(1)
  })

  it('lo/mid/hi are ordered within each cone point', () => {
    const f = forecast(scope, [], { trials: 3000 })
    for (const pt of f.curve) {
      expect(pt.p10Day).toBeLessThanOrEqual(pt.p50Day)
      expect(pt.p50Day).toBeLessThanOrEqual(pt.p90Day)
    }
  })

  it('the median landing runs longer than the raw estimate sum (pessimism)', () => {
    const rawSum = 2 + 3 + 1
    const f = forecast(scope, [], { trials: 4000 })
    expect(f.p50Day).toBeGreaterThan(rawSum)
  })

  it('reports scope and cold-start honestly', () => {
    const f = forecast(scope, [], { trials: 100 })
    expect(f.scope).toBe(3)
    expect(f.coldStart).toBe(true)
  })

  it('a dependency cycle yields an empty, zeroed forecast', () => {
    const edges: DependencyEdge[] = [
      { issue: 1, dependsOn: 2 },
      { issue: 2, dependsOn: 1 },
    ]
    const f = forecast([issue(1), issue(2)], edges, { trials: 100 })
    expect(f.scope).toBe(0)
    expect(f.curve).toEqual([])
    expect(f.p80Day).toBe(0)
  })

  it('an empty scope forecasts nothing', () => {
    const f = forecast([], [], { trials: 100 })
    expect(f.scope).toBe(0)
    expect(f.curve).toEqual([])
  })

  it('unestimated issues fall back to the default-estimate prior', () => {
    const f = forecast([issue(1, { estimateDays: null }), issue(2, { estimateDays: null })], [], {
      trials: 500,
    })
    expect(f.scope).toBe(2)
    expect(f.p50Day).toBeGreaterThan(0)
  })
})
