// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
// Stores docs so getDoc/getCollection return real state. undo() actually
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
function createMockStorage() {
  const docs = new Map();        // path -> data
  const calls = [];              // ordered {fn, path, data}

  const applyPatch = (path, patch) => {
    if (!docs.has(path)) docs.set(path, {});
    const cur = docs.get(path);
    // Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
    // the FULL new value (participants, turnOrderIds, conditions).
    docs.set(path, { ...cur, ...patch });
  };

  return {
    calls,
    docs,
    async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
    async setDoc(path, data) {
      calls.push({ fn: 'setDoc', path, data });
      docs.set(path, { ...data });
    },
    async addDoc(path, data) {
      calls.push({ fn: 'addDoc', path, data });
      // Persist log entry so getCollection + undo can read it back.
      const id = data.id || `log-${calls.length}`;
      const stored = { ...data, id, _path: `${path}/${id}` };
      docs.set(`${path}/${id}`, stored);
      return id;
    },
    async updateDoc(path, patch) {
      calls.push({ fn: 'updateDoc', path, data: patch });
      applyPatch(path, patch);
    },
    async deleteDoc(path) {
      calls.push({ fn: 'deleteDoc', path });
      docs.delete(path);
    },
    async getCollection(path, queryConstraints = []) {
      // Return docs whose _path starts with `${path}/` (Firestore collection).
      const out = [];
      for (const [p, d] of docs.entries()) {
        if (p.startsWith(`${path}/`)) out.push({ ...d });
      }
      // Best-effort query support: where/orderBy/limit.
      let filtered = out;
      for (const qc of queryConstraints) {
        if (!filtered.length) break;
        const op = qc[0];
        if (op === 'where') {
          const [, field, , value] = qc;
          filtered = filtered.filter(d => d[field] === value);
        } else if (op === 'orderBy') {
          const [, field, dir = 'asc'] = qc;
          filtered.sort((a, b) => {
            const av = a[field], bv = b[field];
            if (av < bv) return dir === 'asc' ? -1 : 1;
            if (av > bv) return dir === 'asc' ? 1 : -1;
            return 0;
          });
        } else if (op === 'limit') {
          filtered = filtered.slice(0, qc[1]);
        }
      }
      return filtered;
    },
    async batchWrite(ops) {
      for (const op of ops) {
        if (op.type === 'delete') await this.deleteDoc(op.path);
        else await this.updateDoc(op.path, op.data);
      }
    },
    // Transactional undo — mirrors firebase.js / server.js contract:
    //   redo=false: apply undo.updates to encounterPath, flip log undone=true
    //   redo=true:  apply undo.redo to encounterPath, flip log undone=false
    async undo({ logPath, undo, redo = false }) {
      calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
      const patch = redo ? (undo.redo || undo.updates) : undo.updates;
      applyPatch(undo.encounterPath, patch);
      if (docs.has(logPath)) {
        const log = docs.get(logPath);
        docs.set(logPath, { ...log, undone: !redo });
      }
    },
    // filters
    updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
    logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
    getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
  };
}

// Undo/redo harness: apply op via shared func (writes enc doc + log) then
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
// Reads doc back so assertions reflect actual persisted state.
//   applyThenUndo(ctx, encAfterOp) -> enc after undo applied
//   applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
async function undoLast(ctx, encAfterOp) {
  const { storage, encPath, logPath } = ctx;
  const last = storage.logs()[storage.logs().length - 1];
  const { expandUndo } = require('./..');
  const expanded = expandUndo(last, encAfterOp);
  if (!expanded) throw new Error('expandUndo returned null');
  const logEntryPath = `${logPath}/${last.id}`;
  await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
  return await storage.getDoc(encPath);
}
async function redoLast(ctx, encAfterUndo, last) {
  const { storage, encPath, logPath } = ctx;
  const { expandUndo } = require('./..');
  const expanded = expandUndo(last, encAfterUndo);
  if (!expanded) throw new Error('expandUndo returned null (redo)');
  const logEntryPath = `${logPath}/${last.id}`;
  await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
  return await storage.getDoc(encPath);
}

// Standard ctx for tests. encPath + logPath + displayPath preset.
// Returns { storage, ctx } so tests destructure both in one call:
//   const { storage, ctx } = mockCtx();
function mockCtx(storage) {
  storage = storage || createMockStorage();
  const ctx = {
    storage,
    encPath: 'encounters/e1',
    logPath: 'logs',
    displayPath: 'activeDisplay/status',
  };
  return { storage, ctx };
}

// Standard 2-participant ready encounter.
function readyEnc(opts = {}) {
  return {
    id: 'e1',
    name: opts.name || 'TestEnc',
    round: 0,
    isStarted: false,
    isPaused: false,
    currentTurnParticipantId: null,
    turnOrderIds: [],
    participants: opts.participants || [
      { id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
      { id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
    ],
  };
}

module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
