import type { ChatMessage } from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { clearMetaEventRegistry, getMetaEvents } from '../debug-event-log';
import type { DiagnosticEntry } from './diagnostics';
import { clearDiagnosticsCursorRegistry } from './diagnostics-cursors';
import { clearPersisterRegistry } from './persister-registry';
import type { SessionPreferences } from './session-persistence-provider';
import { SessionPersister, type SessionPersisterDeps } from './session-persister';
import { createPersistedSession, type PersistedSession } from './session-snapshot';

// GENC-1351 §5.10: the shared per-session persister owns restore-core save/restore/
// freeze. Unit-tested in isolation with fake driver/store/provider — the win the
// extraction buys (this logic was previously only exercised by manual testing).

const HOST = window.location.host;
const KEY = 'session-1';
const msg = (o: Partial<ChatMessage>): ChatMessage =>
  ({ role: 'user', content: '', ...o }) as ChatMessage;
const tick = (): Promise<void> =>
  new Promise((r) => {
    setTimeout(r, 0);
  });

interface Fakes {
  saved: PersistedSession[];
  cleared: number;
  loadResult: PersistedSession | null;
  loadedHistory: ChatMessage[][];
  primed: unknown[];
  loadSessionCalls: unknown[];
  inputValues: string[];
  history: ChatMessage[];
  activeAgent: Record<string, unknown> | undefined;
  enabled: boolean;
  restoring: boolean;
  /** Diagnostic entries the element would build (Phase 3). */
  diagnosticEntries: DiagnosticEntry[];
  /** Batches passed to provider.appendDiagnostics. */
  appended: DiagnosticEntry[][];
  /** Blobs passed to provider.savePreferences. */
  savedPrefs: SessionPreferences[];
  /** What provider.loadPreferences returns. */
  loadPrefsResult: SessionPreferences | undefined;
  /** Set false to omit `appendDiagnostics`/`savePreferences` from the provider. */
  diagnosticsCapable: boolean;
  /** When set, `provider.save` rejects with it (GENC-1511 save-failure telemetry). */
  saveError: Error | undefined;
  /** Details passed to `onPersistenceBroken` (GENC-1511 user-facing warning). */
  brokenReports: { unsavedMessages: number; lastSavedAt?: string; reason: string }[];
  /** How many times `onPersistenceRecovered` fired. */
  recovered: number;
}

function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fakes } {
  const f: Fakes = {
    saved: [],
    cleared: 0,
    loadResult: null,
    loadedHistory: [],
    primed: [],
    loadSessionCalls: [],
    inputValues: [],
    history: [],
    activeAgent: undefined,
    enabled: true,
    restoring: false,
    diagnosticEntries: [],
    appended: [],
    savedPrefs: [],
    loadPrefsResult: undefined,
    diagnosticsCapable: true,
    saveError: undefined,
    brokenReports: [],
    recovered: 0,
    ...over,
  };
  const provider = {
    saveDebounceMs: 0,
    diagnosticsDebounceMs: 0,
    save: async (_k: string, snap: PersistedSession) => {
      if (f.saveError) throw f.saveError;
      f.saved.push(snap);
    },
    load: async () => f.loadResult,
    clear: async () => {
      f.cleared += 1;
    },
    loadPreferences: async () => f.loadPrefsResult,
    ...(f.diagnosticsCapable
      ? {
          appendDiagnostics: async (_k: string, entries: DiagnosticEntry[]) => {
            f.appended.push(entries);
          },
          savePreferences: async (_k: string, prefs: SessionPreferences) => {
            f.savedPrefs.push(prefs);
          },
        }
      : {}),
  };
  const driver = {
    getRawHistory: () => f.history,
    getActiveAgent: () => f.activeAgent,
    loadHistory: (h: ChatMessage[]) => f.loadedHistory.push(h),
    primeRestoredAgentState: (s: unknown) => f.primed.push(s),
    setPinnedAgent: () => {},
  };
  const store = {
    store: {
      aiAssistant: {
        messages: f.history,
        flowOwnerAgentName: null,
        flowActivationPrompt: null,
        activeAgent: undefined,
        pinnedAgentName: null,
        // Inline rather than via `emptyUsage()` to keep this mock self-contained. The
        // shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
        // the object crashes the autosave timer rather than failing an assertion.
        sessionUsage: {
          costUsd: 0,
          uncachedInputTokens: 0,
          cacheReadTokens: 0,
          cacheWriteTokens: 0,
          outputTokens: 0,
        },
        get restoring() {
          return f.restoring;
        },
      },
    },
    actions: {
      aiAssistant: {
        loadSession: (p: unknown) => f.loadSessionCalls.push(p),
        setInputValue: (v: string) => f.inputValues.push(v),
      },
    },
  };
  const deps: SessionPersisterDeps = {
    stateKey: KEY,
    getPersistence: () => ({ enabled: f.enabled, provider: provider as never }),
    getAgentNames: () => [],
    getDriver: () => driver as never,
    getStore: () => store as never,
    getDiagnosticEntries: () => f.diagnosticEntries,
    onPersistenceBroken: (d) => f.brokenReports.push(d),
    onPersistenceRecovered: () => {
      f.recovered += 1;
    },
  };
  return { p: new SessionPersister(deps), f };
}

const Suite = createLogicSuite('SessionPersister');

Suite.before.each(() => {
  clearPersisterRegistry();
  clearDiagnosticsCursorRegistry();
  clearMetaEventRegistry();
});

Suite('restore() hydrates the store + driver and appends a "Restored" divider', async () => {
  const snapshot = createPersistedSession({
    sessionKey: KEY,
    host: HOST,
    messages: [msg({ role: 'user', content: 'hi' }), msg({ role: 'assistant', content: 'hello' })],
    pinnedAgentName: null,
    flowOwnerAgentName: null,
    sessionCostUsd: 0,
  });
  const { p, f } = makePersister({ loadResult: snapshot });
  await p.restore();
  assert.is(f.loadSessionCalls.length, 1, 'store hydrated once');
  assert.is(f.loadedHistory.length, 1, 'driver history loaded once');
  const loaded = f.loadedHistory[0];
  assert.is(loaded.length, 3, 'two restored messages + a trailer');
  assert.is(loaded[2].role, 'system-event');
  assert.is(f.inputValues.length, 0, 'no composer prefill for a normal restore');
});

Suite(
  'restore() of an interrupted snapshot re-appends the triggering message + notice + pre-fills',
  async () => {
    const snapshot: PersistedSession = {
      ...createPersistedSession({
        sessionKey: KEY,
        host: HOST,
        // The frozen snapshot is the prior *resumable* conversation — it does NOT
        // contain the interrupted flow's triggering message; that is re-appended from
        // the `interrupted.prompt` so the exchange reads as a conversation.
        messages: [
          msg({ role: 'user', content: 'hi' }),
          msg({ role: 'assistant', content: 'hello' }),
        ],
        pinnedAgentName: null,
        flowOwnerAgentName: null,
        sessionCostUsd: 0,
      }),
      interrupted: { message: 'I did not finish — send it again', prompt: 'do the thing' },
    };
    const { p, f } = makePersister({ loadResult: snapshot });
    await p.restore();
    const loaded = f.loadedHistory[0];
    // …prior conversation, user('do the thing'), assistant(notice)
    assert.is(
      loaded[loaded.length - 2].role,
      'user',
      'triggering message re-appended before the notice',
    );
    assert.is(loaded[loaded.length - 2].content, 'do the thing');
    assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
    assert.is(loaded[loaded.length - 1].content, 'I did not finish — send it again');
    assert.equal(f.inputValues, ['do the thing'], 'composer pre-filled with the flow prompt');
  },
);

Suite(
  'restore() of an interrupted snapshot without a captured prompt appends only the notice',
  async () => {
    const snapshot: PersistedSession = {
      ...createPersistedSession({
        sessionKey: KEY,
        host: HOST,
        messages: [
          msg({ role: 'user', content: 'hi' }),
          msg({ role: 'assistant', content: 'hello' }),
        ],
        pinnedAgentName: null,
        flowOwnerAgentName: null,
        sessionCostUsd: 0,
      }),
      interrupted: { message: 'I did not finish — send it again' },
    };
    const { p, f } = makePersister({ loadResult: snapshot });
    await p.restore();
    const loaded = f.loadedHistory[0];
    assert.is(loaded.length, 3, 'prior 2 messages + notice only — no synthesized user message');
    assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
    assert.equal(f.inputValues, [], 'no composer pre-fill without a captured prompt');
  },
);

Suite(
  'restore() of a fresh-freeze interrupted snapshot does NOT duplicate the trigger message',
  async () => {
    // The fresh-freeze fallback (no prior resumable snapshot) keeps the transcript
    // THROUGH the last user message (`keepThroughLastUserMessage`), so the triggering
    // prompt is already the final message. restore() must NOT re-append it from
    // `interrupted.prompt` — doing so duplicated the bubble on a fresh session that
    // went non-resumable on its first turn (GENC-1351 §5.9).
    const snapshot: PersistedSession = {
      ...createPersistedSession({
        sessionKey: KEY,
        host: HOST,
        messages: [msg({ role: 'user', content: 'cancel trade T123' })],
        pinnedAgentName: null,
        flowOwnerAgentName: null,
        sessionCostUsd: 0,
      }),
      interrupted: { message: 'I did not finish — send it again', prompt: 'cancel trade T123' },
    };
    const { p, f } = makePersister({ loadResult: snapshot });
    await p.restore();
    const loaded = f.loadedHistory[0];
    assert.is(loaded.filter((m) => m.role === 'user').length, 1, 'trigger appears once');
    assert.is(loaded.length, 2, 'trigger + notice only — no duplicate user bubble');
    assert.is(loaded[0].content, 'cancel trade T123');
    assert.is(loaded[1].role, 'assistant', 'notice is an assistant bubble');
    assert.equal(f.inputValues, ['cancel trade T123'], 'composer still pre-filled with the prompt');
  },
);

Suite('restore() with nothing stored is a silent no-op', async () => {
  const { p, f } = makePersister({ loadResult: null });
  await p.restore();
  assert.is(f.loadSessionCalls.length, 0);
  assert.is(f.loadedHistory.length, 0);
});

Suite('scheduleSave() persists the current transcript (single debounced save)', async () => {
  const history = [msg({ role: 'user', content: 'save me' })];
  const { p, f } = makePersister({ history });
  p.scheduleSave();
  p.scheduleSave(); // second view — must coalesce into ONE save
  await tick();
  await tick();
  assert.is(f.saved.length, 1, 'coalesced to a single save');
  assert.is(f.saved[0].messages.length, 1);
  assert.is(f.saved[0].messages[0].content, 'save me');
  assert.is(f.saved[0].interrupted, undefined, 'a normal save carries no interrupted marker');
});

Suite('scheduleSave() is a no-op when persistence is disabled', async () => {
  const { p, f } = makePersister({ enabled: false, history: [msg({ content: 'x' })] });
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.saved.length, 0);
});

Suite(
  'freeze: a non-resumable agent keeps the conversation and drops only in-flight work',
  async () => {
    // Prior persisted (resumable) snapshot with real conversation.
    const prior = createPersistedSession({
      sessionKey: KEY,
      host: HOST,
      messages: [
        msg({ role: 'user', content: 'earlier' }),
        msg({ role: 'assistant', content: 'ok' }),
      ],
      pinnedAgentName: 'Trade Ops',
      flowOwnerAgentName: null,
      sessionCostUsd: 0,
    });
    const { p, f } = makePersister({
      loadResult: prior,
      // in-flight transcript has extra agent work we must NOT persist
      history: [
        msg({ role: 'user', content: 'earlier' }),
        msg({ role: 'assistant', content: 'ok' }),
        msg({ role: 'assistant', content: 'in-flight work' }),
      ],
      activeAgent: {
        name: 'Trade Ops',
        resumable: async () => false,
        notResumableMessage: 'start again',
        serialize: () => ({}),
      },
    });
    p.scheduleSave();
    await tick();
    await tick();
    assert.is(f.saved.length, 1, 'one freeze-stamp write');
    const written = f.saved[0];
    assert.ok(written.interrupted, 'stamped with an interrupted marker');
    assert.is(written.interrupted?.message, 'start again');
    // GENC-1511: the boundary is the LIVE transcript through the last user message, floored
    // at whatever was already persisted — never the stored blob, which is what silently
    // rolled a session back three hours. Here the last user message is index 0, so the floor
    // (nothing persisted by this persister yet) leaves 1; the in-flight assistant turn is
    // dropped either way, which is the property that matters.
    assert.is(written.messages.length, 1, 'in-flight agent work is not persisted');
    assert.is(written.messages[0].content, 'earlier', 'the conversation itself is kept');
    assert.is(written.flowOwnerAgentName, null, 'flow ownership dropped');
    assert.equal(written.agentSnapshots, {}, 'flow state dropped');
  },
);

Suite('freeze: the boundary never regresses below what was already persisted', async () => {
  // A resumable save lands the full transcript; the agent then goes non-resumable WITHOUT
  // a new user prompt (auto-advance, a resumed journey step). `keepThroughLastUserMessage`
  // alone would rewind to message 1 and drop the completed exchange — the floor stops it.
  const history = [
    msg({ role: 'user', content: 'earlier' }),
    msg({ role: 'assistant', content: 'ok' }),
  ];
  const agent = { name: 'Trade Ops', resumable: async () => true, serialize: () => ({}) };
  const { p, f } = makePersister({ history, activeAgent: agent });
  p.scheduleSave();
  await tick();
  assert.is(f.saved.length, 1, 'the resumable save landed');
  assert.is(f.saved[0].messages.length, 2, 'full transcript persisted');

  agent.resumable = async () => false;
  history.push(msg({ role: 'assistant', content: 'in-flight work' }));
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.saved.length, 2, 'the freeze wrote');
  assert.is(f.saved[1].messages.length, 2, 'floored at the already-persisted length');
  assert.ok(f.saved[1].interrupted, 'and stamped interrupted');
});

Suite('freeze: an unmoved boundary skips the write, and never latches', async () => {
  const history = [
    msg({ role: 'user', content: 'do it' }),
    msg({ role: 'assistant', content: 'working' }),
  ];
  const agent = {
    name: 'Trade Ops',
    resumable: async () => false,
    serialize: () => ({}),
  };
  const { p, f } = makePersister({ history, activeAgent: agent });
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.saved.length, 1, 'first freeze writes the boundary');

  // More in-flight turns, same last user message → nothing new to freeze, no write.
  history.push(msg({ role: 'assistant', content: 'still working' }));
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.saved.length, 1, 'unmoved boundary does not re-write');

  // The old `interrupted` latch made that state permanent. A new prompt must still land.
  history.push(msg({ role: 'user', content: 'next thing' }));
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.saved.length, 2, 'a moved boundary writes again — the skip is not a latch');
  assert.is(f.saved[1].messages.length, 4, 'frozen through the new user message');
});

Suite('clearRestoreCore() drops the snapshot', async () => {
  const { p, f } = makePersister();
  await p.clearRestoreCore();
  assert.is(f.cleared, 1);
});

// ── Phase 2: preferences ────────────────────────────────────────────────────

Suite(
  'loadPreferences() populates storedPreferences from the provider blob (null when none)',
  async () => {
    const prefs: SessionPreferences = { enabled: true, showToolCalls: true };
    const { p } = makePersister({ loadPrefsResult: prefs });
    await p.loadPreferences();
    assert.equal(p.storedPreferences, prefs);

    const { p: p2 } = makePersister(); // provider present, nothing stored
    await p2.loadPreferences();
    assert.is(p2.storedPreferences, null);
  },
);

Suite(
  'savePreferences() persists, records the (shared) user change, and refreshes storedPreferences',
  () => {
    const blob: SessionPreferences = { enabled: false, showNarration: true };
    const { p, f } = makePersister();
    assert.is(p.preferencesUserChanged, false);
    p.savePreferences(blob);
    assert.equal(f.savedPrefs, [blob]);
    assert.is(p.preferencesUserChanged, true, 'the shared latch is set so the reconciler freezes');
    assert.equal(p.storedPreferences, blob, 'stored blob kept fresh for a later re-reconcile');

    // No savePreferences on the provider → no write, but the user change is still
    // recorded (freezing the baseline regardless of a persistence-capable backend).
    const { p: p2, f: f2 } = makePersister({ diagnosticsCapable: false });
    p2.savePreferences(blob);
    assert.is(f2.savedPrefs.length, 0);
    assert.is(p2.preferencesUserChanged, true);
  },
);

// ── Phase 3: diagnostics-append ───────────────────────────────────────────────

const evt = (index: number): DiagnosticEntry => ({
  kind: 'event',
  type: 'turn.start',
  index,
  timestamp: `2026-01-01T00:00:0${index}.000Z`,
});

const metaSnap = (dedupSignature: string, meta: unknown): DiagnosticEntry => ({
  kind: 'meta-snapshot',
  timestamp: '2026-01-01T00:00:00.000Z',
  meta,
  dedupSignature,
});

// A diagnostic MESSAGE entry as the reasoning/narration/answer split (GENC-1411) produces them —
// several messages from ONE response, sharing timestamp + role, differing by `category` / content.
const diagMsg = (over: Partial<DiagnosticEntry> = {}): DiagnosticEntry => ({
  kind: 'message',
  role: 'assistant',
  timestamp: '2026-01-01T00:00:05.000Z',
  content: '',
  ...over,
});

Suite('scheduleDiagnostics() appends the collected delta once per window', async () => {
  const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
  p.scheduleDiagnostics();
  p.scheduleDiagnostics(); // second view → coalesced
  await tick();
  await tick();
  assert.is(f.appended.length, 1, 'one append despite two schedules');
  assert.is(f.appended[0].length, 2);
});

Suite('scheduleDiagnostics() holds off while the session is restoring', async () => {
  const { p, f } = makePersister({ diagnosticEntries: [evt(0)], restoring: true });
  p.scheduleDiagnostics();
  await tick();
  await tick();
  assert.is(f.appended.length, 0, 'no capture mid-restore (watermark not yet set)');
});

Suite('flushDiagnostics() appends immediately and de-dups via the shared cursors', async () => {
  const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
  await p.flushDiagnostics();
  assert.is(f.appended.length, 1, 'first flush appends the two events');
  assert.is(f.appended[0].length, 2);
  // Nothing new since — the event cursor has advanced, so the delta is empty.
  await p.flushDiagnostics();
  assert.is(f.appended.length, 1, 'no re-append of already-emitted events');
});

Suite('flushDiagnostics() is a no-op without a diagnostics-capable provider', async () => {
  const { p, f } = makePersister({ diagnosticsCapable: false, diagnosticEntries: [evt(0)] });
  await p.flushDiagnostics();
  assert.is(f.appended.length, 0);
});

Suite(
  'flushDiagnostics() dedups meta-snapshots by dedupSignature, not the volatile meta',
  async () => {
    // Regression for #12: the guard compared JSON.stringify(entry.meta), which embeds a
    // fresh timestamp + per-turn context, so it never matched → the bulky block was
    // re-appended on every flush. It must dedup on the stable config signature instead.
    const { p, f } = makePersister({
      diagnosticEntries: [metaSnap('cfg-1', { timestamp: 't1', context: { tokens: 1 } })],
    });
    await p.flushDiagnostics();
    assert.is(f.appended.length, 1, 'first meta-snapshot appended');

    // Same config signature, but the volatile meta changed (new timestamp + tokens) → skip.
    f.diagnosticEntries = [metaSnap('cfg-1', { timestamp: 't2', context: { tokens: 999 } })];
    await p.flushDiagnostics();
    assert.is(f.appended.length, 1, 'unchanged config → no re-append despite volatile churn');

    // Config signature changed → append again.
    f.diagnosticEntries = [metaSnap('cfg-2', { timestamp: 't3', context: { tokens: 999 } })];
    await p.flushDiagnostics();
    assert.is(f.appended.length, 2, 'changed config → re-appended');
  },
);

Suite(
  'flushDiagnostics() keeps co-timestamped split messages (reasoning + answer), not deduped as one',
  async () => {
    // GENC-1461: the reasoning/narration/answer split emits several messages from ONE response, so
    // they share timestamp + role + subAgentOf. The forward-capture keyed only on those, so it
    // emitted the reasoning message and dropped the answer as a "duplicate" — a server generation's
    // answer message vanished from the persisted download while its reasoning survived. Both must be
    // captured.
    const reasoning = diagMsg({ category: 'reasoning', content: 'thinking…' });
    const answer = diagMsg({ content: 'the generated code' }); // no category = the answer/tool-call slot
    const { p, f } = makePersister({ diagnosticEntries: [reasoning, answer] });
    await p.flushDiagnostics();
    assert.is(f.appended.length, 1, 'one flush');
    assert.is(
      f.appended[0].length,
      2,
      'both the reasoning and the answer message are captured, not collapsed to one',
    );
  },
);

Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
  // A turn entry is created BEFORE its model call and priced when the response returns,
  // so a flush landing in between persists it unpriced. Emitting it exactly once more —
  // when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
  // `assembleDebugLog` collapses the pair back onto the priced copy.
  const inFlight: DiagnosticEntry = {
    kind: 'turn',
    turnIndex: '0',
    timestamp: '2026-01-01T00:00:01.000Z',
    toolNames: [],
  };
  const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
  await p.flushDiagnostics();
  assert.is(f.appended.length, 1, 'the unpriced turn is captured');
  assert.is(f.appended[0][0].usage, undefined);

  const priced: DiagnosticEntry = {
    ...inFlight,
    usage: {
      costUsd: 0.25,
      uncachedInputTokens: 100,
      cacheReadTokens: 900,
      cacheWriteTokens: 0,
      outputTokens: 40,
    },
  };
  f.diagnosticEntries = [priced];
  await p.flushDiagnostics();
  assert.is(f.appended.length, 2, 'the priced copy is appended');
  assert.equal(f.appended[1][0].usage, priced.usage);

  // ...and only once. A throttled flush runs on every transcript change, so a turn that
  // re-emitted per flush would multiply the biggest entries in the stream.
  await p.flushDiagnostics();
  assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
});

Suite(
  'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
  async () => {
    // The dedup must still stop the SAME message being re-appended on every throttled flush.
    const m = diagMsg({ category: 'reasoning', content: 'thinking…' });
    const { p, f } = makePersister({ diagnosticEntries: [m] });
    await p.flushDiagnostics();
    assert.is(f.appended.length, 1, 'first flush appends the message');
    await p.flushDiagnostics();
    assert.is(f.appended.length, 1, 'unchanged message → no re-append');
  },
);

// GENC-1511: the save telemetry has to describe what HAPPENED, not what was attempted.
// `persistence.saved` used to be emitted before `provider.save` was awaited and the
// rejection was swallowed into `logger.error`, so a session whose writes had been
// failing for hours reported nothing but successful saves — in the debug log, in Loki
// and in Sentry alike.
Suite('a save that lands emits persistence.saved and no failure event', async () => {
  const { p, f } = makePersister({ history: [msg({ content: 'hi' })] });
  p.scheduleSave();
  await tick();
  assert.is(f.saved.length, 1, 'the write landed');
  const types = getMetaEvents(KEY).map((e) => e.type);
  assert.ok(types.includes('persistence.saved'), 'success is recorded');
  assert.not.ok(types.includes('persistence.save-failed'), 'no failure recorded');
});

Suite('a save that throws emits persistence.save-failed and NOT persistence.saved', async () => {
  const { p, f } = makePersister({
    history: [msg({ content: 'hi' })],
    saveError: Object.assign(new TypeError('Converting circular structure to JSON'), {}),
  });
  p.scheduleSave();
  await tick();
  assert.is(f.saved.length, 0, 'nothing was written');
  const events = getMetaEvents(KEY);
  const types = events.map((e) => e.type);
  assert.not.ok(
    types.includes('persistence.saved'),
    'a failed write must never report itself as saved',
  );
  const failed = events.find((e) => e.type === 'persistence.save-failed');
  assert.ok(failed, 'the failure is recorded');
  // `name` is what separates a transport rejection from a serialisation fault: a
  // TypeError means the body never reached the wire, so there is no server-side trace.
  assert.is((failed?.detail as Record<string, unknown>)?.error, 'TypeError');
  assert.is((failed?.detail as Record<string, unknown>)?.messages, 1);
});

// GENC-1511 — the user-facing warning. The gate is `resumable`, not `busy`: a bootstrap journey
// is ONE turn (measured at 2817 s with 82 widget interactions inside it), so a turn-boundary gate
// would hold the warning for 47 minutes.
Suite('a single failed save does NOT report broken — one blip is not an outage', async () => {
  const { p, f } = makePersister({
    history: [msg({ content: 'hi' })],
    saveError: new Error('transient'),
  });
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 0, 'a modal about a self-healing blip trains dismissal');
});

Suite('two consecutive failures report broken when the agent is resumable', async () => {
  const history = [msg({ content: 'one' })];
  const { p, f } = makePersister({ history, saveError: new Error('boom') });
  p.scheduleSave();
  await tick();
  history.push(msg({ content: 'two' }));
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 1, 'reported once, at the second failure');
  assert.is(f.brokenReports[0].reason, 'boom');
  assert.is(f.brokenReports[0].unsavedMessages, 2, 'counts what the store does not have');

  // ...and only once per episode, however many more saves fail.
  history.push(msg({ content: 'three' }));
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 1, 'not re-reported while still broken');
});

Suite('a NON-resumable agent holds the report back until it is safe to reload', async () => {
  const history = [msg({ content: 'one' }), msg({ content: 'two' })];
  const agent = {
    name: 'UI Builder',
    resumable: async () => false,
    serialize: () => ({}),
  };
  const { p, f } = makePersister({ history, activeAgent: agent, saveError: new Error('boom') });
  // Unrolled deliberately: these saves must happen in ORDER, so the lint rule's suggested
  // Promise.all would change what is being tested.
  history.push(msg({ role: 'assistant', content: 'turn 0' }));
  p.scheduleSave();
  await tick();
  history.push(msg({ role: 'assistant', content: 'turn 1' }));
  p.scheduleSave();
  await tick();
  history.push(msg({ role: 'assistant', content: 'turn 2' }));
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 0, 'advising a reload mid-flow would discard the VFS buffer');

  // Back at rest: now a reload is safe, so now the user is told.
  agent.resumable = async () => true;
  history.push(msg({ content: 'next' }));
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 1, 'reported at the first resumable moment');
});

Suite('a save that lands withdraws the warning', async () => {
  const history = [msg({ content: 'one' })];
  const fakes = { history, saveError: new Error('boom') as Error | undefined };
  const { p, f } = makePersister(fakes);
  p.scheduleSave();
  await tick();
  history.push(msg({ content: 'two' }));
  p.scheduleSave();
  await tick();
  assert.is(f.brokenReports.length, 1, 'broken');

  f.saveError = undefined; // the store comes back — observed twice in the wild
  history.push(msg({ content: 'three' }));
  p.scheduleSave();
  await tick();
  assert.is(f.recovered, 1, 'the warning is withdrawn rather than left stale');
  assert.ok(f.saved.length > 0, 'and the write landed');
});

Suite('a failed FREEZE write is reported, not swallowed', async () => {
  // This catch was the one silent failure left after save-failed was added to the resumable
  // branch: a session breaking while an agent was mid-flow reported nothing at all.
  const history = [msg({ content: 'do it' }), msg({ role: 'assistant', content: 'working' })];
  const { p, f } = makePersister({
    history,
    activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
    saveError: new Error('freeze write failed'),
  });
  p.scheduleSave();
  await tick();
  await tick();
  const failed = getMetaEvents(KEY).filter((e) => e.type === 'persistence.save-failed');
  assert.is(failed.length, 1, 'the freeze path now emits the failure event');
  assert.is(
    (failed[0].detail as Record<string, unknown>)?.frozen,
    true,
    'marked as the freeze path',
  );
});

// GENC-1511 review [P1]: the failure event must stay serialisable even when the thing that broke
// the save is the agent snapshot itself. `recordMetaEvent` stores detail BY REFERENCE, so
// spreading the captured snapshot made the high-importance event circular too — and then
// `assembleDebugLog` threw while stringifying it, so `downloadDebugLog` could not produce the log
// the blocking modal tells the user to save. Reproduced by Matt; regression-guarded here.
Suite('the save-failed event stays serialisable when the agent snapshot is circular', async () => {
  const circular: Record<string, unknown> = { machine: 'generating' };
  circular.self = circular;
  const { p } = makePersister({
    history: [msg({ content: 'hi' })],
    activeAgent: { name: 'UI Builder', serialize: () => circular },
    saveError: new TypeError('Converting circular structure to JSON'),
  });
  p.scheduleSave();
  await tick();
  const events = getMetaEvents(KEY);
  const failed = events.find((e) => e.type === 'persistence.save-failed');
  assert.ok(failed, 'the failure is recorded');
  assert.not.ok(
    (failed?.detail as Record<string, unknown>)?.agentSnapshots,
    'the raw snapshot must NOT be in the failure detail',
  );
  // The whole point: the debug log must still be producible.
  JSON.stringify(events);
});

Suite('a non-resumable agent is NEVER told to reload, however long it stays broken', async () => {
  // GENC-1511 review [P1]: an earlier draft bypassed the gate after 90 s. The modal's only action
  // is a reload, so firing it here forces the user to discard the out-of-band in-flight work the
  // gate exists to protect.
  const history = [msg({ content: 'do it' })];
  const { p, f } = makePersister({
    history,
    activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
    saveError: new Error('boom'),
  });
  history.push(msg({ role: 'assistant', content: 'a' }));
  p.scheduleSave();
  await tick();
  await tick();
  history.push(msg({ role: 'assistant', content: 'b' }));
  p.scheduleSave();
  await tick();
  await tick();
  assert.is(f.brokenReports.length, 0, 'no unsafe reload prompt, no matter how many failures');
});

Suite.run();
