import {
  type CalibrationModel,
  type CalibrationSample,
  calibrationSamples,
  COLD_START_THRESHOLD,
  type DependencyEdge,
  fitCalibration,
  forecast,
  type GiteaIssue,
  type GiteaMilestone,
  inferLifecycle,
  type LifecycleColumn,
  type LifecycleEvent,
  type LifecycleInference,
  PRIOR_BUCKETS,
  schedule,
  type ScheduledItem,
  selectFocus,
  toDurationModel,
  workingDaysBetween,
} from '@commitea/core'

import {
  type BoardColumn,
  type BoardIssue,
  type CalibrationData,
  type FocusIssue,
  type RunwayMilestone,
} from '../data/fixtures.js'
import { addWorkingDays, type BurnUpData, buildBurnUpData, formatRange, formatShort } from './dates.js'

type Timelines = Record<number, LifecycleEvent[]>

/** Infer every issue's lifecycle once; callers index by issue number. */
function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map<number, LifecycleInference> {
  return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)]))
}

const COLUMN_LABELS: Record<LifecycleColumn, string> = {
  diagnosis: 'Diagnosis',
  triage: 'Triage',
  steeping: 'Steeping',
  review: 'In review',
  done: 'Done',
}

const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done']

function initials(login: string): string {
  return login.slice(0, 2).toUpperCase()
}

/**
 * Shape real gitea issues into the Board's five columns via lifecycle inference
 * (#5). Steeping / In-review are now populated from the event stream (first
 * commit ref → steeping, first PR ref → review); the `days` badge is the
 * steeping age in working days.
 */
export function issuesToBoardColumns(
  issues: GiteaIssue[],
  timelines: Timelines = {},
  asOf: Date = new Date(),
): BoardColumn[] {
  const inf = inferAll(issues, timelines, asOf)
  return COLUMN_ORDER.map((key) => ({
    id: key,
    label: COLUMN_LABELS[key],
    issues: issues
      .filter((i) => inf.get(i.number)!.column === key)
      .map((i): BoardIssue => {
        const li = inf.get(i.number)!
        return {
          id: i.number,
          title: i.title,
          labels: i.labels,
          who: i.assignee ? initials(i.assignee) : '·',
          days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
        }
      }),
  }))
}

export interface FocusView {
  now: FocusIssue | null
  next: FocusIssue | null
  later: FocusIssue | null
}

function toFocusIssue(item: ScheduledItem | null, inf?: Map<number, LifecycleInference>): FocusIssue | null {
  if (!item) return null
  const steepingDays = inf?.get(item.number)?.steepingDays ?? null
  return {
    id: item.number,
    title: item.title,
    labels: item.labels,
    rationale: item.rationale,
    steeping: steepingDays != null ? `${steepingDays}d` : undefined,
  }
}

function toSchedulable(issues: GiteaIssue[]) {
  return issues
    .filter((i) => i.state === 'open')
    .map((i) => ({
      number: i.number,
      title: i.title,
      labels: i.labels,
      estimateDays: i.facts.estimateDays,
      priority: i.facts.priority,
    }))
}

export interface ForecastView {
  scope: number
  cone: BurnUpData
  p80Label: string
  rangeLabel: string
  /** true while the forecast still runs on code priors (calibration not yet trusted). */
  coldStart: boolean
  /** Closed-with-estimate issues feeding calibration so far. */
  calibratedN: number
}

/**
 * Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
 * burn-up cone. When a calibration model is supplied and past cold-start, its
 * fitted params drive the sim. Returns null when there's nothing to forecast.
 * `today` is injectable for tests.
 */
export function forecastBacklog(
  issues: GiteaIssue[],
  deps: DependencyEdge[],
  today: Date = new Date(),
  calibration?: CalibrationModel,
): ForecastView | null {
  const model = calibration ? toDurationModel(calibration) : undefined
  const f = forecast(toSchedulable(issues), deps, model ? { model } : {})
  const cone = buildBurnUpData(f, today)
  if (!cone) return null
  return {
    scope: f.scope,
    cone,
    p80Label: cone.p80Label,
    rangeLabel: cone.rangeLabel,
    coldStart: f.coldStart,
    calibratedN: calibration?.n ?? 0,
  }
}

/** Fit the calibration model from the closed backlog's inferred actuals (#1). */
export function calibrateBacklog(
  issues: GiteaIssue[],
  timelines: Timelines = {},
  asOf: Date = new Date(),
): CalibrationModel {
  return fitCalibration(calibrationSamples(issues, timelines, asOf))
}

/** Calibration model + its screen view in one pass over the closed backlog. */
export function backlogCalibration(
  issues: GiteaIssue[],
  timelines: Timelines = {},
  asOf: Date = new Date(),
): { model: CalibrationModel; data: CalibrationData } {
  const samples = calibrationSamples(issues, timelines, asOf)
  const model = fitCalibration(samples)
  return { model, data: calibrationData(model, samples, issues) }
}

const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100)

/**
 * Shape the calibration model + its samples into the screen's view. Buckets and
 * people only earn a bias once their sample clears the fit floor; everything
 * degrades honestly on a thin (cold-start) dataset.
 */
export function calibrationData(
  model: CalibrationModel,
  samples: CalibrationSample[],
  openIssues: GiteaIssue[],
): CalibrationData {
  const labels = PRIOR_BUCKETS.map((b) => {
    const inBucket = samples.filter((s) => s.bucket === b)
    const fit = model.byBucket[b]
    const mu = fit ? fit.mu : model.global.mu
    return {
      label: `est/${b}d`,
      n: fit ? fit.n : inBucket.length,
      median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—',
      bias: fit ? pctFromMu(fit.mu) : null,
    }
  })

  const people = Object.entries(model.byPerson).map(([who, pb]) => ({
    who,
    n: pb.n,
    bias: pctFromMu(model.global.mu + pb.biasMu),
    note: '',
  }))

  const openEst = openIssues
    .filter((i) => i.state === 'open')
    .reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
  const effect = model.coldStart
    ? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' }
    : {
        raw: `${openEst}d estimated`,
        banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
        p50: `≈${Math.round(openEst * Math.exp(model.global.mu))}d`,
      }

  return {
    n: model.n,
    active: !model.coldStart,
    labels,
    people,
    scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]),
    fit: Number(Math.exp(model.global.mu).toFixed(2)),
    effect,
  }
}

/**
 * Run the deterministic scheduler over the open backlog and take the top three
 * as Now/Next/Later. Estimates + priority come from label facts; dependency
 * edges come from gitea's native issue dependencies.
 */
export function scheduleFocus(
  issues: GiteaIssue[],
  deps: DependencyEdge[],
  timelines: Timelines = {},
  asOf: Date = new Date(),
): FocusView {
  const plan = schedule(toSchedulable(issues), deps)
  const f = selectFocus(plan)
  const inf = inferAll(issues, timelines, asOf)
  return {
    now: toFocusIssue(f.now, inf),
    next: toFocusIssue(f.next, inf),
    later: toFocusIssue(f.later, inf),
  }
}

/**
 * Per-milestone Monte Carlo forecast for the Runway screen — each open milestone's
 * open scope gets its own cone, and the p80 landing is compared to the milestone's
 * due date (ok/at-risk). Ranges, never point dates. Milestones with no open scope
 * (already shipped) are omitted. Falls back to the demo when there's nothing real.
 */
export function runwayView(
  issues: GiteaIssue[],
  milestones: GiteaMilestone[],
  deps: DependencyEdge[],
  today: Date = new Date(),
): RunwayMilestone[] {
  const open = issues.filter((i) => i.state === 'open')
  const rows = milestones
    .filter((m) => m.state === 'open')
    .map((m) => ({ m, scope: open.filter((i) => i.milestone?.id === m.id) }))
    .filter((x) => x.scope.length > 0)
    .map(({ m, scope }) => {
      const f = forecast(
        scope.map((i) => ({
          number: i.number,
          title: i.title,
          labels: i.labels,
          estimateDays: i.facts.estimateDays,
          priority: i.facts.priority,
        })),
        deps,
      )
      const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
      const dueDay = m.dueOn ? workingDaysBetween(today, new Date(m.dueOn)) : null
      return { m, p50: f.p50Day, p80: f.p80Day, p90, dueDay }
    })

  const horizon = Math.max(1, ...rows.map((r) => Math.max(r.p90, r.dueDay ?? 0)))

  return rows.map(({ m, p50, p80, p90, dueDay }) => {
    const onTrack = dueDay == null || p80 <= dueDay
    return {
      name: m.title,
      due: m.dueOn ? formatShort(new Date(m.dueOn)) : 'no date',
      hard: false,
      p80: formatRange(addWorkingDays(today, p50), addWorkingDays(today, p90)),
      pos: p80 / horizon,
      spread: Math.min(0.6, (p90 - p50) / horizon),
      tone: onTrack ? ('ok' as const) : ('warn' as const),
      note: dueDay == null ? `${Math.ceil(p80)}d of work` : onTrack ? 'on track' : 'at risk',
    }
  })
}
