// Combat.scenario.test.js
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
// each phase wraps in try/catch, failures collected, final expect reports all.
//
// Purpose: exercise as much of the supported feature surface as possible in one
// long combat, surfacing behavioral bugs characterization tests miss.

import React from 'react';
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import {
  renderApp, createCampaignViaUI, selectCampaignByName,
  createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
} from './testHelpers';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';

// ---------- scenario helpers (UI only, same buttons as human) ----------

const RESULTS = [];
function record(phase, fn) {
  try { fn(); RESULTS.push({ phase, ok: true }); }
  catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
}
async function recordAsync(phase, fn) {
  try { await fn(); RESULTS.push({ phase, ok: true }); }
  catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
}

function getParticipantForm() {
  const heading = screen.getByText('Add Participants');
  let node = heading;
  for (let i = 0; i < 6; i++) {
    node = node.parentElement;
    if (!node) break;
    if (node.querySelector('form')) return within(node);
  }
  return within(heading.parentElement);
}

// Find a participant's encounter <li> row by name. Scoped to the encounter
// participant list (NOT the CharacterManager roster, which also shows names).
// Encounter participant rows render an 'Init:' label; roster rows do not.
function getParticipantRow(name) {
  const lis = document.querySelectorAll('li');
  for (const li of lis) {
    const txt = li.textContent || '';
    if (txt.includes('Init:') && txt.includes(name)) {
      return within(li);
    }
  }
  throw new Error(`encounter participant row not found: ${name}`);
}

// Character roster (CharacterManager). Assumes campaign selected.
async function addCharacterViaUI(name, maxHp, initMod) {
  fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
  fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
  fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
  fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
  await waitFor(() => {
    const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
      Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
    if (!call) throw new Error('char not persisted');
  });
}

function setParticipantType(type) {
  // The Type select is inside the Add Participants form.
  const form = getParticipantForm();
  const selects = form.getAllByRole('combobox');
  // first combobox in the participant form is Type
  fireEvent.change(selects[0], { target: { value: type } });
}

async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
  const form = getParticipantForm();
  setParticipantType('monster');
  fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
  fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
  fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
  if (isNpc) {
    const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
    if (!npcCheck.checked) fireEvent.click(npcCheck);
  }
  fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
  });
}

async function addCharacterParticipant(charName) {
  const form = getParticipantForm();
  setParticipantType('character');
  // character select is the 2nd combobox in the form after Type
  const charSelect = form.getAllByRole('combobox')[1];
  // find option whose text includes the char name
  const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName));
  if (!opt) throw new Error(`char option not found: ${charName}`);
  fireEvent.change(charSelect, { target: { value: opt.value } });
  fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
  });
}

async function addAllCharacters() {
  fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last) throw new Error('add all no-op');
  });
}

function applyDamage(name, amount) {
  const row = getParticipantRow(name);
  const dmgBtn = row.queryByTitle('Damage');
  if (!dmgBtn) {
    // participant dead (Damage button hidden when currentHp===0). Expected game
    // state over a long fight; not a bug. Skip silently.
    return;
  }
  fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
  fireEvent.click(dmgBtn);
}
function applyHeal(name, amount) {
  const row = getParticipantRow(name);
  const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
  if (!healBtn) throw new Error(`${name} has no Heal button`);
  fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
  fireEvent.click(healBtn);
}
function toggleActive(name) {
  const row = getParticipantRow(name);
  const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
  if (!btn) throw new Error(`${name} has no active toggle`);
  fireEvent.click(btn);
}
function openConditions(name) {
  const row = getParticipantRow(name);
  const btn = row.getByTitle('Conditions');
  // idempotent: ensure panel open. Click toggles; if another participant's panel
  // was open it's already closed by this participant's row focus, so just click.
  fireEvent.click(btn);
}
function toggleCondition(name, label) {
  openConditions(name);
  // panel render is async (React state). Wait for button by title.
  return waitFor(() => {
    const condButtons = document.querySelectorAll('button[title]');
    const target = [...condButtons].find(b => b.getAttribute('title') === label);
    if (!target) throw new Error(`condition button not found: ${label}`);
    fireEvent.click(target);
  });
}
function editParticipant(name, patch) {
  const row = getParticipantRow(name);
  fireEvent.click(row.getByTitle('Edit'));
  // EditParticipantModal. Scope to the modal via its form inputs.
  const modal = document.querySelector('.fixed.inset-0') || document.body;
  const inputs = modal.querySelectorAll('input');
  if (patch.name !== undefined) {
    fireEvent.change(inputs[0], { target: { value: patch.name } });
  }
  if (patch.initiative !== undefined && inputs[1]) {
    fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
  }
  const saveBtn = modal.querySelector('button[type="submit"]') ||
    [...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
  fireEvent.click(saveBtn);
}
function removeParticipant(name) {
  fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
}
async function deathSave(name, saveNum) {
  const row = getParticipantRow(name);
  const btn = row.getByTitle(`Death save ${saveNum}`);
  fireEvent.click(btn);
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last) throw new Error('deathSave no write');
  });
}
async function nextTurn() {
  fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last) throw new Error('nextTurn no write');
  });
}
async function pauseCombat() {
  fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last?.data?.isPaused) throw new Error('not paused');
  });
}
async function resumeCombat() {
  fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (last?.data?.isPaused) throw new Error('not resumed');
  });
}
async function startCombat() {
  fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
  await waitFor(() => {
    const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
    if (!last?.data?.isStarted) throw new Error('not started');
  });
}
function toggleHidePlayerHp() {
  fireEvent.click(screen.getByRole('switch'));
}
function currentEncDoc() {
  const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
  return calls[calls.length - 1]?.data;
}

// ---------- scenario ----------

const ROUNDS = 100;

test('full 100-round combat scenario', async () => {
  await setupReady('ScenarioCamp', 'BigBoss');

  // roster
  await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
  await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
  await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));

  // monsters + npcs
  await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
  await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
  await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
  await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
  await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));

  // add chars into encounter
  await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
  await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
  await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
  await recordAsync('addAllChars', () => addAllCharacters());

  // hidden hp toggle
  record('toggleHidePlayerHp', () => toggleHidePlayerHp());
  record('toggleHidePlayerHp back', () => toggleHidePlayerHp());

  await recordAsync('startCombat', () => startCombat());

  // 100 rounds of mixed actions
  for (let r = 1; r <= ROUNDS; r++) {
    await recordAsync(`round ${r} nextTurn`, () => nextTurn());

    // rotation integrity: turnOrderIds no dup, currentTurn valid
    if (r % 10 === 0) {
      record(`round ${r} rotation-check`, () => {
        const enc = currentEncDoc();
        if (!enc) throw new Error('no encounter doc');
        const order = enc.turnOrderIds || [];
        const uniq = new Set(order);
        if (uniq.size !== order.length) {
          throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
        }
        if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
          throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
        }
      });
    }

    // damage front monster every other round
    if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
    if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
    if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
    if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));

    // pause/resume every 10 rounds, add a participant, resume
    if (r % 10 === 0) {
      await recordAsync(`round ${r} pause`, () => pauseCombat());
      await recordAsync(`round ${r} addReinforcement`, () =>
        addMonsterParticipant(`Reinforce${r}`, 10, 1));
      await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
      await recordAsync(`round ${r} resume`, () => resumeCombat());
    }

    // edit initiative on Wolf every 13
    if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));

    // damage-to-0 + death save on Rogue around round 25 and 50
    if (r === 25) {
      record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
      record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
      record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
    }
    if (r === 50) {
      record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
      record(`round ${r} deathSave Cleric x3`, async () => {
        await deathSave('Cleric', 1);
        await deathSave('Cleric', 2);
        await deathSave('Cleric', 3);
      });
      record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
    }

    // remove a reinforcement late
    if (r === 30) {
      await recordAsync(`round ${r} pause`, () => pauseCombat());
      record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
      await recordAsync(`round ${r} resume`, () => resumeCombat());
    }
  }

  await recordAsync('endCombat', async () => {
    fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
    // End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
    const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
    const modal = endConfirm.closest('.fixed.inset-0') || document.body;
    const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
    fireEvent.click(confirmBtn);
    await waitFor(() => {
      const last = currentEncDoc();
      if (last?.isStarted !== false) throw new Error('not ended');
    });
  });

  // ---------- report ----------
  const failed = RESULTS.filter(r => !r.ok);
  if (failed.length > 0) {
    const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
    // eslint-disable-next-line no-console
    console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
  }
  // eslint-disable-next-line no-console
  console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
  expect(failed).toEqual([]);
}, 240000); // long timeout: 100 rounds
