// scripts/analyze-turns.js
// Invariant checker for combat rotation. Source-agnostic.
//
// Input (autodetect):
//   .jsonl file  — replay-combat trace: per-step {step,ts,type,call:{fn,args},
//                  pre,post}. pre/post = backend read-back snapshots.
//   .json array — downloaded log OR exported events. {ts,type,...,snapshot}.
//                  snapshot = what turn.js logged (lighter: no participants[]).
//   .log file   — replay stdout: extract trace path from 'trace written:' line.
//   stdin       — either jsonl or json.
//   no arg      = usage. User must specify path.
//
// INVARIANTS (define correctness; no prediction):
//  1. round monotonically ascends; +1 only on pointer wrap (last→first active).
//     No backward, no double-increment, no skip.
//  2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive.
//     Never backward, never stationary except on pause/non-rotation mutations.
//  3. no double-act: in one rotation cycle each active participant becomes
//     current ≤1 time.
//  4. no real skip: participant active for full cycle, never removed/deactivated,
//     but never became current = skipped.
//  5. order stable across non-reorder mutations. turnOrderIds shift without
//     add/remove/reorder = display divergence.
//  6. slot order (initiative desc, tie-break stable) maintained except after
//     explicit reorder. Replay-trace only (needs participants[].initiative).
//
// Exit 0 clean, 1 issues found.

'use strict';

const fs = require('fs');

// ---------- input ----------

function readInput() {
  const arg = process.argv[2];

  // No arg + no stdin = usage.
  if (!arg && process.stdin.isTTY) {
    console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
    console.error('       cat events | node scripts/analyze-turns.js');
    process.exit(2);
  }

  if (!arg) {
    const stdin = fs.readFileSync(0, 'utf8');
    if (!stdin.trim()) {
      console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
      console.error('       cat events | node scripts/analyze-turns.js');
      process.exit(2);
    }
    return stdin;
  }

  // Replay stdout .log: extract trace path from `trace written:` line.
  if (/\.log$/i.test(arg)) {
    const logText = fs.readFileSync(arg, 'utf8');
    const m = logText.match(/trace written: \d+ steps -> (.+)$/m);
    if (m) {
      const tracePath = m[1].trim();
      if (fs.existsSync(tracePath)) {
        console.error(`[analyze] trace: ${tracePath}`);
        return fs.readFileSync(tracePath, 'utf8');
      }
      console.error(`[analyze] trace path from .log not found: ${tracePath}`);
      process.exit(2);
    }
    console.error(`[analyze] no 'trace written:' line in ${arg}`);
    process.exit(2);
  }

  return fs.readFileSync(arg, 'utf8');
}

// snake_case log type → camelCase fn (invariant checks match both shapes).
// replay JSONL already camelCase; unchanged.
function normalizeFn(fn) {
  if (!fn) return fn;
  if (!fn.includes('_')) return fn;
  return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}

// Parse input text → array of step-arrays (one per encounter for downloaded
// logs, one for replay JSONL trace). Each step:
//   { step, ts, type, fn, args, pre, post, error }
// JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre.
function loadSteps(text) {
  const trimmed = text.trim();
  if (!trimmed) return [];

  // JSONL: one JSON obj per line (each starts '{' + parses standalone).
  const looksJsonl = (() => {
    const lines = trimmed.split('\n');
    if (lines.length < 2) return false;
    const first = lines[0].trim();
    const second = lines[1].trim();
    if (!first.startsWith('{') || !second.startsWith('{')) return false;
    try { JSON.parse(first); JSON.parse(second); return true; }
    catch { return false; }
  })();

  if (looksJsonl) {
    const steps = trimmed.split('\n').filter(l => l.trim()).map(l => {
      const r = JSON.parse(l);
      return {
        step: r.step, ts: r.ts, type: r.type,
        fn: normalizeFn(r.call ? r.call.fn : r.type),
        args: r.call ? r.call.args : null,
        pre: r.pre || null, post: r.post || null, error: r.error || null,
      };
    });
    return [steps]; // single trace
  }

  // JSON array. Downloaded logs may merge multiple encounters → split by id.
  const raw = JSON.parse(trimmed);
  const arr = Array.isArray(raw) ? raw : [raw];
  const groups = new Map(); // encounterId -> []
  for (const e of arr) {
    const key = e.encounterId || '_none_';
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(e);
  }
  const out = [];
  const toSteps = (evs) => evs.map((e, i) => ({
    step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null,
    pre: null,
    post: e.snapshot ? {
      round: e.snapshot.round,
      currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
      isStarted: true, isPaused: false,
      turnOrderIds: e.snapshot.turnOrderIds || [],
      activeIds: e.snapshot.activeIds || [],
      participants: null, // downloaded logs lack full participant roster
    } : null,
    error: null,
  }));
  for (const evs of groups.values()) {
    // Same encounterId may span multiple combat runs (restart via
    // start_encounter). Sub-split so each rotation cycle analyzed within one
    // continuous run. start_encounter = run boundary — BUT only flush if a
    // prior run already started (i.e. true restart). First start_encounter
    // after pure setup (add_participant etc.) stays with its setup events.
    let cur = [];
    let started = false;
    const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
    for (const e of evs) {
      if (e.type === 'start_encounter' && started) flush();
      if (e.type === 'start_encounter') started = true;
      cur.push(e);
    }
    flush();
  }
  return out;
}

// ---------- helpers ----------

const nameMap = new Map(); // id -> name (built lazily from snapshots)

function learnNames(steps) {
  for (const s of steps) {
    for (const snap of [s.pre, s.post]) {
      if (snap && Array.isArray(snap.participants)) {
        for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name);
      }
    }
  }
}
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }

// next active position after fromPos in order, skipping inactive. Mirrors
// turn.js nextActiveAfter so we know what SHOULD have happened — but this is
// invariant definition, not prediction: we check the ACTUAL post-current.
function expectedAdvance(order, fromPos, isActive) {
  const n = order.length;
  if (n === 0) return { nextId: null, wrapped: false };
  for (let step = 1; step < n; step++) {
    const idx = (fromPos + step) % n;
    const id = order[idx];
    if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
  }
  // solo active = stays itself (turn.js would throw; treat as no-advance)
  return { nextId: null, wrapped: false };
}

// ---------- invariant checks ----------

// Split analysis into independent passes. Each invariant = own function.
// Entangling cycle-skip tracking with per-step mutation handling caused
// stale-set false positives (active-set rebuilt on every roster mutation
// discarded the cycle-start snapshot).
function analyze(steps) {
  const issues = [];
  const rounds = new Map();
  function ensureRound(r) {
    if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] });
    return rounds.get(r);
  }

  // ---- per-step: round monotonic, advance direction, order stability ----
  for (let i = 0; i < steps.length; i++) {
    const s = steps[i];
    const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
    const post = s.post;
    if (!post) continue;

    const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
    const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
    const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';

    if (isStart) { ensureRound(post.round || 1).turnCount++; continue; }
    if (isEnd) continue;
    if (isNextTurn) {
      ensureRound(post.round || 0).turnCount++;
      if (!pre) continue;

      const order = pre.turnOrderIds || [];
      const fromPos = order.indexOf(pre.currentTurnParticipantId);
      const isActive = id => (pre.activeIds || []).includes(id);
      const exp = expectedAdvance(order, fromPos, isActive);
      const actual = post.currentTurnParticipantId;

      // invariant 2: correct advance target
      if (exp.nextId && actual && actual !== exp.nextId) {
        issues.push({ step: s.step, round: post.round, kind: 'wrong_advance',
          expected: nm(exp.nextId), actual: nm(actual),
          detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` });
      }
      // invariant 1: round monotonic + no phantom/skip
      if (pre.round !== undefined && post.round !== undefined) {
        if (post.round < pre.round)
          issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round,
            detail: `round backward ${pre.round}→${post.round}` });
        if (post.round > pre.round + 1)
          issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round,
            detail: `round jumped ${pre.round}→${post.round}` });
        if (post.round === pre.round + 1 && !exp.wrapped)
          issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round,
            detail: `round incremented without pointer wrap` });
      }
      continue;
    }

    // non-rotation mutation: invariant 5 order stability
    if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
      const before = JSON.stringify(pre.turnOrderIds || []);
      const after = JSON.stringify(post.turnOrderIds || []);
      if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
        issues.push({ step: s.step, kind: 'order_shift', fn: s.fn,
          detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` });
      }
    }
  }

  // ---- dedicated cycle pass: skip + double-act (invariants 3+4) ----
  // Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle
  // start. Track removals/deactivations as legitimate disqualifications.
  // Skip = in start-set, never disqualified, never acted.
  issues.push(...checkCycles(steps));

  return { issues, rounds };
}

// checkCycles: walk steps, maintain rotation cycle state. On wrap/end,
// finalize: skip = activeAtStart minus (acted ∪ disqualified). Double-act =
// current that became current >1 in cycle (excluding the legit starter).
function checkCycles(steps) {
  const out = [];
  let cycleActive = new Set();   // snapshot at cycle start (immutable for cycle)
  let cycleActed = new Set();
  let cycleRemoved = new Set();  // removed/disqualified mid-cycle (no skip flag)
  let cycleStarter = null;
  let cycleRound = null;
  let started = false;

  // disqualify by active-set delta, not fn name. Log types vary (deactivate,
  // reactivate, remove_participant, add_participant...). Any step where an id
  // leaves activeIds = disqualified. Any id entering = joins cycle.
  function finalize(endStep) {
    if (!started) return;
    // disqualify: anyone removed/deactivated mid-cycle is gone (legit)
    // skip = was active at start, never acted, never disqualified
    const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
    if (skipped.length) {
      out.push({ step: endStep, round: cycleRound, kind: 'real_skip',
        actors: skipped.map(nm), detail: `active full cycle, never acted` });
    }
  }

  for (let i = 0; i < steps.length; i++) {
    const s = steps[i];
    const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
    const post = s.post;
    if (!post) continue;

    const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
    const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
    const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';

    if (isStart) {
      finalize(s.step);
      cycleRound = post.round || 1;
      cycleActive = new Set(post.activeIds || []);
      cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
      cycleStarter = post.currentTurnParticipantId;
      started = true;
      continue;
    }
    if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete)

    // CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn
    // AND mutation-advance (toggleActive/remove of current auto-advances via
    // computeTurnOrderAfterRemoval). Any new current = got the turn = acted.
    // Only count this on nextTurn (normal) or when a mutation actually moved
    // the pointer (pre.current != post.current).
    if (started && pre && post.currentTurnParticipantId &&
        pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
      const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
      if (wrapped && isNextTurn) {
        finalize(s.step);
        cycleRound = post.round;
        cycleActive = new Set(post.activeIds || []);
        cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
        cycleStarter = post.currentTurnParticipantId;
      } else {
        const c = post.currentTurnParticipantId;
        if (cycleActed.has(c) && c !== cycleStarter) {
          out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c),
            detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` });
        }
        cycleActed.add(c);
      }
    }

    if (isNextTurn) continue;

    // roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable).
    // invariant 4 = active FULL cycle → only removals disqualify (can't have
    // been full-cycle if removed). Mid-cycle additions don't qualify for skip
    // check, so never enroll them. Revivals: stay out (can act, not skip-flag).
    if (pre && post && pre.activeIds && post.activeIds) {
      const postSet = new Set(post.activeIds);
      for (const id of [...cycleActive]) {
        if (!postSet.has(id)) {
          cycleRemoved.add(id);
          cycleActive.delete(id);
          cycleActed.delete(id);
        }
      }
      // no mid-cycle enrollment: new ids weren't active at cycle start.
    }
  }
  // no final finalize: incomplete cycle can't be judged for skips.
  return out;
}

// roster/order-affecting fns where turnOrderIds change is EXPECTED.
// Handle both camelCase (replay trace fn) + snake_case (log type).
function orderChangedByRosterOrReorder(fn) {
  return [
    'addParticipant','addParticipants','removeParticipant','reorderParticipants',
    'startEncounter','endEncounter','setup_encounter','setup_campaign',
    'add_participant','add_participants','remove_participant','reorder',
    'start_encounter','end_encounter',
  ].includes(fn);
}

// slot order (invariant 6) — replay trace only (needs participants[].initiative)
function checkSlotOrder(steps) {
  const violations = [];
  let prevOrder = null; // [{id,init}]
  let prevStep = 0;
  const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant',
    'reorderParticipants','startEncounter','setup_encounter','setup_campaign']);
  for (const s of steps) {
    if (!s.post || !Array.isArray(s.post.participants)) continue;
    const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name }));
    if (prevOrder && prevOrder.length === cur.length) {
      const sameIds = prevOrder.every((p, i) => p.id === cur[i].id);
      if (sameIds) {
        // same roster, same order — check initiative monotonic desc with stable ties
        for (let i = 1; i < cur.length; i++) {
          if (cur[i].initiative > cur[i - 1].initiative) {
            // initiative ascended — only ok if a reorder happened
            if (!orderAffecting.has(s.fn)) {
              violations.push({ step: s.step, kind: 'slot_violation',
                at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init,
                cur: nm(cur[i].id)+':'+cur[i].init,
                detail: `initiative ascended without reorder` });
            }
          }
        }
      }
    }
    prevOrder = cur;
    prevStep = s.step;
  }
  return violations;
}

// ---------- reporting ----------

function reportOne(label, steps) {
  learnNames(steps);
  const { issues, rounds } = analyze(steps);
  const slotViolations = checkSlotOrder(steps);
  const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0));
  const byKind = {};
  for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1;

  console.log(`=== ${label} — ${steps.length} steps, ${rounds.size} rounds ===`);
  if (all.length === 0) {
    console.log('CLEAN');
    return 0;
  }
  console.log(`--- ${all.length} issues ---`);
  for (const k of Object.keys(byKind)) console.log(`  ${k}: ${byKind[k]}`);
  for (const it of all.slice(0, 30)) {
    const where = it.round != null ? `R${it.round} ` : '';
    console.log(`  step ${it.step} ${where}${it.kind}: ${it.detail || ''}`);
  }
  if (all.length > 30) console.log(`  ... +${all.length - 30} more`);
  return all.length;
}

const text = readInput();
const allSteps = loadSteps(text); // array of step-arrays (one per encounter)
let total = 0;
for (let i = 0; i < allSteps.length; i++) {
  const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace';
  if (i > 0) console.log('');
  total += reportOne(label, allSteps[i]);
}
console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`);
process.exit(total === 0 ? 0 : 1);
