/**
 * End-of-game statistics.
 *
 * Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
 * `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
 * engine to produce any of this.
 *
 * Three purposes, in ascending order of usefulness:
 *
 *   1. It is interesting to see what happens in a game.
 *   2. It may reveal strategies — whether freight specialists outscore passenger specialists, say.
 *   3. **It exposes bugs.** Every serious bug found so far was found by noticing a number that was
 *      wrong or absent, not by a failing test: facilities that were never worked, cars that were
 *      never spotted, trains that never departed. So `anomalies()` below treats "this never
 *      happened" as a first-class finding rather than something to scroll past.
 */

import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.ts';

// ---------------------------------------------------------------------------
// Per-game summary
// ---------------------------------------------------------------------------

export type RevenueBreakdown = {
  freightLoad: number;
  freightUnload: number;
  passengerBoard: number;
  passengerDetrain: number;
  collisionLoss: number;
  net: number;
};

export type GameStats = {
  seed: number;
  result: string;
  reason: string;
  days: number;
  turns: number;

  revenue: RevenueBreakdown;
  /** Freight's share of gross positive Revenue, 0..1. The strategy signal. */
  freightShare: number;

  trains: {
    scheduled: number;
    highballed: number;
    arrived: number;
    completed: number;
    clearanceRequests: number;
    clearancesAllowed: number;
  };

  development: {
    finalTier: string;
    gridSize: number;
    freightFacilities: number;
    cardsDrawn: number;
    cardsPlayed: number;
    cardsDiscarded: number;
    officeUpgrades: number;
  };

  actions: {
    switch: number;
    draw: number;
    freightAgent: number;
    moves: number;
    drops: number;
    couples: number;
  };

  workers: { laborerActions: number; porterActions: number };

  collisions: number;
  eventCounts: Record<string, number>;
  intentCounts: Record<string, number>;
};

function inc(map: Record<string, number>, key: string, by = 1): void {
  map[key] = (map[key] ?? 0) + by;
}

export function summarize(
  seed: number,
  events: GameEvent[],
  intents: Intent['type'][],
  final: GameState,
): GameStats {
  const eventCounts: Record<string, number> = {};
  const intentCounts: Record<string, number> = {};

  const rev: RevenueBreakdown = {
    freightLoad: 0,
    freightUnload: 0,
    passengerBoard: 0,
    passengerDetrain: 0,
    collisionLoss: 0,
    net: 0,
  };

  let clearanceRequests = 0;
  let clearancesAllowed = 0;
  let highballed = 0;
  let arrived = 0;
  let completed = 0;

  for (const e of events) {
    inc(eventCounts, e.type);

    if (e.type === 'revenueChanged') {
      const reason = e.reason;
      if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
      else if (reason === 'freightLoad') rev.freightLoad += e.delta;
      else if (reason === 'boarding') rev.passengerBoard += e.delta;
      else if (reason === 'detraining') rev.passengerDetrain += e.delta;
    }

    if (e.type === 'clearanceRequested') clearanceRequests++;
    if (e.type === 'clearanceGiven' && e.allow) clearancesAllowed++;

    // Train lifecycle now has proper event types; it used to be smuggled through `phaseBegan`
    // free text, which made the replay unable to say anything useful about it.
    if (e.type === 'trainHighballed') highballed++;
    else if (e.type === 'trainArrived') arrived++;
    else if (e.type === 'trainCompleted') completed++;
  }

  for (const i of intents) inc(intentCounts, i);

  // An unload scores through the same event as a load completion in the reducer, so count the
  // distinct operations directly.
  rev.freightUnload = eventCounts['unloadBegan'] ?? 0;
  rev.net = final.players.reduce((n, p) => n + p.revenue, 0);

  const grossFreight = rev.freightLoad;
  const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
  const gross = grossFreight + grossPassenger;

  const area = final.officeAreas.get(0);
  let freightFacilities = 0;
  if (area) {
    for (const card of area.grid.values()) {
      if (card.facility?.kind === 'freight') freightFacilities++;
    }
  }

  return {
    seed,
    result: final.outcome?.result ?? 'unfinished',
    reason: final.outcome?.reason ?? 'none',
    days: Math.max(1, final.clock.day - 1),
    turns: intents.length,

    revenue: rev,
    freightShare: gross > 0 ? grossFreight / gross : 0,

    trains: {
      scheduled: eventCounts['trainScheduled'] ?? 0,
      highballed,
      arrived,
      completed,
      clearanceRequests,
      clearancesAllowed,
    },

    development: {
      finalTier: area?.tier ?? 'unknown',
      gridSize: area?.grid.size ?? 0,
      freightFacilities,
      cardsDrawn: eventCounts['cardDrawn'] ?? 0,
      cardsPlayed: eventCounts['cardPlayed'] ?? 0,
      cardsDiscarded: eventCounts['cardDiscarded'] ?? 0,
      officeUpgrades: eventCounts['officeUpgraded'] ?? 0,
    },

    actions: {
      switch: intentCounts['switch.move'] ?? 0,
      draw: (intentCounts['draw.fromHomeOffice'] ?? 0) + (intentCounts['draw.fromDepartment'] ?? 0),
      freightAgent:
        (intentCounts['freightAgent.stockOutbound'] ?? 0) +
        (intentCounts['freightAgent.clearInbound'] ?? 0) +
        (intentCounts['freightAgent.unjam'] ?? 0),
      moves: eventCounts['trayMoved'] ?? 0,
      drops: eventCounts['carsDropped'] ?? 0,
      couples: eventCounts['carsCoupled'] ?? 0,
    },

    workers: {
      laborerActions: (eventCounts['loadAdvanced'] ?? 0) + (eventCounts['loadCompleted'] ?? 0) + (eventCounts['unloadBegan'] ?? 0),
      porterActions: (eventCounts['passengersBoarded'] ?? 0) + (eventCounts['passengersDetrained'] ?? 0),
    },

    collisions: events.filter(
      (e) => e.type === 'revenueChanged' && e.reason.startsWith('collision'),
    ).length,

    eventCounts,
    intentCounts,
  };
}

// ---------------------------------------------------------------------------
// Anomaly detection — purpose 3
// ---------------------------------------------------------------------------

/**
 * Everything the engine can emit or accept. A member of these lists that NEVER fires across a whole
 * run is the signal this module exists for: it means a rule is unreachable, and unreachable rules
 * are where the bugs have been.
 */
const EXPECTED_EVENTS = [
  'trainScheduled',
  'extraQueued',
  'trainMadeUp',
  'trainHighballed',
  'trainArrived',
  'trainCompleted',
  'carPlacedOnTrain',
  'trayMoved',
  'carsCoupled',
  'mainlineModified',
  'redFlagsSet',
  'flyingSwitch',
  'carsDropped',
  'cardDrawn',
  'cardPlayed',
  'cardDiscarded',
  'officeUpgraded',
  'stockToOutbound',
  'inboundCleared',
  'loadAdvanced',
  'loadCompleted',
  'unloadBegan',
  'passengersBoarded',
  'passengersDetrained',
  'revenueChanged',
  'clearanceRequested',
  'clearanceGiven',
] as const;

const EXPECTED_INTENTS = [
  'localOps.choose',
  'switch.move',
  'switch.dropCars',
  'draw.fromHomeOffice',
  'draw.fromDepartment',
  'card.play',
  'card.discard',
  'freightAgent.stockOutbound',
  'freightAgent.clearInbound',
  'newTrain.placeCar',
  'porter.board',
  'porter.detrain',
  'laborer.advanceLoad',
  'laborer.beginUnload',
  'mainline.clearance',
] as const;

export type Anomaly = { severity: 'never' | 'rare' | 'odd'; what: string; detail: string };

export function anomalies(all: GameStats[]): Anomaly[] {
  const out: Anomaly[] = [];
  if (all.length === 0) return out;

  const totalEvents: Record<string, number> = {};
  const totalIntents: Record<string, number> = {};
  for (const g of all) {
    for (const [k, v] of Object.entries(g.eventCounts)) inc(totalEvents, k, v);
    for (const [k, v] of Object.entries(g.intentCounts)) inc(totalIntents, k, v);
  }

  for (const e of EXPECTED_EVENTS) {
    const n = totalEvents[e] ?? 0;
    if (n === 0) {
      out.push({
        severity: 'never',
        what: `event ${e}`,
        detail: `never fired in ${all.length} games — the rule producing it may be unreachable`,
      });
    } else if (n < all.length / 20) {
      out.push({
        severity: 'rare',
        what: `event ${e}`,
        detail: `only ${n} across ${all.length} games`,
      });
    }
  }

  for (const i of EXPECTED_INTENTS) {
    const n = totalIntents[i] ?? 0;
    if (n === 0) {
      out.push({
        severity: 'never',
        what: `intent ${i}`,
        detail: `never chosen in ${all.length} games — unreachable, or the bot never wants it`,
      });
    }
  }

  // Structural checks: things that must hold if the model is coherent.
  const scheduled = all.reduce((n, g) => n + g.trains.scheduled, 0);
  const arrived = all.reduce((n, g) => n + g.trains.arrived, 0);
  if (scheduled > 0 && arrived === 0) {
    out.push({
      severity: 'never',
      what: 'train arrivals',
      detail: `${scheduled} trains scheduled but none ever reached an Office`,
    });
  }

  const highballed = all.reduce((n, g) => n + g.trains.highballed, 0);
  if (arrived > 0 && highballed < arrived) {
    out.push({
      severity: 'odd',
      what: 'trains departing',
      detail: `${arrived} arrivals but only ${highballed} departures — trains may be parking`,
    });
  }

  const tiers = new Set(all.map((g) => g.development.finalTier));
  if (tiers.size === 1) {
    out.push({
      severity: 'odd',
      what: 'Office tier',
      detail: `every game ended at "${[...tiers][0]}" — upgrades may be unreachable or automatic`,
    });
  }

  return out;
}

// ---------------------------------------------------------------------------
// Strategy analysis — purpose 2
// ---------------------------------------------------------------------------

export type StrategyBucket = {
  label: string;
  games: number;
  meanRevenue: number;
  winRate: number;
};

/**
 * Buckets games by how their Revenue was earned, then compares outcomes. If freight specialists
 * consistently outscore passenger specialists (or vice versa), it shows up here.
 */
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
  const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);

  const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
    { label: 'passenger-only  (freight 0%)', test: (g) => g.freightShare === 0 },
    { label: 'passenger-heavy (<33%)', test: (g) => g.freightShare > 0 && g.freightShare < 0.33 },
    { label: 'balanced        (33-66%)', test: (g) => g.freightShare >= 0.33 && g.freightShare <= 0.66 },
    { label: 'freight-heavy   (>66%)', test: (g) => g.freightShare > 0.66 && g.freightShare < 1 },
    { label: 'freight-only    (100%)', test: (g) => g.freightShare === 1 },
  ];

  return buckets.map(({ label, test }) => {
    const members = scored.filter(test);
    return {
      label,
      games: members.length,
      meanRevenue:
        members.length > 0 ? members.reduce((n, g) => n + g.revenue.net, 0) / members.length : 0,
      winRate:
        members.length > 0 ? members.filter((g) => g.result === 'win').length / members.length : 0,
    };
  });
}

// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------

const num = (n: number, w = 7, dp = 1): string => n.toFixed(dp).padStart(w);

function meanOf(all: GameStats[], get: (g: GameStats) => number): number {
  return all.length === 0 ? 0 : all.reduce((n, g) => n + get(g), 0) / all.length;
}

export function formatGameStats(g: GameStats): string {
  const out: string[] = [];
  out.push(`\n--- game seed ${g.seed} · ${g.result}/${g.reason} · ${g.days} Days · ${g.turns} turns`);
  out.push(`  revenue net ${g.revenue.net}`);
  out.push(`    freight loads     ${g.revenue.freightLoad}`);
  out.push(`    freight unloads   ${g.revenue.freightUnload}`);
  out.push(`    passengers on     ${g.revenue.passengerBoard}`);
  out.push(`    passengers off    ${g.revenue.passengerDetrain}`);
  out.push(`    lost to collisions -${g.revenue.collisionLoss}`);
  out.push(`  freight share ${(g.freightShare * 100).toFixed(0)}%`);
  out.push(
    `  trains  scheduled ${g.trains.scheduled}  highballed ${g.trains.highballed}` +
      `  arrived ${g.trains.arrived}  completed ${g.trains.completed}`,
  );
  out.push(
    `  office  ${g.development.finalTier}  grid ${g.development.gridSize}` +
      `  facilities ${g.development.freightFacilities}`,
  );
  return out.join('\n');
}

export function formatAggregate(all: GameStats[]): string {
  const out: string[] = [];
  out.push(`\n=== end-of-game statistics · ${all.length} games ===\n`);

  out.push('  REVENUE (mean per game)');
  out.push(`    net                    ${num(meanOf(all, (g) => g.revenue.net))}`);
  out.push(`    from freight loads     ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
  out.push(`    from passengers on     ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
  out.push(`    from passengers off    ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
  out.push(`    lost to collisions     ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
  out.push(`    freight share of gross ${num(meanOf(all, (g) => g.freightShare) * 100, 6, 0)}%`);

  out.push('\n  TRAFFIC (mean per game)');
  out.push(`    trains scheduled       ${num(meanOf(all, (g) => g.trains.scheduled))}`);
  out.push(`    highballs              ${num(meanOf(all, (g) => g.trains.highballed))}`);
  out.push(`    arrivals at an Office  ${num(meanOf(all, (g) => g.trains.arrived))}`);
  out.push(`    runs completed         ${num(meanOf(all, (g) => g.trains.completed))}`);
  out.push(`    clearance requests     ${num(meanOf(all, (g) => g.trains.clearanceRequests))}`);
  out.push(`    collisions             ${num(meanOf(all, (g) => g.collisions))}`);

  out.push('\n  DEVELOPMENT (mean per game)');
  out.push(`    cards drawn            ${num(meanOf(all, (g) => g.development.cardsDrawn))}`);
  out.push(`    cards played           ${num(meanOf(all, (g) => g.development.cardsPlayed))}`);
  out.push(`    office upgrades        ${num(meanOf(all, (g) => g.development.officeUpgrades))}`);
  out.push(`    final grid size        ${num(meanOf(all, (g) => g.development.gridSize))}`);
  out.push(`    freight facilities     ${num(meanOf(all, (g) => g.development.freightFacilities))}`);

  const tierCounts: Record<string, number> = {};
  for (const g of all) inc(tierCounts, g.development.finalTier);
  out.push('    final Office tier:');
  for (const [t, n] of Object.entries(tierCounts).sort((a, b) => b[1] - a[1])) {
    out.push(`      ${t.padEnd(14)} ${n} (${((n / all.length) * 100).toFixed(0)}%)`);
  }

  out.push('\n  ACTION MIX (mean per game)');
  out.push(`    switch moves           ${num(meanOf(all, (g) => g.actions.moves))}`);
  out.push(`    cars dropped           ${num(meanOf(all, (g) => g.actions.drops))}`);
  out.push(`    cars coupled           ${num(meanOf(all, (g) => g.actions.couples))}`);
  out.push(`    freight agent ops      ${num(meanOf(all, (g) => g.actions.freightAgent))}`);
  out.push(`    laborer actions        ${num(meanOf(all, (g) => g.workers.laborerActions))}`);
  out.push(`    porter actions         ${num(meanOf(all, (g) => g.workers.porterActions))}`);

  out.push('\n  STRATEGY — does the revenue mix predict the score?');
  out.push(`    ${'bucket'.padEnd(30)} games   mean rev   win rate`);
  for (const b of strategyBuckets(all)) {
    out.push(
      `    ${b.label.padEnd(30)} ${String(b.games).padStart(5)}   ${num(b.meanRevenue, 8)}   ${num(b.winRate * 100, 6, 0)}%`,
    );
  }

  const found = anomalies(all);
  out.push('\n  ANOMALIES');
  if (found.length === 0) {
    out.push('    none — every expected event and intent occurred at a plausible rate');
  } else {
    for (const a of found) {
      out.push(`    [${a.severity.toUpperCase().padEnd(5)}] ${a.what}: ${a.detail}`);
    }
  }

  return out.join('\n');
}
