// scripts/combat/replay.js
// Drive fresh combat through LIVE backend. Write lean log to --out path.
// Same log shape as app download (JSON array of canonical events).
//
// DM-facing stdout: round headers + per-turn actions. No internals leak.

'use strict';

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const shared = require('../../shared');
const { normalizeEvent } = shared.logEvent;
const {
  buildCharacterParticipant, buildMonsterParticipant,
  startEncounter, nextTurn, togglePause, endEncounter,
  addParticipant, updateParticipant, removeParticipant,
  toggleParticipantActive, applyHpChange, deathSave,
  stabilizeParticipant, reviveParticipant,
  toggleCondition, reorderParticipants,
} = shared;
const { createServerStorage } = require('../../src/storage/server');

const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';

const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
const getPath = {
  campaigns: () => `${PUB}/campaigns`,
  campaign: (id) => `${PUB}/campaigns/${id}`,
  encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
  encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
  activeDisplay: () => `${PUB}/activeDisplay/status`,
  logs: () => `${PUB}/logs`,
};

const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

function buildRoster() {
  return [
    { id: 'char-fighter', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
    { id: 'char-cleric', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
    { id: 'char-rogue', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
  ];
}
function buildMonsters() {
  return [
    { name: 'Goblin1', maxHp: 30, initMod: 2 },
    { name: 'Goblin2', maxHp: 30, initMod: 2 },
    { name: 'OrcBoss', maxHp: 120, initMod: 1 },
    { name: 'Wolf', maxHp: 40, initMod: 3 },
    { name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
  ];
}

const CONDITIONS = [
  'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
  'frightened','grappled','incapacitated','invisible','paralyzed',
  'petrified','poisoned','prone','restrained','sapped','shield',
  'slowed','stunned','unconscious','vexed',
];
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
let condIdx = 0;

// Lean snapshot — verify needs rotation fields only.
function snapshot(enc) {
  if (!enc) return null;
  return {
    round: enc.round ?? 0,
    currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
    turnOrderIds: [...(enc.turnOrderIds || [])],
    activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
  };
}

function nameOf(enc, id) {
  if (!id || !enc) return '(none)';
  const p = (enc.participants || []).find(x => x.id === id);
  return p ? p.name : '(missing)';
}

// resolve participantId + name from call args + encounter state
let _encCache = null;
function resolveId(a) {
  if (!a) return null;
  const name = a.target || a.participant || a.dragged || a.name;
  if (!name || !_encCache) return null;
  const p = (_encCache.participants || []).find(x => x.name === name);
  return p ? p.id : null;
}
function resolveName(a) {
  if (!a) return null;
  return a.target || a.participant || a.dragged || a.name || null;
}

module.exports = async function replay(args) {
  // In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
  // Then any later console.log hits EPIPE and process dies before cleanup.
  // Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
  for (const s of [process.stdout, process.stderr]) {
    s.on('error', (err) => {
      if (err && err.code === 'EPIPE') return;
      throw err;
    });
  }

  const ROUNDS = parseInt(args.positional[0], 10);
  const DELAY = parseInt(args.positional[1], 10);
  const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
  const delay = Number.isNaN(DELAY) ? 200 : DELAY;
  // Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
  // and killed long replays around 6000 events. Keep generous hard stop.
  const MAX_STEPS = Math.max(rounds * 250, 10000);
  const VERBOSE = args.verbose;

  const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
  const campaignId = crypto.randomUUID();
  const encounterId = crypto.randomUUID();
  const encounterPath = getPath.encounter(campaignId, encounterId);
  const activeDisplayPath = getPath.activeDisplay();
  const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };

  // ensure parent dir exists
  const traceDir = path.dirname(args.out);
  if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });

  const events = []; // collected lean events, flushed to --out at end
  let stepN = 0;
  let prevEnc = null;
  let interrupted = false;
  let finishing = false;
  const onSigint = () => {
    interrupted = true;
    console.error('\ninterrupted — ending combat and verifying partial log...');
    finishAndExit().catch(err => {
      console.error('interrupt cleanup failed:', err);
      process.exit(1);
    });
  };
  process.once('SIGINT', onSigint);

  // callStep: trust func return (post), no per-step getDoc. Build lean event
  // shape = same as app download. Pre/post snapshot = ground truth.
  async function callStep(fn, argsObj, runner) {
    stepN++;
    const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
    const preSnap = snapshot(encBefore);
    let result, threw = null;
    try { result = await runner(encBefore); }
    catch (e) { threw = e.message; }
    const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
    prevEnc = encAfter;
    _encCache = encAfter;
    const postSnap = snapshot(encAfter);

    events.push({
      id: crypto.randomUUID(),
      ts: Date.now(),
      type: fn,
      message: describe(fn, argsObj, encAfter),
      encounterId,
      encounterName: `Replay ${runStamp}`,
      encounterPath,
      participantId: resolveId(argsObj),
      participantName: resolveName(argsObj),
      delta: null,
      undo: null,
      undone: false,
      snapshot: postSnap,
      _pre: preSnap,
      _call: { fn, args: argsObj },
      _error: threw,
    });
    return { enc: encAfter, result, threw };
  }

  async function finishAndExit() {
    if (finishing) return;
    finishing = true;
    process.removeListener('SIGINT', onSigint);

    try {
      // end encounter, even on Ctrl-C, so live display/app is not left mid-combat
      const latest = await storage.getDoc(encounterPath);
      prevEnc = latest;
      if (latest && latest.isStarted) {
        await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
      }
      await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
    } catch (err) {
      console.error('cleanup warning:', err.message || err);
    }

    const log = interrupted ? console.error : console.log;
    const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
    fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
    log(`log written: ${events.length} events -> ${args.out}`);
    log('');

    log('--- verifying ---');
    const text = fs.readFileSync(args.out, 'utf8');
    const verify = require('./verify.js');
    const exit = verify(text, { verbose: VERBOSE, log });
    process.exit(exit);
  }

  // human-readable message per action
  function describe(fn, a, enc) {
    switch (fn) {
      case 'setup_campaign': return 'Campaign created';
      case 'setup_encounter': return 'Encounter created';
      case 'addParticipant': return `${a.name} joined`;
      case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
      case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
      case 'applyHpChange':
        return a.changeType === 'heal'
          ? `${a.target} healed +${a.amount}`
          : `${a.target} took ${a.amount} damage`;
      case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
      case 'updateParticipant': return `${a.participant} edited`;
      case 'deathSave': return `${a.participant} death save (${a.outcome})`;
      case 'toggleParticipantActive':
        return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
      case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
      case 'removeParticipant': return `${a.participant} removed`;
      case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
      case 'endEncounter': return 'Combat ended';
      default: return fn;
    }
  }

  console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);

  // --- setup ---
  await callStep('setup_campaign', { campaignId }, async () =>
    storage.setDoc(getPath.campaign(campaignId), {
      id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
      players: buildRoster(),
    })
  );
  await callStep('setup_encounter', { encounterId }, async () =>
    storage.setDoc(encounterPath, {
      id: encounterId, name: `Replay ${runStamp}`, campaignId,
      participants: [], isStarted: false, isPaused: false,
      round: 0, currentTurnParticipantId: null, turnOrderIds: [],
      createdAt: Date.now(),
    })
  );
  for (const ch of buildRoster()) {
    const { participant } = buildCharacterParticipant(ch);
    await callStep('addParticipant', { name: participant.name }, (enc) =>
      addParticipant(enc, participant, ctx));
  }
  for (const m of buildMonsters()) {
    const { participant } = buildMonsterParticipant(m);
    await callStep('addParticipant', { name: participant.name }, (enc) =>
      addParticipant(enc, participant, ctx));
  }

  // --- start combat ---
  let enc = await storage.getDoc(encounterPath);
  const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
  enc = startRes.enc;
  await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
  console.log(`--- round ${enc.round} ---`);

  let totalTurns = 0;
  let lastRound = enc.round;

  // --- main loop: drive by real enc.round ---
  while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
    const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
    totalTurns++;
    if (VERBOSE && actor) console.log(`  [r${enc.round}] ${actor.name}'s turn`);

    if (actor) {
      const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
      if (living.length > 0) {
        const tgt = pick(living);
        const dmg = 1 + Math.floor(Math.random() * 5);
        if (VERBOSE) console.log(`    damage ${tgt.name} -${dmg}`);
        enc = (await callStep('applyHpChange',
          { target: tgt.name, changeType: 'damage', amount: dmg },
          (e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
      }

      if (totalTurns % 5 === 0) {
        const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
        if (VERBOSE) console.log(`    condition ${actor.name} +${cond}`);
        enc = (await callStep('toggleCondition',
          { participant: actor.name, condition: cond },
          (e) => toggleCondition(e, actor.id, cond, ctx))).enc;
      }

      if (totalTurns % 11 === 0) {
        if (VERBOSE) console.log(`    update ${actor.name} notes`);
        enc = (await callStep('updateParticipant',
          { participant: actor.name, fields: ['notes'] },
          (e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
      }

      if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
        const outcomes = ['success', 'fail', 'nat1', 'nat20'];
        const outcome = outcomes[totalTurns % outcomes.length];
        if (VERBOSE) console.log(`    deathSave ${actor.name} ${outcome}`);
        const dsRes = await callStep('deathSave',
          { participant: actor.name, outcome },
          async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
        enc = dsRes.enc;
      }

      if (totalTurns % 9 === 0) {
        const living = enc.participants.filter(p => p.currentHp > 0);
        if (living.length > 0) {
          const tgt = pick(living);
          if (VERBOSE) console.log(`    toggleActive ${tgt.name}`);
          enc = (await callStep('toggleParticipantActive',
            { participant: tgt.name },
            (e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
        }
      }

      if (totalTurns % 20 === 0 && enc.isPaused === false) {
        if (VERBOSE) console.log(`    reinforcements: pause`);
        enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
        const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
        if (VERBOSE) console.log(`    add ${r.participant.name}`);
        enc = (await callStep('addParticipant',
          { name: r.participant.name, reinforcement: true },
          (e) => addParticipant(e, r.participant, ctx))).enc;
        if (VERBOSE) console.log(`    resume`);
        enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
      }

      if (totalTurns % 13 === 0) {
        const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
        if (dead) {
          if (VERBOSE) console.log(`    remove ${dead.name}`);
          enc = (await callStep('removeParticipant',
            { participant: dead.name, dead: true },
            (e) => removeParticipant(e, dead.id, ctx))).enc;
        }
      }

      if (totalTurns % 17 === 0) {
        const order = enc.participants || [];
        if (order.length >= 2) {
          const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
          if (b) {
            if (VERBOSE) console.log(`    reorder ${b.name} -> ${a.name}`);
            enc = (await callStep('reorderParticipants',
              { dragged: b.name, target: a.name },
              (e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
          }
        }
      }
    }

    if (interrupted) break;
    if (delay > 0) await sleep(delay);
    if (interrupted) break;

    if (!enc.isStarted) { console.log('combat auto-ended'); break; }
    if (VERBOSE) console.log(`    -> nextTurn`);
    const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
    enc = advRes.enc;
    if (advRes.threw) { console.log(`  nextTurn err: ${advRes.threw}`); break; }

    // round wrap
    if (enc.isStarted && enc.round > lastRound) {
      if (enc.round > rounds) {
        // cap hit: the nextTurn that wrapped us here is a phantom — remove it
        // so verify doesn't show an incomplete round.
        events.pop();
        stepN--;
        break;
      }
      console.log(`--- round ${enc.round} ---`);
      lastRound = enc.round;
      const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
      for (const d of down) {
        if (interrupted) break;
        if (d.status === 'dead') {
          if (VERBOSE) console.log(`  revive dead ${d.name}`);
          enc = (await callStep('reviveParticipant',
            { participant: d.name, revive: true },
            (e) => reviveParticipant(e, d.id, ctx))).enc;
        }
        if (d.status === 'stable') {
          if (VERBOSE) console.log(`  heal stable ${d.name} +${d.maxHp}`);
          enc = (await callStep('applyHpChange',
            { target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
            (e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
        }
        const latest = (enc.participants || []).find(p => p.id === d.id);
        if (latest && latest.isActive === false) {
          if (VERBOSE) console.log(`  reactivate ${latest.name}`);
          enc = (await callStep('toggleParticipantActive',
            { participant: latest.name, revive: true },
            (e) => toggleParticipantActive(e, latest.id, ctx))).enc;
        }
      }
    }

    if (!enc.isStarted) { console.log('combat auto-ended'); break; }
  }

  console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);

  await finishAndExit();
};
