import type { ChatMessage } from '@genesislcap/foundation-ai';
import type { AiDriver } from '../../components/ai-driver/ai-driver';
import { OrchestratingDriver } from '../../components/orchestrating-driver/orchestrating-driver';
import { flattenSubAgentMessages } from '../../utils/flatten-sub-agent-messages';
import { logger } from '../../utils/logger';
import {
  isSessionNoticeMarker,
  SESSION_RESTORE_FAILED_LABEL,
  SESSION_RESTORED_LABEL,
} from '../../utils/message-partition';
import { withTimeout } from '../../utils/with-timeout';
import { recordMetaEvent, type MetaEventType } from '../debug-event-log';
import type { SessionStoreReturn } from '../session-store';
import { diagnosticsMessageKey, type DiagnosticEntry } from './diagnostics';
import { getDiagnosticsCursors } from './diagnostics-cursors';
import type { SessionPersistenceConfig, SessionPreferences } from './session-persistence-provider';
import {
  createPersistedSession,
  isRestorableSession,
  keepThroughLastUserMessage,
  type PersistedSession,
  reconcileAgentSnapshots,
  resolveNonResumableNotice,
  toLoadSessionPayload,
  trimToRestorableBoundary,
  unknownAgentSnapshots,
} from './session-snapshot';

/**
 * Backstop for a slow/hung persistence provider on startup restore (GENC-1351
 * §6). Deliberately long: silently losing a restorable session is a far worse
 * outcome than a long "Restoring…" wait, so we give a slow backend plenty of room
 * and only give up (→ fresh session + a "couldn't restore" notice) when a load is
 * genuinely stuck. A healthy server responds well under this. Bounds both the
 * session `load` and the preferences load.
 */
const RESTORE_TIMEOUT_MS = 30_000;

/**
 * Default throttle between diagnostics appends (GENC-1351 §5.8) when the provider
 * doesn't specify `diagnosticsDebounceMs`. Coarser than the restore-core save — the
 * debug log is heavy and doesn't need per-change freshness.
 */
const DEFAULT_DIAGNOSTICS_DEBOUNCE_MS = 4_000;

/**
 * Fallback notice shown on return when an agent declared its state non-resumable
 * (GENC-1351 §5.9) and set no `notResumableMessage`. Paired with the composer
 * pre-filled with the flow-activating prompt, so "send it again" is one keystroke.
 */
/**
 * Consecutive failed saves before persistence is declared broken (GENC-1511). Two, not one: a
 * single failure is usually a transient blip that the next autosave clears on its own, and a
 * modal about a problem that has already fixed itself trains people to dismiss the one that
 * matters. At the 1 s autosave debounce this is ~2 s of exposure.
 */
const SAVE_FAILURES_BEFORE_BROKEN = 2;

const DEFAULT_NOT_RESUMABLE_MESSAGE =
  "Sorry, I didn't manage to finish the task from your previous message. Send it again if you'd like me to try it again";

/**
 * Dependencies the persister reads through — all getters so it always sees current
 * state and never holds a stale reference (the driver can be rebuilt on
 * agents-change, and the creating view can disconnect). Injected rather than read
 * from the module registries directly so the class is unit-testable with fakes.
 *
 * @internal
 */
export interface SessionPersisterDeps {
  /** Session identity — stable for the persister's lifetime. */
  stateKey: string;
  /** Current host persistence config (`enabled` + `provider`). Read fresh each op. */
  getPersistence: () => SessionPersistenceConfig;
  /** Names of the currently-configured agents (for the restore orphan check). */
  getAgentNames: () => string[];
  /** The session's shared driver, or `undefined` before it exists. */
  getDriver: () => AiDriver | undefined;
  /** The session's shared store, or `undefined` before it exists. */
  getStore: () => SessionStoreReturn | undefined;
  /**
   * Build the current diagnostic entries (message/turn/event/meta-snapshot). Stays
   * element-owned — it reads the host's agents, active-agent debug snapshot, meta
   * events, etc. — and is diffed against the shared cursors here (GENC-1351 §5.10).
   */
  getDiagnosticEntries: () => DiagnosticEntry[];
  /**
   * Persistence has failed repeatedly and it is now a safe moment to tell the user (GENC-1511).
   * Called at most once per broken episode — see {@link SessionPersister.persistSession} for the
   * gating, which waits for the active agent to be RESUMABLE so we never advise a reload at a
   * moment when reloading would throw away in-flight work the snapshot cannot carry.
   */
  onPersistenceBroken?: (detail: {
    /** Messages in the live transcript that the store does not have. */
    unsavedMessages: number;
    /** `savedAt` of the last snapshot that landed, if one ever did. */
    lastSavedAt?: string;
    /** Why the writes are failing, as reported by the provider. */
    reason: string;
  }) => void;
  /** A save landed after a broken episode — the warning can be withdrawn (GENC-1511). */
  onPersistenceRecovered?: () => void;
}

/**
 * Owns **restore-core** persistence for one session (GENC-1351 §5.10) — the
 * save/restore/freeze of the {@link PersistedSession} snapshot plus the
 * carry-forward cache. Created once per session via `persister-registry.ts` and
 * shared by every element view, so there is a single save timer and a single
 * cache: the per-view double-write races (and the corruption they caused under a
 * non-atomic backend) are gone by construction.
 *
 * Owns all provider I/O for the session: the restore-core snapshot (save/restore/
 * freeze), the user-preferences blob (load/save — the element keeps only the
 * `chatConfig`-defaults reconciler), and the diagnostics-append scheduling (one
 * timer per session; entry-building stays element-owned via `getDiagnosticEntries`).
 *
 * @internal
 */
export class SessionPersister {
  private _saveTimer?: ReturnType<typeof setTimeout>;
  private _lastSaveAt = 0;
  private _diagnosticsTimer?: ReturnType<typeof setTimeout>;
  private _lastDiagnosticsAt = 0;
  /**
   * The flow-owner's last-known-good serialized image, carried forward when a
   * post-restore autosave fires before the agent re-activates (its live capture is
   * empty). Seeded on restore. See {@link reconcileAgentSnapshots}.
   */
  private _cache: Record<string, unknown> = {};
  /**
   * How many messages the last SUCCESSFUL write persisted. Two jobs, both GENC-1511:
   * it is the monotonic floor for a freeze boundary (the stored transcript must never
   * shrink), and it is what lets a running flow skip a re-write when its boundary has
   * not moved. Content-keyed, so unlike the `interrupted` latch it replaces it can never
   * become a one-way "never write again" state.
   */
  private _persistedMessages = 0;
  /**
   * Whether the CURRENT non-resumable episode has already stamped its `interrupted`
   * marker. Reset by every resumable save, so each new episode stamps exactly once and
   * the "boundary unmoved" skip below can never swallow the stamp itself (GENC-1511).
   */
  private _interruptedStamped = false;
  /** Consecutive failed saves; reset by any success. GENC-1511. */
  private _saveFailures = 0;
  /** When the failure count crossed {@link SAVE_FAILURES_BEFORE_BROKEN}; `undefined` = healthy. */
  private _brokenSince?: number;
  /** Whether this broken episode has already been reported to the host. */
  private _brokenReported = false;
  /** `savedAt` of the last snapshot that actually landed, for the host's message. */
  private _lastSavedAt?: string;
  /** Provider's reason for the most recent failure, for the host's message. */
  private _lastFailureReason = '';
  /**
   * The loaded preferences blob (GENC-1351 §5.10 Phase 2), owned here so it's
   * **shared per session** — the element's `applyPreferenceBaseline` reconciler reads
   * it via {@link storedPreferences}. Kept fresh on every save, so a later re-reconcile
   * uses the latest choices, not the stale startup blob. `null` = loaded, none stored.
   */
  private _storedPreferences: SessionPreferences | null = null;
  /**
   * Whether the user has changed any preference this session. **Shared per session**
   * (was per-element, which let the two views disagree and one clobber the other's
   * choice — issue #1). Once set, the reconciler freezes so `chatConfig` defaults
   * can't overwrite a live user choice, regardless of which view toggled it.
   */
  private _preferencesUserChanged = false;

  constructor(private deps: SessionPersisterDeps) {}

  /**
   * Re-point the dependency getters at the currently-live element view. The
   * persister outlives any single element (it's cached per session in
   * `persister-registry.ts`), so when a later element mounts on the same session
   * — e.g. an SPA teardown + remount — it MUST rebind, or the persister keeps
   * calling the torn-down creator's `getDiagnosticEntries`/`getPersistence`/
   * `getAgentNames` (which read a detached driver/store → empty), silently halting
   * diagnostics + autosave capture (GENC-1351 §5.10). `stateKey` is unchanged.
   *
   * @internal
   */
  rebindDeps(deps: SessionPersisterDeps): void {
    this.deps = deps;
  }

  /** The loaded preferences blob (shared per session); read by the element's reconciler. */
  get storedPreferences(): SessionPreferences | null {
    return this._storedPreferences;
  }

  /** True once the user has changed any preference this session (shared per session). */
  get preferencesUserChanged(): boolean {
    return this._preferencesUserChanged;
  }

  private get stateKey(): string {
    return this.deps.stateKey;
  }

  private logMeta(type: MetaEventType, detail?: Record<string, unknown>): void {
    recordMetaEvent(this.stateKey, type, detail);
  }

  /**
   * Throttled autosave. Called by every view on each transcript change; the single
   * shared timer coalesces them into at most one save per the provider's
   * `saveDebounceMs`. `saveDebounceMs: 0` (e.g. `sessionStorage`) means save as
   * soon as the current tick settles.
   */
  scheduleSave(): void {
    const { provider, enabled } = this.deps.getPersistence();
    if (!enabled || !provider) return;
    if (this._saveTimer != null) return; // a save is already pending — coalesce
    const elapsed = Date.now() - this._lastSaveAt;
    const delay = Math.max(0, provider.saveDebounceMs - elapsed);
    this._saveTimer = setTimeout(() => {
      this._saveTimer = undefined;
      this._lastSaveAt = Date.now();
      void this.persistSession();
    }, delay);
  }

  /**
   * Cancel a pending debounced autosave **without flushing it** (GENC-1351 §5.12).
   * Called before a session switch/re-key so the outgoing transcript can never be
   * written to the incoming session's file after the key changes — the clobber
   * guard for in-place project switching.
   */
  cancelPendingSave(): void {
    if (this._saveTimer != null) {
      clearTimeout(this._saveTimer);
      this._saveTimer = undefined;
    }
  }

  /**
   * Record a save outcome and, when persistence has been failing, decide whether now is the
   * moment to tell the user (GENC-1511).
   *
   * The gate is `resumable`, NOT "is a turn running". `busy` was the obvious choice and it is
   * useless here: a bootstrap journey is ONE turn — measured at 2817 s containing 82 widget
   * interactions — so waiting for a turn boundary would hold the warning for 47 minutes. What
   * actually matters is whether a reload can be resumed from the stored snapshot, and that is
   * exactly what `resumable` already answers for the freeze. Bootstrap keeps everything in its
   * machine context, so it is always resumable and gets told immediately; UI Builder keeps
   * in-flight work in an out-of-band VFS buffer, so it waits for `idle`/`done` — which is also
   * the point at which its files have been flushed and a reload costs nothing.
   */
  private recordSaveOutcome(ok: boolean, reason: string, savedAt?: string): void {
    if (ok) {
      const wasBroken = this._brokenSince !== undefined;
      this._saveFailures = 0;
      this._brokenSince = undefined;
      this._lastSavedAt = savedAt ?? this._lastSavedAt;
      if (wasBroken) {
        // Withdraw the warning: a save landing means the store is caught up, which we have
        // watched happen twice in the wild — a broken window is not necessarily permanent.
        this._brokenReported = false;
        this.deps.onPersistenceRecovered?.();
      }
      return;
    }
    this._saveFailures += 1;
    this._lastFailureReason = reason;
    if (this._saveFailures >= SAVE_FAILURES_BEFORE_BROKEN && this._brokenSince === undefined) {
      this._brokenSince = Date.now();
    }
  }

  /**
   * Surface a broken episode if it is now safe to advise a reload.
   *
   * Gated on `resumable` and NOTHING else. An earlier draft added a 90 s backstop so a flow that
   * errored and stuck mid-state could not hold the warning forever — that was wrong, and the
   * review caught it: the modal's only action is a reload, so firing it while the agent is
   * non-resumable forces the user to destroy exactly the out-of-band, in-flight work the gate
   * exists to protect. Being told late is bad; being told to do the damaging thing is worse.
   * Surfacing a stuck flow earlier needs non-blocking warning UI, not this modal.
   */
  private maybeReportBroken(resumable: boolean, liveMessages: number): void {
    if (this._brokenSince === undefined || this._brokenReported || !resumable) return;
    this._brokenReported = true;
    this.deps.onPersistenceBroken?.({
      unsavedMessages: Math.max(0, liveMessages - this._persistedMessages),
      lastSavedAt: this._lastSavedAt,
      reason: this._lastFailureReason,
    });
  }

  /**
   * Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the flow-owner
   * during a locked flow, like `serialize`). A thrown predicate fails safe to persisting; no
   * live active agent (e.g. the post-restore, pre-reactivation save) leaves it resumable and
   * persists normally.
   *
   * Extracted from `persistSession` so that method stays under the complexity cap, and because
   * this answer now has two consumers: the freeze decision, and whether it is safe to advise the
   * user to reload (GENC-1511).
   */
  private async resolveResumable(
    liveActive: ReturnType<NonNullable<AiDriver['getActiveAgent']>> | undefined,
    key: string,
    agentSnapshots: Record<string, unknown>,
    messages: ChatMessage[],
    s: SessionStoreReturn['store']['aiAssistant'],
  ): Promise<boolean> {
    if (!liveActive?.resumable) return true;
    try {
      return await liveActive.resumable({
        agentName: liveActive.name,
        sessionKey: key,
        state: agentSnapshots[liveActive.name],
        messages,
        isFlowOwner: s.flowOwnerAgentName === liveActive.name,
      });
    } catch (e) {
      logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
      return true;
    }
  }

  /**
   * Capture the current session to durable storage (GENC-1351 §5.1). Reads the
   * full transcript from the driver and the restorable scalars from the store, and
   * captures the active stateful agent's `serialize()` into `agentSnapshots`.
   */
  private async persistSession(): Promise<void> {
    const { provider, enabled } = this.deps.getPersistence();
    if (!enabled || !provider) return;
    const key = this.stateKey;
    const ref = this.deps.getStore();
    const driver = this.deps.getDriver();
    if (!ref) return;
    const s = ref.store.aiAssistant;
    // Strip the display-only restore notices (restored / couldn't-restore) so they
    // never persist or accumulate across reload→save→reload cycles.
    const messages = (driver?.getRawHistory?.() ?? s.messages).filter(
      (m) => !isSessionNoticeMarker(m),
    );
    if (!messages.length) return; // nothing worth persisting (e.g. a just-cleared session)
    // Serialize the live active agent from the DRIVER (the instance whose
    // `onActivate` ran), not via the host's `agents` — a host whose `agents` getter
    // mints fresh configs (the showcase) would otherwise hand us a never-activated
    // closure whose `serialize()` returns undefined.
    const liveSnapshots: Record<string, unknown> = {};
    const liveActive = driver?.getActiveAgent?.();
    if (liveActive?.serialize) {
      try {
        const snap = liveActive.serialize();
        if (snap !== undefined) liveSnapshots[liveActive.name] = snap;
      } catch (e) {
        logger.error('Session save: agent serialize() failed:', e);
      }
    }
    // A post-restore autosave (triggered by the restore's own history change) runs
    // BEFORE the pinned stateful agent re-activates, so the live capture is empty
    // even though a flow is mid-stream. Writing `{}` would wipe the machine and
    // reset the flow on the next reload — so carry the flow-owner's last-known-good
    // image forward (GENC-1351 §6). Pure + unit-tested.
    const { snapshots: agentSnapshots, cache } = reconcileAgentSnapshots(
      liveSnapshots,
      s.flowOwnerAgentName,
      this._cache,
    );
    this._cache = cache;

    const resumable = await this.resolveResumable(liveActive, key, agentSnapshots, messages, s);

    // Report a broken episode at the first SAFE moment, which is what `resumable` decides.
    // Deliberately before either branch acts, so the warning does not wait on the very write
    // that is failing.
    this.maybeReportBroken(resumable, messages.length);

    if (!resumable) {
      // FREEZE THE FLOW STATE, NOT THE TRANSCRIPT (GENC-1511).
      //
      // The intent of §5.9 is the one stated on `PersistedSession.interrupted`: the
      // conversation is persisted and restored in full, and only the running agent's
      // flow-state is dropped. The original implementation did the opposite whenever a
      // snapshot already existed — it froze at the **stored** blob (`existing ?? …`),
      // so everything since the last resumable save was silently rolled back, transcript
      // included, and `if (existing?.interrupted) return` then latched that rollback in
      // for the life of the page. A UI-builder session spent 3h17m in that state: 256
      // loads, zero writes, and a reload that took the user back to a mid-bootstrap
      // snapshot from three hours earlier.
      //
      // So the freeze target is now ALWAYS the live transcript through the last user
      // message — the conversation plus the prompt that started the flow, with the
      // agent's in-flight turns and flow-state dropped. Advancing on each new boundary
      // means a long-running flow can no longer cost the user the work that preceded it.
      const { resumeMessage, resumePrompt } = resolveNonResumableNotice({
        notResumableMessage: liveActive?.notResumableMessage,
        flowActivationPrompt: s.flowActivationPrompt,
        messages,
        defaultMessage: DEFAULT_NOT_RESUMABLE_MESSAGE,
      });
      // Never regress the stored transcript. `keepThroughLastUserMessage` alone can be
      // SHORTER than what is already persisted — an agent that goes non-resumable without
      // a fresh user prompt (auto-advance, a resumed journey step) would otherwise drop
      // completed exchanges that were already safely stored. Taking the max of the two
      // makes the boundary monotonic: it advances with each new prompt and never rewinds.
      const boundary = Math.max(
        keepThroughLastUserMessage(messages).length,
        this._persistedMessages,
      );
      const frozen = createPersistedSession({
        sessionKey: key,
        host: window.location.host,
        messages: messages.slice(0, Math.min(boundary, messages.length)),
        activeAgentName: s.activeAgent?.name,
        pinnedAgentName: s.pinnedAgentName,
        flowOwnerAgentName: null,
        sessionCostUsd: s.sessionUsage.costUsd,
        contextTokens: s.contextTokens,
        contextLimit: s.contextLimit,
        activeModel: s.activeModel,
        activeProviderName: s.activeProviderName,
        agentSnapshots: {},
      });
      // Skip the write when the frozen boundary has not moved — a flow autosaves on every
      // transcript change, and while it runs the last user message (and therefore the
      // frozen content) is unchanged, so re-writing it would put one ~MB PUT on the wire
      // per tool call for nothing. Keyed on CONTENT, unlike the old `interrupted` latch:
      // it re-arms the moment the boundary moves, and the resumable path below clears it,
      // so it can never become the one-way "never write again" state it replaces. It also
      // drops the `provider.load` this branch used to issue on every single autosave.
      if (this._persistedMessages === frozen.messages.length && this._interruptedStamped) {
        return;
      }
      this.logMeta('persistence.suppressed', {
        agent: liveActive?.name ?? null,
        isFlowOwner: s.flowOwnerAgentName === liveActive?.name,
        frozeAtMessages: frozen.messages.length,
        custom: !!liveActive?.notResumableMessage,
      });
      try {
        await provider.save(key, {
          ...frozen,
          interrupted: { message: resumeMessage, prompt: resumePrompt },
        });
        this._persistedMessages = frozen.messages.length;
        this._interruptedStamped = true;
        this.recordSaveOutcome(true, '', frozen.savedAt);
        logger.debug('Flow non-resumable — froze snapshot + stamped interrupted marker', { key });
      } catch (e) {
        // GENC-1511: this catch used to be the ONE swallowed failure left after the save-failed
        // event was added to the resumable branch — so a session that broke while an agent was
        // mid-flow still reported nothing at all, which is precisely the blind spot the
        // user-facing warning exists to cover.
        this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
        // Non-resumable, so this can only surface via the backstop — a reload here would throw
        // away the in-flight work the snapshot cannot carry.
        this.maybeReportBroken(false, messages.length);
        this.logMeta('persistence.save-failed', {
          messages: frozen.messages.length,
          agents: [],
          active: liveActive?.name ?? null,
          hasSerialize: !!liveActive?.serialize,
          frozen: true,
          error: (e as Error)?.name ?? typeof e,
          message: (e as Error)?.message ?? String(e),
        });
        logger.error('Session save (interrupted marker) failed:', e);
      }
      return;
    }

    // Resumable → normal save (this overwrites any prior `interrupted` marker, so a
    // later non-resumable episode stamps afresh).
    const snapshot = createPersistedSession({
      sessionKey: key,
      host: window.location.host,
      messages: [...messages],
      activeAgentName: s.activeAgent?.name,
      pinnedAgentName: s.pinnedAgentName,
      flowOwnerAgentName: s.flowOwnerAgentName,
      sessionCostUsd: s.sessionUsage.costUsd,
      contextTokens: s.contextTokens,
      contextLimit: s.contextLimit,
      activeModel: s.activeModel,
      activeProviderName: s.activeProviderName,
      agentSnapshots,
    });
    // Observability (GENC-1351): record what each autosave captured for stateful
    // agents — `agents: []` here means no machine snapshot was taken (the bug to
    // watch for: the active agent's `serialize()` returned nothing).
    //
    // GENC-1511: this event is emitted AFTER the write resolves, never before. It used
    // to be logged ahead of `provider.save`, which made it a record of an ATTEMPT while
    // reading as a record of a SAVE — and the failure below was swallowed into
    // `logger.error`, which is not wired to Sentry. A session whose writes had been
    // failing for three hours therefore showed 52 cheerful `persistence.saved` events
    // and no failures anywhere: not in the debug log, not in Loki, not in Sentry. The
    // whole incident was invisible until the box's nginx access log was read by hand.
    const captured = {
      messages: messages.length,
      agents: Object.keys(agentSnapshots),
      active: liveActive?.name ?? null,
      hasSerialize: !!liveActive?.serialize,
      agentSnapshots,
    };
    try {
      await provider.save(key, snapshot);
      this._persistedMessages = messages.length;
      this._interruptedStamped = false;
      this.recordSaveOutcome(true, '', snapshot.savedAt);
      this.logMeta('persistence.saved', captured);
      logger.debug('Session saved', { key, messages: messages.length });
    } catch (e) {
      // `high` importance, and deliberately verbose: this is the one event that says
      // "the transcript on screen is no longer backed by the store". `name` separates a
      // provider/transport rejection from a serialisation fault — a `TypeError` here
      // means the body never reached the wire at all, because a provider builds its
      // request body inside the `fetch` init, so a throw while serialising creates no
      // request and leaves no server-side trace.
      this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
      // Report straight away rather than waiting for the next attempt: we are on the resumable
      // path, so a reload is safe NOW, and a one-cycle delay is a second of extra exposure for
      // no benefit.
      this.maybeReportBroken(true, messages.length);
      // Deliberately NOT `...captured`: that carries the raw `agentSnapshots` object, and
      // `recordMetaEvent` stores the detail BY REFERENCE. When the save failed *because* that
      // object is circular — the fault this catch exists for — the high-importance failure event
      // became circular too, so `assembleDebugLog` threw while stringifying it and
      // `downloadDebugLog` could not produce the log this modal tells the user to save. Names
      // and scalars only; the success event above may keep the snapshots, because a landed write
      // has already proved them serialisable.
      this.logMeta('persistence.save-failed', {
        messages: captured.messages,
        agents: captured.agents,
        active: captured.active,
        hasSerialize: captured.hasSerialize,
        error: (e as Error)?.name ?? typeof e,
        message: (e as Error)?.message ?? String(e),
      });
      logger.error('Session save failed:', e);
    }
  }

  /**
   * Page-reload restore (GENC-1351 §5.3). With a stored snapshot for this session,
   * hydrate the store and reseed the driver so the conversation continues. No-op
   * when disabled, absent, or version/host mismatched. An `interrupted` snapshot
   * (§5.9) restores the conversation but shows a "start again" assistant message
   * instead of rehydrating the mid-flight flow.
   */
  async restore(): Promise<void> {
    const { provider } = this.deps.getPersistence();
    if (!provider) return;
    const key = this.stateKey;
    const driver = this.deps.getDriver();

    // Bound a slow/hung provider so a stuck load can't brick startup. On timeout
    // (or a load rejection) `withTimeout` throws — its late result is discarded by
    // the race, so it can never clobber a now-live session (§6). Crucially, a throw
    // is distinct from a resolved `null`: a throw means "there may have been a
    // session but we couldn't fetch it" → surface a notice; `null` means "nothing
    // stored" → start fresh silently.
    let snapshot: PersistedSession | null;
    try {
      snapshot = await withTimeout(provider.load(key), RESTORE_TIMEOUT_MS);
    } catch (e) {
      logger.warn('Session restore: load failed or timed out — starting fresh', e);
      this.showRestoreFailedNotice();
      return;
    }
    if (!snapshot) return; // genuinely nothing stored → fresh session, no notice

    try {
      if (!isRestorableSession(snapshot, { host: window.location.host })) {
        // A saved snapshot existed but can't be used (schema bump / cross-host) —
        // tell the user rather than silently dropping their conversation.
        logger.debug('Session restore skipped: version/host mismatch', { key });
        this.showRestoreFailedNotice();
        return;
      }
      // Trim a trailing incomplete turn (orphaned tool call / placeholder / pending
      // interaction) so a snapshot saved mid-loop restores to the last
      // provider-valid boundary (§5.3 step 2).
      const trimmed = trimToRestorableBoundary(snapshot.messages).filter(
        (m) => !isSessionNoticeMarker(m),
      );
      const ref = this.deps.getStore();
      if (!ref) return;
      // Diagnostics watermark (§5.8): messages present at restore already live in a
      // prior load's diagnostics stream — record their identities in the shared
      // per-session cursors so this load's forward capture never re-appends them
      // (only genuinely new activity streams). Shared, so the element view that
      // never runs restore honours the same watermark.
      getDiagnosticsCursors(key).restoredMsgKeys = new Set(
        flattenSubAgentMessages(trimmed)
          .map((e) => diagnosticsMessageKey(e as unknown as DiagnosticEntry))
          .filter((k): k is string => k !== null),
      );
      // Prime each stateful agent's persisted machine/state image FIRST — before
      // seeding Redux or driver history — so the image is waiting on the driver no
      // matter when the agent re-activates. The flow-owner lock self-heals on
      // activation (OrchestratingDriver.applyAgent re-sets it).
      driver?.primeRestoredAgentState?.(snapshot.agentSnapshots);
      // Seed the carry-forward cache so the immediate post-restore autosave (fired
      // by the history change below, before any agent re-activates) preserves the
      // machine snapshot instead of overwriting it with an empty one (GENC-1351).
      this._cache = { ...snapshot.agentSnapshots };
      // Seed the monotonic freeze floor too (GENC-1511): a flow that goes non-resumable
      // straight after a restore must not freeze BELOW what the restore just brought back.
      this._persistedMessages = trimmed.length;
      // Observability (GENC-1351): record exactly what the restore primed, so a
      // post-reload debug-log export shows whether a stateful agent's snapshot
      // actually came back (meta events reset on reload, so this is the only window
      // onto the saved→loaded handoff).
      this.logMeta('persistence.restored', {
        agents: Object.keys(snapshot.agentSnapshots ?? {}),
        agentSnapshots: snapshot.agentSnapshots,
        pinnedAgentName: snapshot.pinnedAgentName,
        flowOwnerAgentName: snapshot.flowOwnerAgentName,
      });
      // Loudly surface saved agent state that can't be rehydrated because the agent
      // isn't in the current set — otherwise the flow silently fails to resume, a
      // real footgun for hosts with dynamic agent arrays. Non-fatal: the transcript
      // and any known agents still restore.
      const orphanedAgents = unknownAgentSnapshots(snapshot, this.deps.getAgentNames());
      if (orphanedAgents.length) {
        logger.error(
          `Session restore: saved state for agent(s) [${orphanedAgents.join(', ')}] cannot be ` +
            `rehydrated — not in the configured agents, so their flow will not resume. ` +
            `(The transcript and any known agents still restore.)`,
          { key },
        );
        this.logMeta('persistence.restore-orphan', {
          orphanedAgents,
          knownAgents: this.deps.getAgentNames(),
        });
      }
      // Trailing rows appended to the restored transcript:
      //  • interrupted flow (§5.9) → the user's triggering message (when captured) followed
      //    by a real `assistant` "start again" message. Both are normal chat bubbles,
      //    persisted as part of the conversation, so the exchange reads naturally — the user
      //    sees what they asked and the assistant's reply explaining it didn't complete
      //    (instead of an assistant reply to an invisible message). The frozen snapshot never
      //    rehydrates the in-flight flow (its `agentSnapshots` are the last *resumable* state),
      //    so the triggering message is text only — none of the dropped work comes back.
      //  • otherwise → the subtle "restored" `system-event` divider (§10-O2),
      //    display-only and stripped again on the next save.
      const trailerTs = new Date().toISOString();
      let trailers: ChatMessage[];
      if (snapshot.interrupted) {
        trailers = [];
        // Re-append the triggering user message UNLESS the frozen transcript already
        // ends with it. The two freeze paths differ: an *existing* prior snapshot is
        // older than the trigger (so the trigger is absent and must be re-added), but
        // the *fresh-freeze fallback* keeps the transcript through the last user
        // message (`keepThroughLastUserMessage`), so the trigger is already present —
        // re-appending it there would duplicate the bubble (GENC-1351 §5.9).
        const lastTrimmed = trimmed[trimmed.length - 1];
        const promptAlreadyPresent =
          lastTrimmed?.role === 'user' && lastTrimmed.content === snapshot.interrupted.prompt;
        if (snapshot.interrupted.prompt && !promptAlreadyPresent) {
          trailers.push({
            role: 'user',
            content: snapshot.interrupted.prompt,
            timestamp: trailerTs,
          } as ChatMessage);
        }
        trailers.push({
          role: 'assistant',
          content: snapshot.interrupted.message,
          timestamp: trailerTs,
        } as ChatMessage);
      } else if (trimmed.length === 0) {
        trailers = [];
      } else {
        trailers = [
          {
            role: 'system-event',
            content: SESSION_RESTORED_LABEL,
            timestamp: trailerTs,
          } as ChatMessage,
        ];
      }
      const messages = [...trimmed, ...trailers];
      ref.actions.aiAssistant.loadSession(toLoadSessionPayload({ ...snapshot, messages }));
      driver?.loadHistory?.([...messages]);
      // Interrupted flow (§5.9): pre-fill the composer with the prompt that started
      // the flow so "send it again" is one keystroke, and record it for observability.
      if (snapshot.interrupted) {
        if (snapshot.interrupted.prompt) {
          ref.actions.aiAssistant.setInputValue(snapshot.interrupted.prompt);
        }
        this.logMeta('persistence.not-resumable', {
          hadPrompt: !!snapshot.interrupted.prompt,
          messages: trimmed.length,
        });
      }
      // Propagate the restored pin AND flow-owner lock to the DRIVER's routing (GENC-1351).
      // `loadSession` only hydrates the store; the driver learns them via `setPinnedAgent`/
      // `setFlowOwner`, which otherwise ran at `wireDriver` time — before restore, when the
      // store values were still null. Without this the first post-restore message falls
      // through to the classifier and re-routes away from the flow agent. The pin covers a
      // user-pinned agent; the flow-owner lock covers a *classifier-routed* stateful flow
      // (flow-owned but unpinned) — `resolvePinnedAgent` reads `flowOwnerAgentName ??
      // pinnedAgentName`, so restoring both resumes the flow without re-classifying, and the
      // driver's routing agrees with the restored (picker-locked) store state.
      if (driver instanceof OrchestratingDriver) {
        driver.setPinnedAgent(snapshot.pinnedAgentName ?? null);
        driver.setFlowOwner(snapshot.flowOwnerAgentName ?? null);
      }
      logger.debug('Session restored', { key, messages: messages.length });
    } catch (e) {
      logger.error('Session restore failed:', e);
      this.showRestoreFailedNotice();
    }
  }

  /**
   * Surface a non-fatal "couldn't restore" notice after a failed restore (load
   * timeout/error, or an unusable snapshot) so a vanished conversation isn't a
   * silent mystery on reload (GENC-1351 §6). Reuses the display-only `system-event`
   * divider (like the "Restored…" one) — stripped before persist, so it never lands
   * in the fresh session's saved history. `loadHistory` is safe here: this path only
   * runs when nothing was restored, so the driver is empty.
   */
  private showRestoreFailedNotice(): void {
    this.deps.getDriver()?.loadHistory?.([
      {
        role: 'system-event',
        content: SESSION_RESTORE_FAILED_LABEL,
        timestamp: new Date().toISOString(),
      } as ChatMessage,
    ]);
  }

  /**
   * Drop the restore-core snapshot (the "remember chat" toggle-off and the Clear
   * action) and reset the carry-forward cache. Diagnostics + preferences are
   * separate and untouched here.
   */
  async clearRestoreCore(): Promise<void> {
    this._cache = {};
    this._persistedMessages = 0;
    this._interruptedStamped = false;
    const { provider } = this.deps.getPersistence();
    const key = this.stateKey;
    if (!provider) return;
    try {
      await provider.clear(key);
    } catch (e) {
      logger.error('Session clear failed:', e);
    }
  }

  // ── Preferences (GENC-1351 §5.10 Phase 2) ──────────────────────────────────
  // The persister owns the provider I/O for the stored preferences blob; the
  // element keeps `gatherPreferences` + the `chatConfig`-defaults reconciler
  // (`applyPreferenceBaseline`), which needs `this.chatConfig`.

  /**
   * Load the remembered preferences blob (bounded by {@link RESTORE_TIMEOUT_MS}) into
   * {@link storedPreferences}. A throw propagates to the caller (the element wraps it
   * and falls back to config defaults); on success `null` = no provider / none stored.
   */
  async loadPreferences(): Promise<void> {
    const { provider } = this.deps.getPersistence();
    if (!provider) {
      this._storedPreferences = null;
      return;
    }
    const prefs = await withTimeout(
      Promise.resolve(provider.loadPreferences?.(this.stateKey)),
      RESTORE_TIMEOUT_MS,
    );
    this._storedPreferences = prefs ?? null;
  }

  /**
   * Persist the preferences blob. Only ever called on a genuine user change, so it
   * also records that (freezing the reconciler — shared per session) and keeps
   * {@link storedPreferences} fresh. Fires **regardless of "remember chat"** — display
   * choices are the user's — needing only a provider that implements `savePreferences`.
   */
  savePreferences(prefs: SessionPreferences): void {
    this._preferencesUserChanged = true;
    this._storedPreferences = prefs;
    const { provider } = this.deps.getPersistence();
    if (!provider?.savePreferences) return;
    void provider
      .savePreferences(this.stateKey, prefs)
      .catch((e) => logger.error('Persistence: savePreferences failed:', e));
  }

  // ── Diagnostics append (GENC-1351 §5.10 Phase 3) ───────────────────────────
  // One append scheduler per session (was per-view). Entry *building* stays
  // element-owned (`getDiagnosticEntries`); the cursor-diffing + append live here.

  /**
   * Throttled diagnostics append (GENC-1351 §5.8). On each transcript change, if the
   * provider persists diagnostics, coalesce and append the new activity since the
   * last flush (coarser than the restore-core save — the log is heavy). Keyed on
   * provider capability only, **independent of "remember chat"**. No-op without a
   * diagnostics-capable provider, so it's free for other consumers.
   */
  scheduleDiagnostics(): void {
    const { provider } = this.deps.getPersistence();
    if (!provider?.appendDiagnostics) return;
    // Don't capture mid-restore: the watermark (restoredMsgKeys) is set during
    // restore, and a flush before it lands would re-append the restored history.
    // `restoring` is slice-backed (shared), so every view holds off (§5.8/§6).
    if (this.deps.getStore()?.store.aiAssistant.restoring) return;
    if (this._diagnosticsTimer != null) return; // coalesce
    const debounce = provider.diagnosticsDebounceMs ?? DEFAULT_DIAGNOSTICS_DEBOUNCE_MS;
    const delay = Math.max(0, debounce - (Date.now() - this._lastDiagnosticsAt));
    this._diagnosticsTimer = setTimeout(() => {
      this._diagnosticsTimer = undefined;
      this._lastDiagnosticsAt = Date.now();
      void this.flushDiagnostics();
    }, delay);
  }

  /**
   * Append the diagnostic entries created since the last flush to the provider's
   * forward-only stream. Best-effort — a failed write is logged and never touches
   * the chat. (The cursors advance on collect, so a dropped batch isn't retried;
   * acceptable for a forensic stream that isn't the source of truth.) Also called
   * directly by the download path to land the current delta before reassembly.
   */
  async flushDiagnostics(): Promise<void> {
    const { provider } = this.deps.getPersistence();
    if (!provider?.appendDiagnostics) return;
    const delta = this.collectDiagnosticsDelta();
    if (!delta.length) return;
    try {
      await provider.appendDiagnostics(this.stateKey, delta);
    } catch (e) {
      logger.error('Diagnostics append failed:', e);
    }
  }

  /**
   * The diagnostic entries not yet appended this load — the forward-capture delta
   * (§5.8). Skips messages present at restore or already appended; emits each meta-event
   * (by index) once and each turn (by turn-index) once unpriced plus once more when its
   * per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
   * Advances the shared per-session cursors as it goes.
   *
   * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
   * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
   * so we must diff it against per-session cursors to append only the new tail — and messages, unlike
   * events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
   * rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
   * restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
   * identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
   * silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
   * robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
   * creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
   * the message-identity key and this dedup entirely. Bigger change; deferred.
   */
  private collectDiagnosticsDelta(): DiagnosticEntry[] {
    const cursors = getDiagnosticsCursors(this.stateKey);
    const delta: DiagnosticEntry[] = [];
    for (const entry of this.deps.getDiagnosticEntries()) {
      if (entry.kind === 'message') {
        const key = diagnosticsMessageKey(entry);
        if (key !== null) {
          if (cursors.restoredMsgKeys.has(key) || cursors.emittedMsgKeys.has(key)) continue;
          cursors.emittedMsgKeys.add(key);
        }
        delta.push(entry);
      } else if (entry.kind === 'turn') {
        // A turn entry is created BEFORE its model call and priced when the response
        // lands, so a flush that falls in between persists it unpriced. Emit it a second
        // time once `usage` is there — keyed separately so it happens at most once per
        // turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
        // Without this the stitched lifetime log silently loses per-call cost for any
        // turn that outlived a flush, which is most of the slow ones.
        const key = String(entry.turnIndex ?? '');
        const emitKey = entry.usage ? `${key}::usage` : key;
        if (cursors.emittedTurnKeys.has(emitKey)) continue;
        cursors.emittedTurnKeys.add(emitKey);
        delta.push(entry);
      } else if (entry.kind === 'event') {
        const index = typeof entry.index === 'number' ? entry.index : -1;
        if (index <= cursors.lastEventIndex) continue;
        cursors.lastEventIndex = index;
        delta.push(entry);
      } else {
        // meta-snapshot — append only when the near-static config actually changed.
        // Prefer the entry's `dedupSignature` (the config-only projection the element
        // builds), so per-flush churn in the volatile fields (export timestamp,
        // context tokens/cost, live debug snapshot) no longer forces a re-append of
        // the bulky block every time (GENC-1351 §5.8 / #12). Falls back to the full
        // `meta` for any entry that predates the signature.
        const sig =
          typeof entry.dedupSignature === 'string'
            ? entry.dedupSignature
            : JSON.stringify(entry.meta);
        if (sig === cursors.lastMetaJson) continue;
        cursors.lastMetaJson = sig;
        delta.push(entry);
      }
    }
    return delta;
  }

  /** Cancel any pending timers. Called on explicit session teardown. */
  dispose(): void {
    clearTimeout(this._saveTimer);
    this._saveTimer = undefined;
    clearTimeout(this._diagnosticsTimer);
    this._diagnosticsTimer = undefined;
  }
}
