// scripts/analyze-turns.js
// Ingest replay-combat.js stdout (or any text matching its format), reconstruct
// rounds, report real skips + double-acts. Deterministic — no eyeballing.
//
// Usage:
//   node scripts/analyze-turns.js [path]        # analyze a saved log file
//   node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js
//   cat /tmp/replay.log | node scripts/analyze-turns.js
//
// Skip = participant active for WHOLE round (never deactivated/removed mid-round
// before their slot, never added mid-round) but never appeared as a turn actor.
// Double-act = same participant takes 2+ turns in one round.
//
// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app
// logs too, not just replay stdout. Format-agnostic core lives in parseReplay().

'use strict';

const fs = require('fs');

// ---------- parsing ----------

const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|\s*order=\[(.*)\](?:\s*cur=.*)?)?\s*$/;
const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
const PAUSE_RE = /^\s*\[pause\]\s*$/;
const RESUME_RE = /^\s*\[resume\]\s*$/;
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+(?:complete|starting)/;
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/;
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;

function parseLine(line) {
  if (TURN_RE.test(line)) {
    const m = line.match(TURN_RE);
    const orderStr = m[4] || '';
    // parse Name:init pairs
    const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => {
      const [name, init] = pair.split(':');
      return { name: name.trim(), init: init !== undefined ? +init : null };
    });
    return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order };
  }
  if (FIRST_RE.test(line)) {
    const m = line.match(FIRST_RE);
    return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() };
  }
  if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() };
  if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() };
  if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() };
  if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() };
  if (PAUSE_RE.test(line)) return { kind: 'pause' };
  if (RESUME_RE.test(line)) return { kind: 'resume' };
  if (POINTER_RE.test(line)) {
    const m = line.match(POINTER_RE);
    return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
  }
  if (REORDER_RE.test(line)) {
    const m = line.match(REORDER_RE);
    return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() };
  }
  if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
  return null;
}

// ---------- reconstruction ----------

// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] }
// Then compute skips + double-acts.
function reconstruct(events) {
  // global state: active set by name. Start populated lazily from first turn.
  const active = new Set();
  const rounds = new Map(); // round -> { turns: [name], events: [{...}] }
  let curRound = 1;
  let sawFirstTurn = false;

  for (const ev of events) {
    if (ev.kind === 'turn') {
      sawFirstTurn = true;
      curRound = ev.round;
      if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false });
      const r = rounds.get(curRound);
      r.turns.push(ev.actor);
      r.events.push({ ...ev, idx: r.events.length });
      if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active
    } else if (ev.kind === 'deactivate') {
      active.delete(ev.name);
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    } else if (ev.kind === 'reactivate' || ev.kind === 'add') {
      active.add(ev.name);
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    } else if (ev.kind === 'remove') {
      active.delete(ev.name);
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    } else if (ev.kind === 'pointer') {
      // wrap pointer advances to next round — credit there.
      if (ev.wrap) curRound += 1;
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    } else if (ev.kind === 'reorder') {
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    } else if (ev.kind === 'round-complete') {
      if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
    }
    // pause/resume: rotation-affecting but no roster change; tracked in events
    else if (ev.kind === 'pause' || ev.kind === 'resume') {
      const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
      r.events.push({ ...ev, idx: r.events.length });
    }
  }
  return rounds;
}

// For each round, recompute active-at-start and acted, then find real skips.
function analyze(rounds) {
  const report = [];
  for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) {
    // Replay stdout doesn't dump roster, so infer "active at round start":
    // walk events IN ORDER, snapshot active set at first turn of this round.
    // We replay from a clean per-round pass using a carry-over active set.
    report.push(analyzeRound(roundN, r));
  }
  return report;
}

// Re-run per-round with active-set carry-over across rounds (module scope).
function analyzeRounds(rounds) {
  // Carry active set + current-name forward round to round.
  let activeCarry = new Set();
  let currentCarry = null;
  const reports = [];
  const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]);
  for (const [roundN, r] of sortedRounds) {
    if (!r.complete) continue; // incomplete final round — can't judge skips
    if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
    const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
    reports.push(result.report);
    activeCarry = result.activeAfter;
    currentCarry = result.currentAfter;
  }
  return reports;
}

// When current participant is deactivated/removed, code advances current to
// next active. That target gets the turn pointer = acts. Parser can't see
// roster/order from stdout, so on deact-current the NEXT turn actor is the
// advance target and is credited an extra "pointer turn" (not a logged turn).
function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) {
  // activeAtStart: Set copy. Mutations during round adjust a working copy.
  const active = new Set(activeAtStart);
  const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed
  const addedThisRound = new Set();
  const turns = []; // ordered actor names (logged)
  const pointerTurns = new Set(); // names that got the turn pointer this round
  let current = currentAtStart; // current participant name (carry)

  for (const ev of r.events) {
    if (ev.kind === 'turn') {
      turns.push(ev.actor);
      pointerTurns.add(ev.actor);
      if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
      current = ev.actor;
    } else if (ev.kind === 'pointer') {
      // mutation advanced current pointer: ev.to now holds it = got the turn.
      // Credit ev.to. Update tracking.
      pointerTurns.add(ev.to);
      current = ev.to;
    } else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
      // deact/REMOVE of current → code auto-advances (emitted as pointer line).
      // Disqualify from whole-round (roster mutation = not "whole round").
      activeWholeRound.delete(ev.name);
      active.delete(ev.name);
    } else if (ev.kind === 'reactivate' || ev.kind === 'add') {
      activeWholeRound.delete(ev.name);
      active.add(ev.name);
    }
  }

  // acted = names that took a turn OR got pointer via mutation-advance
  // (deact/remove of current advances to target — that target acts).
  // Pointer lines from replay tell us the target explicitly.
  const acted = new Set([...turns, ...pointerTurns]);

  // double-acts: logged turns with count > 1 (pointer-credits excluded —
  // a deact-advance target acting once via pointer then once via nextTurn
  // is correct, not a bug).
  const counts = {};
  for (const n of turns) counts[n] = (counts[n] || 0) + 1;
  const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c }));

  // real skip: active for WHOLE round (no roster mutation) AND never got
  // turn/pointer. Mutations disqualify from whole-round already.
  const realSkips = [...activeWholeRound].filter(n => !acted.has(n));

  return {
    report: {
      round: roundN,
      turnCount: turns.length,
      uniqueActors: acted.size,
      realSkips,
      doubleActs,
      turns,
    },
    activeAfter: active,
    currentAfter: current,
  };
}

// ---------- order-shift detection ----------
// Compare order+init between consecutive turn lines. Flag shifts NOT explained
// by: logged reorder, add/remove (roster change), or initiative change.
// DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation
// divergence bug (invariant: display === turnOrderIds === nextTurn).
function detectOrderShifts(events) {
  const shifts = [];
  let prev = null;
  let prevTurnNo = null;
  // mutations since last turn (reorder/add/remove/reactivate/pointer)
  let pending = [];
  let initMap = {}; // name -> last known initiative

  for (const ev of events) {
    if (ev.kind === 'turn' && ev.order && ev.order.length) {
      const curNames = ev.order.map(o => o.name);
      const curInits = {};
      ev.order.forEach(o => { curInits[o.name] = o.init; });

      if (prev) {
        const sameRoster = prev.length === curNames.length &&
          prev.every((n, i) => n === curNames[i]);
        if (!sameRoster) {
          // roster change (add/remove) — skip, expected order shift
        } else {
          // same roster, different order → explainable by reorder OR init change?
          const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames);
          const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init);
          const hasReorder = pending.some(p => p.kind === 'reorder');
          if (orderChanged && !hasReorder && !initChanged) {
            shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' });
          }
        }
      }
      prev = curNames;
      curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; });
      pending = [];
      prevTurnNo = ev.turn;
    } else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' ||
               ev.kind === 'reactivate' || ev.kind === 'pointer') {
      pending.push(ev);
    }
  }
  return shifts;
}

// ---------- CLI ----------

function readInput() {
  const arg = process.argv[2];
  if (arg) return fs.readFileSync(arg, 'utf8');
  // stdin
  return fs.readFileSync(0, 'utf8');
}

function main() {
  const text = readInput();
  const lines = text.split('\n');
  const events = lines.map(parseLine).filter(Boolean);
  const rounds = reconstruct(events);
  const reports = analyzeRounds(rounds);

  let totalSkips = 0;
  let totalDoubles = 0;
  const problemRounds = [];

  for (const rep of reports) {
    const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0;
    if (hasIssue) problemRounds.push(rep);
    totalSkips += rep.realSkips.length;
    totalDoubles += rep.doubleActs.length;
  }

  for (const rep of problemRounds) {
    console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`);
    if (rep.realSkips.length) console.log(`  REAL SKIPS: ${rep.realSkips.join(', ')}`);
    if (rep.doubleActs.length) console.log(`  DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`);
    console.log(`  sequence: ${rep.turns.join(' -> ')}`);
  }

  // order-shift detection: flag unexplained display/rotation divergence
  const shifts = detectOrderShifts(events);
  if (shifts.length) {
    console.log(`\n--- order shifts (${shifts.length}) ---`);
    for (const s of shifts.slice(0, 10)) {
      console.log(`  turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`);
    }
    if (shifts.length > 10) console.log(`  ... +${shifts.length - 10} more`);
  }

  console.log(`\n=== ${reports.length} rounds analyzed ===`);
  console.log(`real skips: ${totalSkips}`);
  console.log(`double-acts: ${totalDoubles}`);
  console.log(`order shifts: ${shifts.length}`);
  const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0;
  console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND');

  process.exit(clean ? 0 : 1);
}

main();
