// @ttrpg/shared — turn.js
// Turn-order + combat actions. WRITES OWN LOGS (single source of truth).
// Every mutating func is async, takes ctx {storage, encPath, logPath,
// displayPath}, persists encounter patch + log entry itself. UI, replay,
// anything calling these funcs = identical writes. Impossible to forget log.
//
// Pure helpers (generateId, build*, sort, slot) stay sync — no I/O.
//
// return value: NEW encounter state (immutable). Callers use for local state
// sync; persistence already done inside.

// ----------------------------------------------------------------------------
// Constants
// ----------------------------------------------------------------------------

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

// ----------------------------------------------------------------------------
// Pure utilities (no I/O)
// ----------------------------------------------------------------------------

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;

// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
// Returns null on invalid input. Whitespace/case tolerant.
function rollHpFormula(str) {
  if (!str || typeof str !== 'string') return null;
  const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
  if (!m) return null;
  const count = parseInt(m[1], 10);
  const sides = parseInt(m[2], 10);
  if (count === 0 || sides === 0) return null;
  let bonus = 0;
  if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
  let total = 0;
  for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
  return total + bonus;
}

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

// SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
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;
  });
};

function slotIndexForInit(list, init) {
  for (let i = 0; i < list.length; i++) {
    if (init > list[i].initiative) return i;
  }
  return list.length;
}

function withDeathStatusConditions(participant, updates) {
  const status = updates.status !== undefined ? updates.status : participant.status;
  const current = participant.conditions || [];
  let conditions = current;
  if (status === 'dying' || status === 'stable') {
    conditions = current.includes('unconscious') ? current : [...current, 'unconscious'];
  } else if (status === 'conscious' || status === 'dead') {
    conditions = current.filter(c => c !== 'unconscious');
  }
  return { ...updates, conditions };
}

const syncTurnOrder = (participants) => ({
  turnOrderIds: participants.map(p => p.id),
});

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 };
};

const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
  if (!encounter.isStarted) return {};
  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;
};

// ----------------------------------------------------------------------------
// Snapshot helper (lean — analyzer + viewer use this only)
// ----------------------------------------------------------------------------

function snapshotOf(enc) {
  return {
    round: enc.round ?? 0,
    currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
    turnOrderIds: [...(enc.turnOrderIds || [])],
    activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
    startedAt: enc.startedAt ?? null,
    endedAt: enc.endedAt ?? null,
  };
}

// Build LEAN log entry. Stores mutation delta only — no roster arrays,
// no payload duplication, no redo wrapper. Undo = id-based inverse.
// log object now carries: { type, message, participantId, delta, undo }.
//   delta: type-specific scalars (amount, condition, init, changedFields...)
//   undo:  type-specific inverse (full participant obj for remove; id scalars
//          for others). Client expandUndo() turns this into a patch at click.
function buildEntry(enc, encPath, log) {
  if (!log) return null;
  const e = {
    ts: Date.now(),
    type: log.type,
    message: log.message || '',
    encounterId: enc.id || null,
    encounterName: enc.name || null,
    encounterPath: encPath,
    participantId: log.participantId || null,
    participantName: log.participantName || null,
    undone: false,
    snapshot: snapshotOf(enc),
  };
  if (log.delta) e.delta = log.delta;
  if (log.undo) e.undo = log.undo;
  return e;
}

// Execute: apply patch to encounter doc + write lean log. Returns newEnc.
async function commit(enc, patch, log, ctx) {
  const newEnc = { ...enc, ...(patch || {}) };
  const logPromise = (log && ctx.logPath)
    ? ctx.storage.addDoc(ctx.logPath, buildEntry(newEnc, ctx.encPath, log))
    : Promise.resolve();
  await Promise.all([
    ctx.storage.updateDoc(ctx.encPath, patch),
    logPromise,
  ]);
  return newEnc;
}

// expandUndo: turn lean `undo` (id-based) into full patch for server merge.
// Caller passes CURRENT encounter doc (from subscription) so roster ops can
// rebuild arrays. Returns { encounterPath, updates, redo } shaped for legacy
// transactionalUndo contract. redo = forward delta applied fresh.
function expandUndo(entry, currentEnc) {
  const u = entry.undo;
  if (!u || !currentEnc) return null;
  const encPath = entry.encounterPath;
  const parts = currentEnc.participants || [];
  const snap = entry.snapshot || {};
  // snapshot captured POST-action state; redo = re-apply forward from pre.
  // pre snapshot derivable from undo when scalar; roster ops need stored obj.
  switch (entry.type) {
    case 'add_participant':
    case 'add_participants': {
      const added = Array.isArray(u.participants) ? u.participants : [u.participant];
      const ids = new Set(added.filter(Boolean).map(p => p.id));
      const turnState = {};
      if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
      if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
      // redo: re-insert added into current parts, order by stored newTurnOrderIds.
      const redoOrder = u.newTurnOrderIds || null;
      let redoParts = null;
      if (redoOrder) {
        const pool = new Map([...parts, ...added].map(p => [p.id, p]));
        redoParts = redoOrder.map(id => pool.get(id)).filter(Boolean);
      }
      const redoTurnState = {};
      if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
      return {
        encounterPath: encPath,
        updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState },
        redo: redoParts
          ? { participants: redoParts, turnOrderIds: redoOrder, ...redoTurnState }
          : { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] },
      };
    }
    case 'remove_participant': {
      const p = u.participant;
      const turnState = {};
      if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
      if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
      let restoredParts = parts;
      if (p) {
        const idx = typeof u.slotIdx === 'number' ? Math.min(u.slotIdx, parts.length) : parts.length;
        restoredParts = [...parts.slice(0, idx), p, ...parts.slice(idx)];
      }
      // redo: re-remove + restore stored forward turn state.
      const redoTurnState = {};
      if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
      if (u.newTurnOrderIds) redoTurnState.turnOrderIds = u.newTurnOrderIds;
      return {
        encounterPath: encPath,
        updates: { participants: restoredParts, ...turnState },
        redo: u.newTurnOrderIds
          ? { participants: parts.filter(x => x.id !== (p && p.id)), ...redoTurnState }
          : { participants: parts.filter(x => x.id !== (p && p.id)) },
      };
    }
    case 'damage':
    case 'heal':
    case 'death_save':
    case 'stabilize':
    case 'revive':
    case 'mark_dead':
    case 'deactivate_dead_monster': {
      // Full death-state restore: currentHp, status, death-save counters,
      // isActive, conditions (unconscious added/removed by withDeathStatusConditions).
      const pid = entry.participantId;
      const cur = parts.find(p => p.id === pid);
      if (!cur) return null;
      const oldVals = u.oldValues || {};
      const newVals = u.newValues || {};
      const restoreFields = (vals) => {
        const out = {};
        if (vals.currentHp !== undefined) out.currentHp = vals.currentHp;
        if (vals.status !== undefined) out.status = vals.status;
        if (vals.deathSaveSuccesses !== undefined) out.deathSaveSuccesses = vals.deathSaveSuccesses;
        if (vals.deathSaveFailures !== undefined) out.deathSaveFailures = vals.deathSaveFailures;
        if (vals.isActive !== undefined) out.isActive = vals.isActive;
        if (vals.conditions !== undefined) out.conditions = vals.conditions;
        return out;
      };
      return {
        encounterPath: encPath,
        updates: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(oldVals) } : p) },
        redo: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(newVals) } : p) },
      };
    }
    case 'add_condition':
    case 'remove_condition': {
      const cond = u.condition;
      const pid = entry.participantId;
      const cur = parts.find(p => p.id === pid);
      if (!cur) return null;
      const has = (cur.conditions || []).includes(cond);
      const next = has ? (cur.conditions || []).filter(c => c !== cond) : [...(cur.conditions || []), cond];
      return {
        encounterPath: encPath,
        updates: { participants: parts.map(p => p.id === pid ? { ...p, conditions: next } : p) },
        redo: { participants: parts.map(p => p.id === pid ? { ...p, conditions: !has ? [...(p.conditions||[]), cond] : (p.conditions||[]).filter(c => c !== cond) } : p) },
      };
    }
    case 'reactivate':
    case 'deactivate': {
      const pid = entry.participantId;
      const wasActive = u.wasActive;
      const turnState = {};
      if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
      if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
      return {
        encounterPath: encPath,
        updates: { participants: parts.map(p => p.id === pid ? { ...p, isActive: wasActive } : p), ...turnState },
        redo: { participants: parts.map(p => p.id === pid ? { ...p, isActive: !wasActive } : p) },
      };
    }
    case 'reorder': {
      // undo: restore old order. redo: re-apply stored forward order.
      const redoParts = u.newParticipants || null;
      return {
        encounterPath: encPath,
        updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}), ...(u.currentTurnParticipantId !== undefined ? { currentTurnParticipantId: u.currentTurnParticipantId } : {}) },
        redo: redoParts
          ? { participants: redoParts, turnOrderIds: u.newTurnOrderIds || redoParts.map(p => p.id) }
          : { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
      };
    }
    case 'update_participant': {
      const pid = entry.participantId;
      const oldVals = u.oldValues || {};
      const newVals = u.newValues || {};
      // If initiative changed, restore array order too (undo by oldTurnOrderIds,
      // redo by newTurnOrderIds). Otherwise just patch fields in place.
      const applyFields = (vals) => parts.map(p => p.id === pid ? { ...p, ...vals } : p);
      const hasOrderChange = !!(u.oldTurnOrderIds && u.oldTurnOrderIds.length && u.newTurnOrderIds && u.newTurnOrderIds.length && JSON.stringify(u.oldTurnOrderIds) !== JSON.stringify(u.newTurnOrderIds));
      if (hasOrderChange && u.oldTurnOrderIds && u.newTurnOrderIds) {
        const undoApplied = applyFields(oldVals);
        const undoPool = new Map(undoApplied.map(p => [p.id, p]));
        const undoOrdered = u.oldTurnOrderIds.map(id => undoPool.get(id)).filter(Boolean);
        const redoApplied = applyFields(newVals);
        const redoPool = new Map(redoApplied.map(p => [p.id, p]));
        const redoOrdered = u.newTurnOrderIds.map(id => redoPool.get(id)).filter(Boolean);
        return {
          encounterPath: encPath,
          updates: { participants: undoOrdered, turnOrderIds: u.oldTurnOrderIds },
          redo: { participants: redoOrdered, turnOrderIds: u.newTurnOrderIds },
        };
      }
      return {
        encounterPath: encPath,
        updates: { participants: applyFields(oldVals) },
        redo: { participants: applyFields(newVals), ...(u.newTurnOrderIds ? { turnOrderIds: u.newTurnOrderIds } : {}) },
      };
    }
    case 'next_turn':
    case 'auto_end': {
      return {
        encounterPath: encPath,
        updates: { currentTurnParticipantId: u.currentTurnParticipantId, round: u.round, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) },
        redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
      };
    }
    case 'start_encounter': {
      return {
        encounterPath: encPath,
        updates: { ...u, startedAt: u.startedAt ?? null },
        redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
      };
    }
    case 'end_encounter': {
      return {
        encounterPath: encPath,
        updates: { ...u, endedAt: u.endedAt ?? null },
        redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
      };
    }
    default:
      return null;
  }
}

// ----------------------------------------------------------------------------
// Participant factories (pure — no write)
// ----------------------------------------------------------------------------

function makeParticipant(opts) {
  return {
    id: opts.id || generateId(),
    name: opts.name,
    type: opts.type,
    originalCharacterId: opts.originalCharacterId || null,
    initiative: opts.initiative,
    maxHp: opts.maxHp,
    currentHp: opts.currentHp,
    conditions: opts.conditions || [],
    isActive: opts.isActive !== undefined ? opts.isActive : true,
    status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
    deathSaveSuccesses: opts.deathSaveSuccesses || 0,
    deathSaveFailures: opts.deathSaveFailures || 0,
    hpFormula: opts.hpFormula || null,
    ac: opts.ac !== undefined ? opts.ac : null,
    tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
  };
}

function buildCharacterParticipant(character) {
  const initiativeRoll = rollD20();
  const modifier = character.defaultInitMod || 0;
  const finalInitiative = initiativeRoll + modifier;
  const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
  const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
  return {
    participant: makeParticipant({
      name: character.name,
      type: character.isNpc ? 'npc' : 'character',
      originalCharacterId: character.id,
      initiative: finalInitiative,
      maxHp,
      currentHp,
      ac: character.defaultAc !== undefined ? character.defaultAc : null,
    }),
    roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
  };
}

function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) {
  const initiativeRoll = rollD20();
  const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
  const finalInitiative = initiativeRoll + modifier;
  // maxHp wins if both set; else roll formula; else default.
  let hp;
  let hpRoll = null;
  if (maxHp) {
    hp = maxHp;
  } else if (hpFormula) {
    const rolled = rollHpFormula(hpFormula);
    hp = rolled || DEFAULT_MAX_HP;
    hpRoll = rolled;
  } else {
    hp = DEFAULT_MAX_HP;
  }
  return {
    participant: makeParticipant({
      name,
      type: asNpc ? 'npc' : 'monster',
      initiative: finalInitiative,
      maxHp: hp,
      currentHp: hp,
      hpFormula: hpFormula || null,
      ac: ac !== undefined ? ac : null,
    }),
    roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
  };
}

// ----------------------------------------------------------------------------
// Combat actions — async. Write encounter + log inside. Return newEnc.
// ctx = { storage, encPath, logPath, displayPath }
// ----------------------------------------------------------------------------

async function startEncounter(encounter, ctx) {
  if (!encounter.participants || encounter.participants.length === 0) {
    throw new Error('Add participants first.');
  }
  const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
  const firstActive = sortedParticipants.find(p => p.isActive);
  if (!firstActive) throw new Error('No active participants.');
  const patch = {
    isStarted: true, isPaused: false, round: 1,
    participants: sortedParticipants,
    currentTurnParticipantId: firstActive.id,
    turnOrderIds: sortedParticipants.map(p => p.id),
    startedAt: Date.now(),
    endedAt: null,
  };
  const log = {
    type: 'start_encounter',
    participantId: firstActive.id, participantName: firstActive.name,
    message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
    delta: { round: 1, firstTurnId: firstActive.id },
    undo: {
      isStarted: encounter.isStarted ?? false,
      isPaused: encounter.isPaused ?? false,
      round: encounter.round ?? 0,
      currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
      turnOrderIds: [...(encounter.turnOrderIds || [])],
      startedAt: encounter.startedAt ?? null,
      endedAt: encounter.endedAt ?? null,
    },
  };
  return commit(encounter, patch, log, ctx);
}

async function nextTurn(encounter, ctx) {
  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) {
    const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round };
    const log = { type: 'auto_end', message: `Combat auto-ended: no active participants`, undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, isStarted: true, isPaused: false } };
    return commit(encounter, patch, log, ctx);
  }
  let nextRound = encounter.round;
  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);
  const patch = { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: encounter.turnOrderIds };
  const log = {
    type: 'next_turn',
    participantId: nextParticipant.id, participantName: nextParticipant.name,
    message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
    delta: { wrapped, prevRound: encounter.round },
    undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, turnOrderIds: [...encounter.turnOrderIds] },
  };
  return commit(encounter, patch, log, ctx);
}

async function togglePause(encounter, ctx) {
  if (!encounter || !encounter.isStarted) throw new Error('Encounter not started.');
  const newPausedState = !encounter.isPaused;
  const patch = { isPaused: newPausedState, turnOrderIds: encounter.turnOrderIds };
  // Lifecycle op: no log entry. Pause/resume excluded from undo stack so DM
  // undo targets the last player action (damage/condition/etc), not a flag flip.
  return commit(encounter, patch, null, ctx);
}

async function addParticipant(encounter, participant, ctx) {
  if ((encounter.participants || []).some(p => p.id === participant.id)) {
    throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
  }
  const base = [...(encounter.participants || [])];
  const idx = slotIndexForInit(base, participant.initiative);
  base.splice(idx, 0, participant);
  const patch = { participants: base, ...syncTurnOrder(base) };
  const log = {
    type: 'add_participant',
    participantId: participant.id, participantName: participant.name,
    message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
    delta: { init: participant.initiative, maxHp: participant.maxHp, ptype: participant.type },
    undo: {
      participant,
      currentTurnParticipantId: encounter.currentTurnParticipantId,
      turnOrderIds: [...(encounter.turnOrderIds || [])],
      newTurnOrderIds: patch.turnOrderIds,
      newCurrentTurnParticipantId: patch.currentTurnParticipantId,
    },
  };
  return commit(encounter, patch, log, ctx);
}

async function addParticipants(encounter, newParticipants, ctx) {
  // Slot each new participant into list by initiative (desc), preserve
  // existing order + drag-established ties. Matches addParticipant semantics.
  let base = [...(encounter.participants || [])];
  for (const np of newParticipants) {
    const idx = slotIndexForInit(base, np.initiative);
    base.splice(idx, 0, np);
  }
  const names = newParticipants.map(p => p.name).join(', ');
  const patch = { participants: base, ...syncTurnOrder(base) };
  const log = {
    type: 'add_participants',
    message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
    delta: { count: newParticipants.length },
    undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
  };
  return commit(encounter, patch, log, ctx);
}

async function updateParticipant(encounter, participantId, updatedData, ctx) {
  const existing = encounter.participants || [];
  const target = existing.find(p => p.id === participantId);
  if (!target) throw new Error(`Participant "${participantId}" not found.`);
  const merged = { ...target, ...updatedData };
  let patch;
  const oldValues = {};
  for (const k of Object.keys(updatedData)) oldValues[k] = target[k];
  if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
    const sameSlot = existing.map(p => p.id === participantId ? merged : p);
    const fields = Object.keys(updatedData).join(', ');
    patch = { participants: sameSlot, ...syncTurnOrder(sameSlot) };
    const log = {
      type: 'update_participant',
      participantId, participantName: target.name,
      message: `${target.name} updated (${fields})`,
      delta: { changedFields: Object.keys(updatedData) },
      undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
    };
    return commit(encounter, patch, log, ctx);
  } else {
    const without = existing.filter(p => p.id !== participantId);
    const idx = slotIndexForInit(without, merged.initiative);
    without.splice(idx, 0, merged);
    patch = { participants: without, ...syncTurnOrder(without) };
    const log = {
      type: 'update_participant',
      participantId, participantName: target.name,
      message: `${target.name} updated (initiative: ${merged.initiative})`,
      delta: { changedFields: Object.keys(updatedData), initiative: merged.initiative },
      undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
    };
    return commit(encounter, patch, log, ctx);
  }
}

async function removeParticipant(encounter, participantId, ctx) {
  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);
  const slotIdx = (encounter.participants || []).findIndex(p => p.id === participantId);
  const patch = { participants: updatedParticipants, ...turnUpdates };
  const log = {
    type: 'remove_participant',
    participantId, participantName: participant ? participant.name : null,
    message: `${participant ? participant.name : 'Participant'} removed from encounter`,
    delta: { dead: true },
    undo: {
      participant, slotIdx,
      currentTurnParticipantId: encounter.currentTurnParticipantId,
      turnOrderIds: [...(encounter.turnOrderIds || [])],
      newCurrentTurnParticipantId: patch.currentTurnParticipantId,
      newTurnOrderIds: patch.turnOrderIds,
    },
  };
  return commit(encounter, patch, log, ctx);
}

async function toggleParticipantActive(encounter, participantId, ctx) {
  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
  );
  // Toggle active changes only active state + mirrored order. It must NOT
  // advance turn or increment round; DM can deactivate current, then click Next.
  // Design: "Toggle active: No position change. Skip in rotation only."
  const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
  const patch = { participants: updatedParticipants, ...turnUpdates };
  const log = {
    type: newIsActive ? 'reactivate' : 'deactivate',
    participantId, participantName: participant.name,
    message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
    delta: { revived: newIsActive },
    undo: { wasActive: participant.isActive, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] },
  };
  return commit(encounter, patch, log, ctx);
}

async function setTempHp(encounter, participantId, tempHp, ctx) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const value = Math.max(0, tempHp || 0);
  const oldValues = { tempHp: participant.tempHp || 0 };
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, tempHp: value } : p
  );
  const log = {
    type: 'set_temp_hp',
    participantId,
    participantName: participant.name,
    message: `${participant.name} temp HP set to ${value}`,
    delta: { tempHp: value, oldTempHp: oldValues.tempHp },
    undo: { oldValues, newValues: { tempHp: value } },
  };
  return commit(encounter, { participants: updatedParticipants }, log, ctx);
}

async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
  const ctx = maybeCtx || optionsOrCtx;
  const options = maybeCtx ? (optionsOrCtx || {}) : {};
  const ruleset = encounter.ruleset || '5e';
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  if (isNaN(amount) || amount === 0) return encounter;
  if (amount < 0) {
    amount = Math.abs(amount);
    changeType = changeType === 'damage' ? 'heal' : 'damage';
  }

  const oldValues = {
    currentHp: participant.currentHp,
    tempHp: participant.tempHp || 0,
    status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
    deathSaveSuccesses: participant.deathSaveSuccesses || 0,
    deathSaveFailures: participant.deathSaveFailures || 0,
    isActive: participant.isActive,
    conditions: [...(participant.conditions || [])],
  };

  let updates = {};
  let message = '';
  let logDelta = { amount, from: participant.currentHp };
  const status = oldValues.status;
  const currentTemp = participant.tempHp || 0;

  // GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
  // Monster death = dead + inactive. No unconscious auto-condition.
  if (ruleset === 'generic') {
    return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
  }

  if (changeType === 'damage') {
    // Temp HP absorbs damage first.
    let remainingDmg = amount;
    let tempAfter = currentTemp;
    if (tempAfter > 0) {
      const absorbed = Math.min(tempAfter, remainingDmg);
      tempAfter -= absorbed;
      remainingDmg -= absorbed;
      updates.tempHp = tempAfter;
    }
    // If fully absorbed by temp, no regular HP change.
    if (remainingDmg === 0 && participant.currentHp > 0) {
      const updatedParticipants0 = (encounter.participants || []).map(p =>
        p.id === participantId ? { ...p, tempHp: tempAfter } : p
      );
      const log0 = {
        type: 'hp_change',
        participantId,
        participantName: participant.name,
        message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
        delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
        undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
      };
      return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
    }
    amount = remainingDmg;
    const tempBefore = currentTemp;
    const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
    if (participant.currentHp === 0) {
      if (status === 'dead') {
        if (participant.type === 'monster' && participant.isActive !== false) {
          const updatedParticipants = (encounter.participants || []).map(p =>
            p.id === participantId ? { ...p, isActive: false } : p
          );
          const patch = { participants: updatedParticipants };
          const newP = updatedParticipants.find(p => p.id === participantId);
          const newValues = {
            currentHp: newP.currentHp,
            status: newP.status,
            deathSaveSuccesses: newP.deathSaveSuccesses || 0,
            deathSaveFailures: newP.deathSaveFailures || 0,
            isActive: newP.isActive,
            conditions: [...(newP.conditions || [])],
          };
          const log = {
            type: 'deactivate_dead_monster',
            participantId,
            participantName: participant.name,
            message: `${participant.name} marked inactive (dead monster)`,
            delta: { status: 'dead', isActive: false },
            undo: { oldValues, newValues },
          };
          return commit(encounter, patch, log, ctx);
        }
        return encounter;
      }
      const add = options.isCriticalHit ? 2 : 1;
      const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
      if (failures >= 3) {
        updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
      } else {
        updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
      }
      message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
      logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
    } else if (amount >= participant.currentHp + participant.maxHp) {
      updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
      message = `Massive damage! ${participant.name} instantly killed`;
      logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
    } else {
      const newHp = Math.max(0, participant.currentHp - amount);
      if (newHp === 0) {
        const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
        updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
      } else {
        updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
      }
      message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
      logDelta = { ...logDelta, to: newHp, status: updates.status };
    }
  } else if (changeType === 'heal') {
    if (status === 'dead') return encounter;
    const newHp = Math.min(participant.maxHp, Math.max(1, participant.currentHp + amount));
    updates = { currentHp: newHp, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 };
    message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
    logDelta = { ...logDelta, to: newHp, status: 'conscious', deathSavesCleared: true };
  } else {
    throw new Error(`Unknown HP change type: ${changeType}`);
  }

  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
  );
  const patch = { participants: updatedParticipants };
  const undoAmount = changeType === 'damage' ? amount : -amount;
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp,
    status: newP.status,
    deathSaveSuccesses: newP.deathSaveSuccesses || 0,
    deathSaveFailures: newP.deathSaveFailures || 0,
    isActive: newP.isActive,
    conditions: [...(newP.conditions || [])],
  };
  const log = {
    type: changeType,
    participantId, participantName: participant.name,
    message,
    delta: logDelta,
    undo: { amount: undoAmount, oldValues, newValues },
  };
  return commit(encounter, patch, log, ctx);
}

// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
  const participantId = participant.id;
  let updates, message, logDelta = { amount, from: participant.currentHp };
  const currentTemp = participant.tempHp || 0;

  if (changeType === 'damage') {
    // Temp HP absorbs damage first.
    let remainingDmg = amount;
    let tempAfter = currentTemp;
    if (tempAfter > 0) {
      const absorbed = Math.min(tempAfter, remainingDmg);
      tempAfter -= absorbed;
      remainingDmg -= absorbed;
    }
    if (remainingDmg === 0) {
      const updatedParticipants0 = (encounter.participants || []).map(p =>
        p.id === participantId ? { ...p, tempHp: tempAfter } : p
      );
      const log0 = {
        type: 'hp_change', participantId, participantName: participant.name,
        message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
        delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
        undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
      };
      return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
    }
    amount = remainingDmg;
    const tempFinal = tempAfter;
    if (oldValues.status === 'dead') {
      if (participant.type === 'monster' && participant.isActive !== false) {
        const updatedParticipants = (encounter.participants || []).map(p =>
          p.id === participantId ? { ...p, isActive: false } : p
        );
        const newP = updatedParticipants.find(p => p.id === participantId);
        const newValues = {
          currentHp: newP.currentHp, status: newP.status,
          deathSaveSuccesses: 0, deathSaveFailures: 0,
          isActive: newP.isActive, conditions: [...(newP.conditions || [])],
        };
        return commit(encounter, { participants: updatedParticipants }, {
          type: 'deactivate_dead_monster', participantId, participantName: participant.name,
          message: `${participant.name} marked inactive (dead monster)`,
          delta: { status: 'dead', isActive: false },
          undo: { oldValues, newValues },
        }, ctx);
      }
      return encounter;
    }
    const newHp = participant.currentHp - amount;
    const isMonster = participant.type === 'monster';
    if (newHp <= 0 && isMonster) {
      updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
    } else if (newHp <= 0) {
      updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
    } else {
      updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
    }
    message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
    logDelta = { ...logDelta, to: newHp, status: updates.status };
  } else if (changeType === 'heal') {
    if (oldValues.status === 'dead') return encounter;
    const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
    updates = { currentHp: newHp, status: 'conscious' };
    message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
    logDelta = { ...logDelta, to: newHp, status: 'conscious' };
  } else {
    throw new Error(`Unknown HP change type: ${changeType}`);
  }

  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...updates } : p
  );
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp, status: newP.status,
    deathSaveSuccesses: newP.deathSaveSuccesses || 0,
    deathSaveFailures: newP.deathSaveFailures || 0,
    isActive: newP.isActive, conditions: [...(newP.conditions || [])],
  };
  const undoAmount = changeType === 'damage' ? amount : -amount;
  return commit(encounter, { participants: updatedParticipants }, {
    type: changeType, participantId, participantName: participant.name,
    message, delta: logDelta,
    undo: { amount: undoAmount, oldValues, newValues },
  }, ctx);
}

async function deathSave(encounter, participantId, outcome, ctx) {
  if ((encounter.ruleset || '5e') === 'generic') {
    throw new Error('Death saves not available in generic ruleset.');
  }
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
  if (statusBefore !== 'dying') throw new Error('Death saves only apply while dying.');
  if (!['success', 'fail', 'nat1', 'nat20'].includes(outcome)) {
    throw new Error(`deathSave outcome must be success, fail, nat1, or nat20; got "${outcome}".`);
  }

  const oldValues = {
    currentHp: participant.currentHp,
    status: statusBefore,
    deathSaveSuccesses: participant.deathSaveSuccesses || 0,
    deathSaveFailures: participant.deathSaveFailures || 0,
    isActive: participant.isActive,
    conditions: [...(participant.conditions || [])],
  };

  let updates;
  if (outcome === 'nat20') {
    updates = { currentHp: 1, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 };
  } else {
    const successes = (participant.deathSaveSuccesses || 0) + (outcome === 'success' ? 1 : 0);
    const failures = (participant.deathSaveFailures || 0) + (outcome === 'fail' ? 1 : outcome === 'nat1' ? 2 : 0);
    if (failures >= 3) updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0 };
    else if (successes >= 3) updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 };
    else updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: successes, deathSaveFailures: failures };
  }

  const name = participant.name;
  const message = outcome === 'nat20' ? `Nat 20! ${name} restored to 1 HP, conscious`
    : outcome === 'nat1' ? `Nat 1! ${name} takes 2 death save failures`
    : updates.status === 'stable' ? `${name} stabilized (3 death saves)`
    : updates.status === 'dead' ? `${name} failed 3 death saves — dead`
    : `${name} death save ${outcome}`;
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
  );
  const patch = { participants: updatedParticipants };
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp,
    status: newP.status,
    deathSaveSuccesses: newP.deathSaveSuccesses || 0,
    deathSaveFailures: newP.deathSaveFailures || 0,
    isActive: newP.isActive,
    conditions: [...(newP.conditions || [])],
  };
  const log = {
    type: 'death_save',
    participantId, participantName: name,
    message,
    delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures },
    undo: { oldValues, newValues },
  };
  const enc = await commit(encounter, patch, log, ctx);
  return { enc, status: updates.status };
}

async function toggleCondition(encounter, participantId, conditionId, ctx) {
  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 };
  });
  const patch = { participants: updatedParticipants };
  const log = {
    type: wasActive ? 'remove_condition' : 'add_condition',
    participantId, participantName: participant.name,
    message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
    delta: { condition: conditionId, added: !wasActive },
    undo: { condition: conditionId },
  };
  return commit(encounter, patch, log, ctx);
}

async function stabilizeParticipant(encounter, participantId, ctx) {
  if ((encounter.ruleset || '5e') === 'generic') {
    throw new Error('Stabilize not available in generic ruleset.');
  }
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
  if (status === 'conscious' || participant.currentHp > 0) throw new Error('Cannot stabilize conscious participant.');
  if (status === 'dead') throw new Error('Cannot stabilize dead participant.');
  if (status === 'stable') return encounter;

  const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 };
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
  );

  const patch = { participants: updatedParticipants };
  const oldValues = {
    currentHp: participant.currentHp,
    status,
    deathSaveSuccesses: participant.deathSaveSuccesses || 0,
    deathSaveFailures: participant.deathSaveFailures || 0,
    isActive: participant.isActive,
    conditions: [...(participant.conditions || [])],
  };
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp,
    status: newP.status,
    deathSaveSuccesses: newP.deathSaveSuccesses || 0,
    deathSaveFailures: newP.deathSaveFailures || 0,
    isActive: newP.isActive,
    conditions: [...(newP.conditions || [])],
  };
  const log = {
    type: 'stabilize',
    participantId,
    participantName: participant.name,
    message: `${participant.name} stabilized at 0 HP`,
    delta: { status: 'stable' },
    undo: { oldValues, newValues },
  };
  return commit(encounter, patch, log, ctx);
}

async function reviveParticipant(encounter, participantId, ctx) {
  const ruleset = encounter.ruleset || '5e';
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
  if (status !== 'dead') return encounter;

  // Generic: dead→conscious (no stable state). HP unchanged (may be negative).
  // 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
  const updates = ruleset === 'generic'
    ? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
    : { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
  );

  const patch = { participants: updatedParticipants };
  const oldValues = {
    currentHp: participant.currentHp,
    status,
    deathSaveSuccesses: participant.deathSaveSuccesses || 0,
    deathSaveFailures: participant.deathSaveFailures || 0,
    isActive: participant.isActive,
    conditions: [...(participant.conditions || [])],
  };
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp,
    status: newP.status,
    deathSaveSuccesses: newP.deathSaveSuccesses || 0,
    deathSaveFailures: newP.deathSaveFailures || 0,
    isActive: newP.isActive,
    conditions: [...(newP.conditions || [])],
  };
  const log = {
    type: 'revive',
    participantId,
    participantName: participant.name,
    message: `${participant.name} revived to 0 HP (stable, unconscious)`,
    delta: { status: 'stable', currentHp: 0 },
    undo: { oldValues, newValues },
  };
  return commit(encounter, patch, log, ctx);
}

// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
async function markDead(encounter, participantId, ctx) {
  const participant = (encounter.participants || []).find(p => p.id === participantId);
  if (!participant) throw new Error('Participant not found.');
  if (participant.status === 'dead') return encounter;
  const oldValues = {
    currentHp: participant.currentHp,
    status: participant.status || 'conscious',
    isActive: participant.isActive,
    conditions: [...(participant.conditions || [])],
  };
  const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
  const updatedParticipants = (encounter.participants || []).map(p =>
    p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
  );
  const newP = updatedParticipants.find(p => p.id === participantId);
  const newValues = {
    currentHp: newP.currentHp, status: newP.status,
    isActive: newP.isActive, conditions: [...(newP.conditions || [])],
  };
  return commit(encounter, { participants: updatedParticipants }, {
    type: 'mark_dead', participantId, participantName: participant.name,
    message: `${participant.name} marked dead`,
    delta: { status: 'dead' },
    undo: { oldValues, newValues },
  }, ctx);
}

async function reorderParticipants(encounter, draggedId, targetId, ctx) {
  const participants = [...(encounter.participants || [])];
  const dragged = participants.find(p => p.id === draggedId);
  const target = participants.find(p => p.id === targetId);
  if (!dragged || !target) throw new Error('Dragged or target item not found.');
  if (draggedId === targetId) return encounter; // no-op, no write
  if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked
  const draggedIndex = participants.findIndex(p => p.id === draggedId);
  if (encounter.isStarted && !encounter.isPaused && encounter.currentTurnParticipantId) {
    const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId);
    const targetIdx0 = participants.findIndex(p => p.id === targetId);
    if (pointerIdx >= 0) {
      const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx);
      if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked
    }
  }
  const targetIndex = participants.findIndex(p => p.id === targetId);
  const [removedItem] = participants.splice(draggedIndex, 1);
  const newTargetIndex = participants.findIndex(p => p.id === targetId);
  const insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex;
  participants.splice(insertIndex, 0, removedItem);
  const patch = { participants, ...syncTurnOrder(participants) };
  const log = {
    type: 'reorder',
    participantId: draggedId, participantName: removedItem.name,
    message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
    delta: { draggedId, targetId },
    undo: {
      participants: encounter.participants || [],
      turnOrderIds: encounter.turnOrderIds || [],
      currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
      newParticipants: participants,
      newTurnOrderIds: patch.turnOrderIds,
    },
  };
  return commit(encounter, patch, log, ctx);
}

async function endEncounter(encounter, ctx) {
  const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
  const log = {
    type: 'end_encounter',
    message: `Combat ended: "${encounter.name}"`,
    delta: {},
    undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
  };

  // Character writeback: sync maxHp/ac/currentHp to campaign players if enabled.
  // ctx.campaignPath: adapter-specific doc path (firebase needs the full
  // artifacts/{APP_ID}/public/data prefix); bare campaigns/{id} fallback for
  // server/test adapters. Writeback failure must not block ending combat.
  const wbCampaignId = encounter.campaignId || ctx.campaignId;
  if ((wbCampaignId || ctx.campaign) && ctx.storage) {
    const campaignPath = ctx.campaignPath || `campaigns/${wbCampaignId}`;
    let campaign;
    try {
      campaign = ctx.campaign || await ctx.storage.getDoc(campaignPath);
    } catch { campaign = null; }
    if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) {
      const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId);
      const oldValues = [];
      let changed = false;
      const updatedPlayers = campaign.players.map(player => {
        const cp = charParticipants.find(p => p.originalCharacterId === player.id);
        if (!cp) return player;
        const oldMaxHp = player.defaultMaxHp;
        const oldAc = player.defaultAc;
        const oldCurrentHp = player.defaultCurrentHp;
        oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp });
        const newPlayer = { ...player };
        if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; }
        if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; }
        if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; }
        return newPlayer;
      });
      if (changed) {
        try {
          await ctx.storage.updateDoc(campaignPath, { players: updatedPlayers });
          log.undo.characterWriteback = oldValues;
        } catch (err) {
          console.error(`endEncounter: character writeback failed (${campaignPath}):`, err);
        }
      }
    }
  }

  return commit(encounter, patch, log, ctx);
}

// ----------------------------------------------------------------------------
// Display lifecycle — write activeDisplay doc, no combat log.
// ctx.displayPath required.
// ----------------------------------------------------------------------------

async function activateDisplay({ campaignId, encounterId }, ctx) {
  await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
}

async function clearDisplay(ctx) {
  await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: null, activeEncounterId: null });
}

async function toggleHidePlayerHp(currentValue, ctx) {
  await ctx.storage.updateDoc(ctx.displayPath, { hidePlayerHp: !currentValue });
}

module.exports = {
  DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
  generateId, rollD20, rollHpFormula, formatInitMod,
  sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
  snapshotOf, buildEntry, expandUndo,
  makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
  startEncounter, nextTurn, togglePause,
  addParticipant, addParticipants, updateParticipant, removeParticipant,
  toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
  reorderParticipants, endEncounter,
  activateDisplay, clearDisplay, toggleHidePlayerHp,
};
