// @ttrpg/shared — turn.js
// Pure turn-order logic. No I/O, no React, no Firebase.
// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally.
// Characterization tests lock current behavior. Fixes come in M4.
//
// Functions return NEW state (immutable). They never mutate input encounter.

'use strict';

// ----------------------------------------------------------------------------
// Constants (mirror src/App.js)
// ----------------------------------------------------------------------------

const DEFAULT_MAX_HP = 10;
const DEFAULT_INIT_MOD = 0;
const MONSTER_DEFAULT_INIT_MOD = 2;

// ----------------------------------------------------------------------------
// Utility functions (verbatim from src/App.js)
// ----------------------------------------------------------------------------

const generateId = () =>
  (typeof crypto !== 'undefined' && crypto.randomUUID)
    ? crypto.randomUUID()
    : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;

const rollD20 = () => Math.floor(Math.random() * 20) + 1;

const formatInitMod = (mod) => {
  if (mod === undefined || mod === null) return 'N/A';
  return mod >= 0 ? `+${mod}` : `${mod}`;
};

// Sort used ONLY at insert points (startEncounter, addParticipant) to position
// participants by initiative. Once positioned, turnOrderIds = participants.map(id)
// (1-list model). No re-sort after start — drag/edit are manual overrides.
const sortParticipantsByInitiative = (participants, originalOrder) => {
  return [...participants].sort((a, b) => {
    if (a.initiative === b.initiative) {
      const indexA = originalOrder.findIndex(p => p.id === a.id);
      const indexB = originalOrder.findIndex(p => p.id === b.id);
      return indexA - indexB;
    }
    return b.initiative - a.initiative;
  });
};

// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
// Call after any participants[] mutation. Returns turnOrderIds patch.
const syncTurnOrder = (participants) => ({
  turnOrderIds: participants.map(p => p.id),
});

// SHARED ADVANCE CORE (BUG-5 DRY fix).
// Single source of truth for "who acts next". Both nextTurn and
// computeTurnOrderAfterRemoval delegate here — prevents drift where one path
// changes and the other doesn't.
//
// order: turnOrderIds (raw, may contain inactive/removed ids).
// fromPos: index of the last-acted slot (current participant, or the removed
//   participant's old slot). Step +1 forward, skip fromPos itself.
// isActive: predicate id -> bool.
// Returns { nextId, wrapped }. wrapped = cycled past order end = new round.
const nextActiveAfter = (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 };
  }
  return { nextId: null, wrapped: false }; // no other active participant
};

// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates
// when a participant leaves active combat.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
  if (!encounter.isStarted) return {};
  // 1-list: turnOrderIds syncs from participants[].map(id) at call site.
  // Here only handle current-advance if removed == current.
  const updates = {};
  if (encounter.currentTurnParticipantId === removedId) {
    const removedPos = (encounter.turnOrderIds || []).indexOf(removedId);
    const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
    const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive);
    updates.currentTurnParticipantId = nextId;
    if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
  }
  return updates;
};

// Insert addedId into turnOrderIds by initiative. New participant slots into
// correct initiative position at add time (not appended to end). Preserves
// current pointer — no re-sort anywhere except startEncounter.
// Tie rule: insert AFTER existing same-init (preserves creation order).
// NOTE: 1-list model — caller syncs participants[] in same pos as insert target.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
  if (!encounter.isStarted) return {};
  const currentIds = encounter.turnOrderIds || [];
  if (currentIds.includes(addedId)) return {};
  const added = (encounter.participants || []).find(p => p.id === addedId);
  if (!added) return {};
  // find first id with strictly lower initiative; insert before it (== after all >= )
  const initOf = id => {
    const p = (encounter.participants || []).find(x => x.id === id);
    return p ? (p.initiative || 0) : 0;
  };
  const addedInit = added.initiative || 0;
  let insertAt = currentIds.length;
  for (let i = 0; i < currentIds.length; i++) {
    if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
  }
  return { insertAt }; // caller splices participants[] at this pos, then syncs
};

// ----------------------------------------------------------------------------
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
// ----------------------------------------------------------------------------

function makeParticipant(opts) {
  return {
    id: opts.id || generateId(),
    name: opts.name,
    type: opts.type, // 'character' | 'monster'
    originalCharacterId: opts.originalCharacterId || null,
    initiative: opts.initiative,
    maxHp: opts.maxHp,
    currentHp: opts.currentHp,
    isNpc: opts.isNpc || false,
    conditions: opts.conditions || [],
    isActive: opts.isActive !== undefined ? opts.isActive : true,
    deathSaves: opts.deathSaves || 0,
    isDying: opts.isDying || false,
  };
}

// Build a character participant from a campaign character (rolls initiative).
function buildCharacterParticipant(character) {
  const initiativeRoll = rollD20();
  const modifier = character.defaultInitMod || 0;
  const finalInitiative = initiativeRoll + modifier;
  const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
  return {
    participant: makeParticipant({
      name: character.name,
      type: 'character',
      originalCharacterId: character.id,
      initiative: finalInitiative,
      maxHp,
      currentHp: maxHp,
      isNpc: false,
    }),
    roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
  };
}

// Build a monster participant (rolls initiative).
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
  const initiativeRoll = rollD20();
  const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
  const finalInitiative = initiativeRoll + modifier;
  const hp = maxHp || DEFAULT_MAX_HP;
  return {
    participant: makeParticipant({
      name,
      type: 'monster',
      initiative: finalInitiative,
      maxHp: hp,
      currentHp: hp,
      isNpc: isNpc || false,
    }),
    roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
  };
}

// ----------------------------------------------------------------------------
// Action handlers — pure: (encounter, action) => { encounter, patch, log }
// Return patch = partial fields to merge into stored encounter.
// Caller persists patch + broadcasts.
// ----------------------------------------------------------------------------

// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter
function startEncounter(encounter) {
  if (!encounter.participants || encounter.participants.length === 0) {
    throw new Error('Add participants first.');
  }
  // 1-list model: sort ALL participants by init (active + inactive) so display
  // order = initiative. nextTurn skips inactive. turnOrderIds mirrors list.
  const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
  const firstActive = sortedParticipants.find(p => p.isActive);
  if (!firstActive) {
    throw new Error('No active participants.');
  }
  const orderedParticipants = sortedParticipants;
  return {
    patch: {
      isStarted: true,
      isPaused: false,
      round: 1,
      participants: orderedParticipants,
      currentTurnParticipantId: firstActive.id,
      turnOrderIds: orderedParticipants.map(p => p.id),
    },
    log: {
      message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
      undo: {
        isStarted: encounter.isStarted ?? false,
        isPaused: encounter.isPaused ?? false,
        round: encounter.round ?? 0,
        currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
        turnOrderIds: [...(encounter.turnOrderIds || [])],
      },
    },
  };
}

// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn
// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization.
function nextTurn(encounter) {
  if (!encounter.isStarted || encounter.isPaused) {
    throw new Error('Encounter not running.');
  }
  if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) {
    throw new Error('No active turn.');
  }

  const activePsInOrder = encounter.turnOrderIds
    .map(id => encounter.participants.find(p => p.id === id && p.isActive))
    .filter(Boolean);

  if (activePsInOrder.length === 0) {
    // End encounter — no active participants left.
    return {
      patch: {
        isStarted: false,
        isPaused: false,
        currentTurnParticipantId: null,
        round: encounter.round,
      },
      log: { message: `Combat auto-ended: no active participants`, undo: null },
    };
  }

  let nextRound = encounter.round;
  let newTurnOrderIds = encounter.turnOrderIds;

  // Delegate to shared advance core (BUG-5 DRY fix). Same math
  // computeTurnOrderAfterRemoval uses → no drift. fromPos = current's slot
  // in raw turnOrderIds; -1 path handles removed/stale current.
  const order = encounter.turnOrderIds || [];
  const fromPos = order.indexOf(encounter.currentTurnParticipantId);
  const isActive = id => {
    const p = encounter.participants.find(x => x.id === id);
    return !!p && p.isActive;
  };
  const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive);

  if (!nextId) {
    throw new Error('Could not determine next participant.');
  }
  if (wrapped) nextRound += 1;

  const nextParticipant = encounter.participants.find(p => p.id === nextId);

  return {
    patch: {
      currentTurnParticipantId: nextParticipant.id,
      round: nextRound,
      turnOrderIds: newTurnOrderIds,
    },
    log: {
      message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
      undo: {
        currentTurnParticipantId: encounter.currentTurnParticipantId,
        round: encounter.round,
        turnOrderIds: [...encounter.turnOrderIds],
      },
    },
  };
}

// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause
function togglePause(encounter) {
  if (!encounter || !encounter.isStarted) {
    throw new Error('Encounter not started.');
  }
  const newPausedState = !encounter.isPaused;
  let newTurnOrderIds = encounter.turnOrderIds;
  if (!newPausedState && encounter.isPaused) {
    // Resume: do NOT re-sort. Re-sorting displaces the current pointer —
    // participants who already acted move earlier in order and nextTurn
    // revisits them (whole round replays). Order is frozen at startEncounter
    // and patched incrementally; resume keeps it stable.
  }
  return {
    patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
    log: {
      message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
      undo: {
        isPaused: encounter.isPaused ?? false,
        turnOrderIds: [...(encounter.turnOrderIds || [])],
      },
    },
  };
}

// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
// If encounter already started, also slot participant into turnOrderIds by
// initiative (via computeTurnOrderAfterAddition).
function addParticipant(encounter, participant) {
  if ((encounter.participants || []).some(p => p.id === participant.id)) {
    throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
  }
  // 1-list: splice participant into participants[] by initiative position,
  // then sync turnOrderIds = participants.map(id).
  let updatedParticipants;
  let insertAt;
  if (!encounter.isStarted) {
    updatedParticipants = [...(encounter.participants || []), participant];
  } else {
    const { insertAt: at } = computeTurnOrderAfterAddition(
      { ...encounter, participants: [...(encounter.participants || []), participant] },
      participant.id);
    insertAt = at !== undefined ? at : (encounter.participants || []).length;
    updatedParticipants = [
      ...(encounter.participants || []).slice(0, insertAt),
      participant,
      ...(encounter.participants || []).slice(insertAt),
    ];
  }
  const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
  return {
    patch: { participants: updatedParticipants, ...turnUpdates },
    log: {
      message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
      undo: {
        participants: [...(encounter.participants || [])],
        ...(encounter.isStarted ? {
          turnOrderIds: [...(encounter.turnOrderIds || [])],
        } : {}),
      },
    },
  };
}

// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
function addParticipants(encounter, newParticipants) {
  const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
  return { patch: { participants: updatedParticipants }, log: null };
}

// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
function updateParticipant(encounter, participantId, updatedData) {
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...updatedData } : p
  );
  return { patch: { participants: updatedParticipants }, log: null };
}

// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
function removeParticipant(encounter, participantId) {
  const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId);
  const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
  const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {};
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  return {
    patch: { participants: updatedParticipants, ...turnUpdates },
    log: {
      message: `${participant ? participant.name : 'Participant'} removed from encounter`,
      undo: {
        participants: [...(encounter.participants || [])],
        ...(encounter.isStarted ? {
          currentTurnParticipantId: encounter.currentTurnParticipantId,
          turnOrderIds: [...(encounter.turnOrderIds || [])],
        } : {}),
      },
    },
  };
}

// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive
function toggleParticipantActive(encounter, participantId) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const newIsActive = !participant.isActive;
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, isActive: newIsActive } : p
  );
  // 1-list: participant stays in slot on toggle (active or not). nextTurn
  // skips inactive. Only advance current if deact hits current.
  let turnUpdates = {};
  if (encounter.isStarted) {
    turnUpdates = syncTurnOrder(updatedParticipants);
    if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
      const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
      turnUpdates = { ...turnUpdates, ...adv };
    }
  }
  return {
    patch: { participants: updatedParticipants, ...turnUpdates },
    log: {
      message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
      undo: {
        participants: [...(encounter.participants || [])],
        ...(encounter.isStarted ? {
          currentTurnParticipantId: encounter.currentTurnParticipantId,
          turnOrderIds: [...(encounter.turnOrderIds || [])],
        } : {}),
      },
    },
  };
}

// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange
// changeType: 'damage' | 'heal'
function applyHpChange(encounter, participantId, changeType, amount) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  if (isNaN(amount) || amount === 0) {
    return { patch: null, log: null }; // no-op
  }
  let newHp = participant.currentHp;
  if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount);
  else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount);

  const wasDead = participant.currentHp === 0;
  const isDead = newHp === 0;
  const wasResurrected = wasDead && newHp > 0;

  // FEAT-1: death no longer flips isActive or touches turnOrderIds.
  // Dead participants stay in turn order, nextTurn still visits them, PCs
  // get their death-save turn. isActive = DM-controlled combatant toggle only.
  const updatedParticipants = (encounter.participants || []).map(p => {
    if (p.id !== participantId) return p;
    const updates = { ...p, currentHp: newHp };
    if (isDead && !wasDead) {
      updates.deathSaves = p.deathSaves || 0;
      updates.isDying = false;
    }
    if (wasResurrected) {
      updates.deathSaves = 0;
      updates.isDying = false;
    }
    return updates;
  });

  // No turn-order updates on death/revive (FEAT-1).
  const turnUpdates = {};

  const hpLine = `${participant.currentHp} → ${newHp} HP`;
  const deathSuffix = (isDead && !wasDead)
    ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated')
    : '';
  const resurSuffix = wasResurrected ? ' — Revived' : '';
  const message = changeType === 'damage'
    ? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`
    : `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`;

  return {
    patch: { participants: updatedParticipants, ...turnUpdates },
    log: {
      message,
      undo: {
        participants: [...(encounter.participants || [])],
        ...((isDead && !wasDead) || wasResurrected ? {
          currentTurnParticipantId: encounter.currentTurnParticipantId,
          turnOrderIds: [...(encounter.turnOrderIds || [])],
        } : {}),
      },
    },
  };
}

// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation).
function deathSave(encounter, participantId, saveNumber) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const currentSaves = participant.deathSaves || 0;
  const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;

  if (newSaves === 3) {
    // Mark dying — caller waits for animation, then calls removeParticipant.
    const updatedParticipants = (encounter.participants || []).map(p =>
      p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
    );
    return {
      patch: { participants: updatedParticipants },
      log: null,
      isDying: true,
    };
  }

  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, deathSaves: newSaves } : p
  );
  return { patch: { participants: updatedParticipants }, log: null, isDying: false };
}

// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
function toggleCondition(encounter, participantId, conditionId) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const wasActive = (participant.conditions || []).includes(conditionId);
  const updatedParticipants = (encounter.participants || []).map(p => {
    if (p.id !== participantId) return p;
    const current = p.conditions || [];
    const next = wasActive ? current.filter(c => c !== conditionId) : [...current, conditionId];
    return { ...p, conditions: next };
  });
  return {
    patch: { participants: updatedParticipants },
    log: {
      message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
      undo: { participants: [...(encounter.participants || [])] },
    },
  };
}

// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
function reorderParticipants(encounter, draggedId, targetId) {
  const participants = [...(encounter.participants || [])];
  const draggedIndex = participants.findIndex(p => p.id === draggedId);
  const targetIndex = participants.findIndex(p => p.id === targetId);
  if (draggedIndex === -1 || targetIndex === -1) {
    throw new Error('Dragged or target item not found.');
  }
  const [removedItem] = participants.splice(draggedIndex, 1);
  // recompute targetIndex after removal (shift if dragged was before target)
  const newTargetIndex = participants.findIndex(p => p.id === targetId);
  participants.splice(newTargetIndex, 0, removedItem);
  const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
  return { patch: { participants, ...turnUpdates }, log: null };
}

// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
function endEncounter(encounter) {
  return {
    patch: {
      isStarted: false,
      isPaused: false,
      currentTurnParticipantId: null,
      round: 0,
      turnOrderIds: [],
    },
    log: {
      message: `Combat ended: "${encounter.name}"`,
      undo: {
        isStarted: encounter.isStarted ?? false,
        isPaused: encounter.isPaused ?? false,
        round: encounter.round ?? 0,
        currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
        turnOrderIds: [...(encounter.turnOrderIds || [])],
      },
    },
  };
}

module.exports = {
  DEFAULT_MAX_HP,
  DEFAULT_INIT_MOD,
  MONSTER_DEFAULT_INIT_MOD,
  generateId,
  rollD20,
  formatInitMod,
  sortParticipantsByInitiative,
  syncTurnOrder,
  computeTurnOrderAfterRemoval,
  computeTurnOrderAfterAddition,
  makeParticipant,
  buildCharacterParticipant,
  buildMonsterParticipant,
  startEncounter,
  nextTurn,
  togglePause,
  addParticipant,
  addParticipants,
  updateParticipant,
  removeParticipant,
  toggleParticipantActive,
  applyHpChange,
  deathSave,
  toggleCondition,
  reorderParticipants,
  endEncounter,
};
