import type { ChatMessage } from '@genesislcap/foundation-ai';
import type { LoadSessionPayload } from '../ai-assistant-slice';

/**
 * Schema version of the persisted session shape. Bump on an incompatible
 * change; a snapshot whose `version` doesn't match is discarded on load rather
 * than risk feeding a stale shape into restore.
 *
 * @public
 */
export const PERSISTED_SESSION_VERSION = 1;

/**
 * A durable, restorable snapshot of one chat session — the *restorable core* of
 * the debug log (GENC-1351 §5.1/D1), captured losslessly and independently of
 * the diagnostic timeline. Written by a `SessionPersistenceProvider` and read
 * back on page-reload restore.
 *
 * @public
 */
export interface PersistedSession {
  /** Schema version — see {@link PERSISTED_SESSION_VERSION}. */
  version: number;
  /** ISO timestamp the snapshot was written. */
  savedAt: string;
  /** `window.location.host` at save time — restore refuses a cross-host snapshot. */
  host: string;
  /** The session this snapshot belongs to (the assistant's state key). */
  sessionKey: string;
  /** Full conversation transcript (`driver.getRawHistory()`) — the source of truth. */
  messages: ChatMessage[];
  /** Name of the agent active at save time, if any. */
  activeAgentName?: string;
  /** User's pinned agent (sticky picker choice); `null` = Auto. */
  pinnedAgentName: string | null;
  /** Agent that owned a guided flow at save time; `null` = none. */
  flowOwnerAgentName: string | null;
  /** Aggregated USD cost across the session. */
  sessionCostUsd: number;
  /** Provider-reported context-window usage on the last turn. */
  contextTokens?: number;
  /** Context-window limit for the active model. */
  contextLimit?: number;
  /** Active model id (e.g. `claude-sonnet-4-6`). */
  activeModel?: string;
  /** Name of the provider used on the last turn. */
  activeProviderName?: string;
  /**
   * Per-stateful-agent serialized state images (each from an agent's
   * `serialize()`), keyed by agent name. Handed back to the agent's
   * `init({ restored })` on restore. Typically holds at most the active
   * flow-owner — other stateful agents are disposed when switched away. The
   * value shape is the agent's own, hence `unknown`.
   */
  agentSnapshots: Record<string, unknown>;
  /**
   * Interrupted-flow marker (GENC-1351 §5.9). Present when the session was saved
   * while an agent declared its current state non-resumable (`resumable === false`).
   * The **conversation is still persisted and restored in full** — only the running
   * agent's flow-state is dropped (`agentSnapshots` empty, `flowOwnerAgentName`
   * null), so restore does not rehydrate the mid-flight flow. On restore the
   * assistant appends `message` as a normal assistant bubble and pre-fills the
   * composer with `prompt` so the user can re-run it.
   */
  interrupted?: {
    /** Shown as an assistant message on return. */
    message: string;
    /** The prompt that started the flow, pre-filled into the composer on return. */
    prompt?: string;
  };
}

/**
 * Everything needed to assemble a {@link PersistedSession} except the stamped
 * metadata (`version`, `savedAt`), which `createPersistedSession` fills.
 *
 * @internal
 */
export interface PersistedSessionInput {
  sessionKey: string;
  host: string;
  messages: ChatMessage[];
  activeAgentName?: string;
  pinnedAgentName: string | null;
  flowOwnerAgentName: string | null;
  sessionCostUsd: number;
  contextTokens?: number;
  contextLimit?: number;
  activeModel?: string;
  activeProviderName?: string;
  agentSnapshots?: Record<string, unknown>;
}

/**
 * Assemble a versioned {@link PersistedSession}, stamping `version` and
 * `savedAt`. Pure but for the timestamp (reads the host clock at call time).
 *
 * @internal
 */
export function createPersistedSession(input: PersistedSessionInput): PersistedSession {
  return {
    version: PERSISTED_SESSION_VERSION,
    savedAt: new Date().toISOString(),
    host: input.host,
    sessionKey: input.sessionKey,
    messages: input.messages,
    activeAgentName: input.activeAgentName,
    pinnedAgentName: input.pinnedAgentName,
    flowOwnerAgentName: input.flowOwnerAgentName,
    sessionCostUsd: input.sessionCostUsd,
    contextTokens: input.contextTokens,
    contextLimit: input.contextLimit,
    activeModel: input.activeModel,
    activeProviderName: input.activeProviderName,
    agentSnapshots: input.agentSnapshots ?? {},
  };
}

/**
 * Resolve the notice text + pre-fill prompt for an interrupted (non-resumable) flow
 * (GENC-1351 §5.9). Pure — the decision half of the persist gate, split out so it's
 * unit-testable independent of the save path:
 *   - `resumeMessage`: the agent's `notResumableMessage` when non-blank, else the
 *     passed default.
 *   - `resumePrompt`: the captured flow-activating prompt, falling back to the last
 *     user message in the transcript (covers a stateless agent that declines to
 *     persist without owning a flow); `undefined` when neither exists.
 *
 * @internal
 */
export function resolveNonResumableNotice(input: {
  notResumableMessage?: string;
  flowActivationPrompt?: string | null;
  messages: ReadonlyArray<ChatMessage>;
  defaultMessage: string;
}): { resumeMessage: string; resumePrompt?: string } {
  const custom = input.notResumableMessage;
  const resumeMessage = custom && custom.trim() ? custom : input.defaultMessage;
  const resumePrompt =
    input.flowActivationPrompt ?? input.messages[lastUserMessageIndex(input.messages)]?.content;
  return { resumeMessage, resumePrompt };
}

/**
 * Index of the last `user`-role message in `messages`, or `-1` when there is none.
 * The single backward scan behind "what did the user last say" — used to slice the
 * transcript to a clean boundary (`keepThroughLastUserMessage`) and to recover a
 * flow-activation prompt. Allocation-free (no reversed copy).
 *
 * @internal
 */
export function lastUserMessageIndex(messages: ReadonlyArray<ChatMessage>): number {
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    if (messages[i]?.role === 'user') return i;
  }
  return -1;
}

/**
 * Type guard: is `value` a {@link PersistedSession} we can safely restore?
 * Gates on matching schema version, same host, and a messages array — the
 * minimum needed to avoid feeding a stale or cross-app blob into restore.
 *
 * @internal
 */
export function isRestorableSession(
  value: unknown,
  opts: { host: string; version?: number },
): value is PersistedSession {
  if (!value || typeof value !== 'object') return false;
  const s = value as Partial<PersistedSession>;
  const expectedVersion = opts.version ?? PERSISTED_SESSION_VERSION;
  return (
    s.version === expectedVersion &&
    typeof s.host === 'string' &&
    s.host === opts.host &&
    Array.isArray(s.messages)
  );
}

/**
 * Project a {@link PersistedSession} onto the Redux `loadSession` payload (the
 * fields restored verbatim into the slice). Agent identity + machine snapshots
 * are applied by the host separately.
 *
 * @internal
 */
export function toLoadSessionPayload(s: PersistedSession): LoadSessionPayload {
  return {
    messages: s.messages,
    pinnedAgentName: s.pinnedAgentName ?? null,
    flowOwnerAgentName: s.flowOwnerAgentName ?? null,
    // `sessionCostUsd` is deliberately NOT projected: the slice re-derives cost and
    // token totals from `messages` on load. The snapshot field is still written (its
    // shape is public, and it is a useful cross-check in a saved blob), but it is no
    // longer authoritative — a snapshot saved mid-reset could hold a zeroed total
    // while its transcript still shows the spend.
    contextTokens: s.contextTokens,
    contextLimit: s.contextLimit,
    activeModel: s.activeModel,
    activeProviderName: s.activeProviderName,
  };
}

/**
 * Trim a restored transcript back to the last point a provider will accept.
 *
 * Autosave fires on every transcript change (GENC-1351 §5.3) so it can land
 * mid-turn — meaning a snapshot can end on an *incomplete* turn:
 *   • an assistant `tool_use` whose `tool_result` hasn't arrived (or only some
 *     of its calls are answered) — an orphaned call makes the next turn invalid;
 *   • an empty streaming/`thinking` placeholder assistant message;
 *   • a pending (unresolved) interaction — a mid-`requestInteraction` turn that
 *     can't be resumed (explicitly unsupported).
 *
 * Walks back from the end, dropping such trailing messages until the transcript
 * is balanced — every assistant tool call has its matching result — and ends on
 * a clean boundary. Returns the input unchanged (same reference) when already
 * clean. Turns a mid-turn reload into "recover to the last complete exchange"
 * rather than a hard drop.
 *
 * @internal
 */
export function trimToRestorableBoundary(messages: ChatMessage[]): ChatMessage[] {
  let end = messages.length;
  while (end > 0) {
    const slice = messages.slice(0, end);
    const last = slice[end - 1];
    // Trailing unresolved interaction — can't be resumed.
    if (last.interaction && !last.interaction.resolved) {
      end -= 1;
      continue;
    }
    // Trailing empty assistant placeholder (streaming/thinking stub with no
    // content, tool calls, or interaction yet).
    if (
      last.role === 'assistant' &&
      !last.content &&
      !last.toolCalls?.length &&
      !last.interaction
    ) {
      end -= 1;
      continue;
    }
    // Any assistant tool call without its matching result — orphaned `tool_use`.
    if (hasUnansweredToolCall(slice)) {
      end -= 1;
      continue;
    }
    break;
  }
  return end === messages.length ? messages : messages.slice(0, end);
}

/**
 * Keep the transcript through the **last user message** (inclusive), dropping any
 * trailing assistant/tool messages after it (GENC-1351 §5.9). Used by the freeze
 * fallback: when a flow goes non-resumable and there's no prior resumable snapshot
 * to freeze at, we keep the conversation + the prompt that triggered the flow but
 * discard the agent's in-flight work — so a reload shows the user's request, not the
 * half-done agent turns. Returns the input unchanged (same ref) when it already ends
 * on a user message, or when there is no user message at all (nothing to trim to).
 *
 * @internal
 */
export function keepThroughLastUserMessage(messages: ChatMessage[]): ChatMessage[] {
  const lastUser = lastUserMessageIndex(messages);
  if (lastUser === -1 || lastUser === messages.length - 1) return messages;
  return messages.slice(0, lastUser + 1);
}

/**
 * Names of agents the restored session holds machine/state snapshots for but
 * which are **not** in the currently-configured agent set — their flow cannot
 * resume. Common when the host's agent array is dynamic (agents gated by feature
 * flags, project state, or permissions; e.g. genesis-create). Callers should
 * surface these; the restore is otherwise non-fatal (the transcript and any
 * known agents still restore). Returns `[]` when `knownAgentNames` is empty —
 * the agent set isn't known yet, so we can't classify without false positives.
 *
 * @beta
 */
export function unknownAgentSnapshots(
  snapshot: PersistedSession,
  knownAgentNames: Iterable<string>,
): string[] {
  const known = new Set(knownAgentNames);
  if (known.size === 0) return [];
  return Object.keys(snapshot.agentSnapshots ?? {}).filter((name) => !known.has(name));
}

/**
 * Reconcile the per-agent `serialize()` images to persist on a session save,
 * guarding against a post-restore autosave wiping a mid-flow machine (GENC-1351).
 *
 * A restore reads a good snapshot, but the restore's own history change fires an
 * autosave **before** the pinned stateful agent re-activates — at which point the
 * live capture (`getActiveAgent()?.serialize()`) is empty even though a flow is
 * mid-stream. Writing that empty map would overwrite the stored machine snapshot,
 * resetting the flow to its initial step on the *next* reload. So when a flow is
 * owned (`flowOwnerName`) but its live image is missing, carry the last-known-good
 * image forward instead of dropping it.
 *
 * Pure: the caller owns the `lastKnownGood` cache and its lifecycle (seeded on
 * restore, reset on clear). This decides only what to persist now and what the
 * cache should become — advanced only when non-empty, so an empty capture can
 * never erase a good cache.
 *
 * @param live - Agent images captured live this save, keyed by agent name.
 * @param flowOwnerName - The agent owning the in-flight flow, or `null`.
 * @param lastKnownGood - The last non-empty images persisted or restored.
 * @returns `snapshots` to persist, and the `cache` to remember for next time.
 *
 * @internal
 */
export function reconcileAgentSnapshots(
  live: Record<string, unknown>,
  flowOwnerName: string | null,
  lastKnownGood: Record<string, unknown>,
): { snapshots: Record<string, unknown>; cache: Record<string, unknown> } {
  const snapshots = { ...live };
  if (flowOwnerName && snapshots[flowOwnerName] === undefined) {
    const carried = lastKnownGood[flowOwnerName];
    if (carried !== undefined) snapshots[flowOwnerName] = carried;
  }
  const cache = Object.keys(snapshots).length ? snapshots : lastKnownGood;
  return { snapshots, cache };
}

/** True if any assistant tool call in `messages` lacks a matching tool result. */
function hasUnansweredToolCall(messages: ChatMessage[]): boolean {
  const answered = new Set<string>();
  for (const m of messages) {
    if (m.toolResult) answered.add(m.toolResult.toolCallId);
  }
  for (const m of messages) {
    if (m.toolCalls) {
      for (const tc of m.toolCalls) {
        if (!answered.has(tc.id)) return true;
      }
    }
  }
  return false;
}
