// scripts/replay-combat.js
// Drive a full combat through the LIVE backend via the ws storage adapter
// (same contract boundary as the App), so the player display window
// (subscribed via WS) live-updates as combat progresses.
// Uses shared/turn.js for all turn logic (same model as the UI).
//
// Coverage goals (rotate across rounds):
//   - nextTurn (every turn)
//   - applyHpChange damage + heal (varying magnitude)
//   - toggleCondition (all CONDITIONS at least once)
//   - toggleParticipantActive (mark inactive, later reactivate)
//   - deathSave (when a PC reaches 0 HP)
//   - addParticipant (reinforcements drop in)
//   - removeParticipant (dead monsters hauled off)
//   - updateParticipant (edit fields mid-combat)
//   - togglePause / resume
//   - reorderParticipants (initiative reorder)
//   - endEncounter (cleanup)
//
// Run: node scripts/replay-combat.js [rounds] [delayMs]
//   rounds default 100, delayMs default 200

'use strict';

const shared = require('../shared');
const {
  buildCharacterParticipant, buildMonsterParticipant,
  startEncounter, nextTurn, togglePause,
  addParticipant, updateParticipant, removeParticipant,
  toggleParticipantActive, applyHpChange, deathSave,
  toggleCondition, reorderParticipants, endEncounter,
} = shared;
const { createWsStorage } = require('../src/storage/ws');

const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
const ROUNDS = parseInt(process.argv[2], 10) || 100;
const DELAY = parseInt(process.argv[3], 10) || 200;

const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
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`,
};

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

// Use the ADAPTER as the contract boundary (same as App). No raw REST.
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });

// Mirror App.js CONDITIONS so we exercise all of them.
const CONDITIONS = [
  'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened',
  'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated',
  'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
  'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
];

async function patch(encounterPath, enc, result, label) {
  if (!result || !result.patch) { if (label) console.log(`  (${label}: no-op)`); return enc; }
  await storage.updateDoc(encounterPath, result.patch);
  if (label) console.log(`  [${label}]`);
  // emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
  // nextTurn passes label=null — it's a normal advance, already logged via
  // the turn line. Emitting pointer for it double-counts.
  const oldCur = enc.currentTurnParticipantId;
  const oldRound = enc.round;
  const newEnc = { ...enc, ...result.patch };
  const newCur = newEnc.currentTurnParticipantId;
  const newRound = newEnc.round;
  if (label && oldCur && newCur && oldCur !== newCur) {
    const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
    const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
    const wrap = oldRound !== newRound ? ' wrap' : '';
    console.log(`  [pointer ${oldName}→${newName}${wrap}]`);
  }
  return newEnc;
}

function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

async function main() {
  console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);

  const campaignId = crypto.randomUUID();
  const encounterId = crypto.randomUUID();

  await storage.setDoc(getPath.campaign(campaignId), {
    name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
    playerDisplayBackgroundUrl: '',
    ownerId: 'replay',
    createdAt: new Date().toISOString(),
    players: [
      { id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
      { id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
      { id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
    ],
  });

  const charSpecs = [
    { id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
    { id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
    { id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
  ];
  const monsterSpecs = [
    { name: 'Goblin1', maxHp: 100, initMod: 2 },
    { name: 'Goblin2', maxHp: 100, initMod: 2 },
    { name: 'OrcBoss', maxHp: 500, initMod: 1 },
    { name: 'Wolf', maxHp: 120, initMod: 3 },
    { name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
  ];

  const participants = [
    ...charSpecs.map(c => buildCharacterParticipant(c).participant),
    ...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
  ];

  await storage.setDoc(getPath.encounter(campaignId, encounterId), {
    name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
    campaignId,
    createdAt: new Date().toISOString(),
    participants,
    round: 0,
    currentTurnParticipantId: null,
    isStarted: false,
    isPaused: false,
    turnOrderIds: [],
  });

  console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);

  await storage.setDoc(getPath.activeDisplay(), {
    activeCampaignId: campaignId,
    activeEncounterId: encounterId,
    hidePlayerHp: false,
  });
  await sleep(800);

  const encounterPath = getPath.encounter(campaignId, encounterId);
  const activeDisplayPath = getPath.activeDisplay();

  // start
  let enc = await storage.getDoc(encounterPath);
  enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
  console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
  await sleep(DELAY);

  let totalTurns = 0;
  const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5);
  let reinforcementsAdded = 0;
  let lastPaused = false;
  let lastReorder = 0;

  for (let roundN = 1; roundN <= ROUNDS; roundN++) {
    console.log(`--- round ${roundN} starting ---`);
    // advance initiative until round counter ticks (full cycle done).
    const cap = (enc.participants.length + 2) * 2;
    let guard = 0;
    while (enc.round < roundN + 1 && guard < cap) {
      // NOTE: do NOT getDoc here — async re-fetch can return stale state and
      // cause nextTurn to compute off pre-mutation data (double-acts/skips).
      // Trust the local enc returned by patch (sync spread of updateDoc).

      // 9. resume if paused: must happen BEFORE nextTurn or it throws.
      if (lastPaused) {
        enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
        lastPaused = false;
      }

      let t;
      try { t = nextTurn(enc); } catch (e) { console.log(`  nextTurn err: ${e.message}`); break; }
      enc = await patch(encounterPath, enc, t, null);
      totalTurns++;
      const actorName = firstActiveName(enc);
      const actor = currentParticipant(enc);

      // Dump turn line with order AND initiative (DM drag may reorder without
      // changing init — log both so parser can flag unexplained shifts).
      const ordStr = enc.turnOrderIds.map(id => {
        const p = enc.participants.find(x => x.id === id);
        return p ? `${p.name}:${p.initiative}` : id;
      }).join(',');
      // Also dump participants[] order (display source). Diverge from order = sync bug.
      const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
      console.log(`  turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);

      // 1. damage: actor hits a random living, active target.
      if (actor) {
        const foes = enc.participants.filter(
          p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
        );
        if (foes.length > 0) {
          const tgt = pick(foes);
          const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
          const h = applyHpChange(enc, tgt.id, 'damage', dmg);
          if (h.patch) {
            await storage.updateDoc(encounterPath, h.patch);
            enc = { ...enc, ...h.patch };
            console.log(`    ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
          }
        }
      }

      // 2. heal: Cleric (when active) heals lowest-HP ally every other turn.
      if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
        const wounded = enc.participants
          .filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
          .sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp));
        if (wounded.length > 0) {
          const tgt = wounded[0];
          const amt = 2 + Math.floor(Math.random() * 5); // 2-6
          const h = applyHpChange(enc, tgt.id, 'heal', amt);
          if (h.patch) {
            await storage.updateDoc(encounterPath, h.patch);
            enc = { ...enc, ...h.patch };
            console.log(`    Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
          }
        }
      }

      // 3. conditions: toggle a queued condition off some participant each turn.
      if (condQueue.length > 0) {
        const cond = condQueue[0];
        const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
        if (living.length > 0) {
          const tgt = pick(living);
          try {
            const c = toggleCondition(enc, tgt.id, cond);
            enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
            condQueue.shift();
          } catch (e) { console.log(`    condition ${cond} err: ${e.message}`); condQueue.shift(); }
        }
      } else if (totalTurns % 6 === 0) {
        // second pass: toggle a random condition on random participant (add/remove).
        const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
        if (living.length > 0) {
          const tgt = pick(living);
          const cond = pick(CONDITIONS);
          try {
            const c = toggleCondition(enc, tgt.id, cond);
            enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
          } catch (e) { /* ignore */ }
        }
      }

      // 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
      if (totalTurns % 9 === 0) {
        const living = enc.participants.filter(p => p.currentHp > 0);
        if (living.length > 0) {
          const tgt = pick(living);
          try {
            const r = toggleParticipantActive(enc, tgt.id);
            enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`);
          } catch (e) { /* ignore */ }
        }
      }

      // 5. deathSave: when a PC is at 0 HP on their turn, attempt a save.
      if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) {
        try {
          const ds = deathSave(enc, actor.id, 1);
          enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`);
        } catch (e) { /* ignore */ }
      }

      // 6. removeParticipant: dead monsters hauled off (every ~5 turns).
      if (totalTurns % 5 === 0) {
        const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
        if (dead) {
          try {
            const r = removeParticipant(enc, dead.id);
            enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
          } catch (e) { /* ignore */ }
        }
      }

      // 7. addParticipant (reinforcements): every 10 turns a new monster joins.
      if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
        const spec = pick([
          { name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 },
          { name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 },
        ]);
        try {
          const built = buildMonsterParticipant(spec).participant;
          const r = addParticipant(enc, built);
          enc = await patch(encounterPath, enc, r, `add ${spec.name}`);
          reinforcementsAdded++;
        } catch (e) { /* ignore */ }
      }

      // 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
      if (totalTurns % 7 === 0) {
        const living = enc.participants.filter(p => p.currentHp > 0);
        if (living.length > 0) {
          const tgt = pick(living);
          try {
            const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
            enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
          } catch (e) { /* ignore */ }
        }
      }

      // 9. togglePause: every 12 turns, pause (resumes next iteration via above).
      if (totalTurns % 12 === 0 && !lastPaused) {
        enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
        lastPaused = true;
      }

      // 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
      // Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
      // and swap them. Avoids crossing current pointer — crossing it creates
      // ambiguous "who acted this round" semantics (skip/double). Swapping two
      // upcoming actors is always safe and still exercises reorder.
      if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
        const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
        // upcoming = everyone after current in turn order (rest of this round)
        const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
          .filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
        // swap first adjacent upcoming pair (drag index1 before index0)
        if (upcomingIds.length >= 2) {
          const target = enc.participants.find(p => p.id === upcomingIds[0]);
          const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
          try {
            const r = reorderParticipants(enc, dragged.id, target.id);
            enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
            lastReorder = totalTurns;
          } catch (e) { /* swap not allowed — skip this round */ }
        }
      }

      await sleep(DELAY);
      guard++;
      if (!enc.isStarted) { console.log('combat auto-ended'); break; }
    }
    if (!enc.isStarted) { console.log('combat auto-ended'); break; }
    const alive = enc.participants.filter(p => p.currentHp > 0).length;
    // revive dead: heal to full + reactivate. Sustains combat for 100 rounds
    // and exercises toggleActive reactivate + heal-from-zero path.
    const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
    for (const d of dead) {
      try {
        if (d.isActive === false) {
          enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
        }
        const h = applyHpChange(enc, d.id, 'heal', d.maxHp);
        enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`);
      } catch (e) { console.log(`    revive ${d.name} err: ${e.message}`); }
    }
  }

  console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);

  // end
  enc = await storage.getDoc(encounterPath);
  if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
  await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
  console.log('replay done');
}

function firstActiveName(enc) {
  if (!enc.currentTurnParticipantId) return '(none)';
  const p = currentParticipant(enc);
  return p ? p.name : '(missing)';
}

function currentParticipant(enc) {
  if (!enc.currentTurnParticipantId) return null;
  return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
}

main().catch(err => { console.error('replay failed:', err); process.exit(1); });
