import type { ChatMessage } from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
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;
}

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,
    ...over,
  };
  const provider = {
    saveDebounceMs: 0,
    diagnosticsDebounceMs: 0,
    save: async (_k: string, snap: PersistedSession) => {
      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,
  };
  return { p: new SessionPersister(deps), f };
}

const Suite = createLogicSuite('SessionPersister');

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

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 stamps the PRIOR snapshot, never an empty one', 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');
  assert.is(written.messages.length, 2, 'froze at the PRIOR transcript, not the in-flight one');
});

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() 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');
  },
);

Suite.run();
