/**
 * Pure chat state machine. Zero React. Zero I/O.
 *
 * Invariants:
 *  - At most one message has `isStreaming === true` at any time. It is always
 *    the last message in the array.
 *  - Messages are immutable; updates produce new objects.
 *  - `version` bumps on edit so memo keys invalidate.
 */

import type { ChatAttachment, ChatMessage, ChatToolCall } from '../types';

export interface ChatState {
  sessionId: string | null;
  messages: ChatMessage[];
  /** Initial history load in flight. */
  isLoading: boolean;
  /**
   * True once the initial history load has settled (done OR errored). False
   * from mount until then. Lets the UI distinguish "history still loading"
   * from "genuinely empty conversation": render a spinner while
   * `!historyLoaded`, the empty-state only once `historyLoaded` is true and
   * `messages` is empty. `isLoading` alone can't carry this — it is `false`
   * on the very first synchronous render (before the bootstrap effect runs),
   * which would flash the empty-state for a session that does have history.
   */
  historyLoaded: boolean;
  /** Assistant is generating a reply. */
  isStreaming: boolean;
  /** Older history page in flight. */
  isLoadingMore: boolean;
  hasMore: boolean;
  oldestCursor: string | null;
  error: string | null;
  /**
   * The message id of the turn the current `error` belongs to (the assistant
   * placeholder that errored), or null. Lets the reducer keep an errored turn's
   * banner visible until a *strictly newer* turn begins — the errored turn's own
   * terminal (STREAM_DONE), its trailing chunks, and the user typing the next
   * message must NOT clear it; only a new turn's STREAM_START / first STREAM_CHunk
   * (landing on a different id) does. Cleared alongside `error`.
   */
  errorTurnId: string | null;
}

/**
 * Where an injected message lands: `append`/`prepend`, or `{ afterId }` —
 * insert right after the message with that id (falls back to append when the
 * anchor isn't rendered). The anchored form lets a history reconcile place
 * server rows in TRANSCRIPT order instead of tacking them onto the end after
 * the client's own newer live turn (the "scheduled message appears last" bug).
 */
export type MessageInjectPosition = 'append' | 'prepend' | { afterId: string };

export const initialState: ChatState = {
  sessionId: null,
  messages: [],
  isLoading: false,
  historyLoaded: false,
  isStreaming: false,
  isLoadingMore: false,
  hasMore: true,
  oldestCursor: null,
  error: null,
  errorTurnId: null,
};

export type ChatAction =
  | {
      type: 'SESSION_SET';
      sessionId: string;
      messages?: ChatMessage[];
      hasMore?: boolean;
      cursor?: string | null;
    }
  | { type: 'HISTORY_LOAD_START' }
  | {
      type: 'HISTORY_LOAD_DONE';
      messages: ChatMessage[];
      hasMore: boolean;
      cursor: string | null;
      /**
       * The session this page belongs to. When present and different from
       * `state.sessionId`, the commit is DROPPED — a slow history read for a
       * session the user already switched away from must not clobber the
       * current transcript. Omitted by legacy dispatch sites (applies
       * unconditionally, as before).
       */
      sessionId?: string;
    }
  | {
      type: 'SESSION_SWITCH';
      sessionId: string;
      /** Warm transcript for the NEW session (host cache). When present the
       *  feed swaps atomically (`historyLoaded` true, no spinner); when
       *  absent the feed enters the loading state until HISTORY_LOAD_DONE. */
      messages?: ChatMessage[];
    }
  | { type: 'HISTORY_MORE_START' }
  | {
      type: 'HISTORY_MORE_DONE';
      messages: ChatMessage[];
      hasMore: boolean;
      cursor: string | null;
    }
  | { type: 'MESSAGE_USER_ADD'; message: ChatMessage }
  | { type: 'STREAM_START'; id: string; createdAt?: number }
  | { type: 'STREAM_CHUNK'; delta: string }
  | { type: 'STREAM_TOOL_ACTIVITY'; tool: string }
  | { type: 'TOOL_CALL_START'; messageId: string; toolCall: ChatToolCall }
  | { type: 'TOOL_CALL_DELTA'; messageId: string; toolId: string; delta: string }
  | {
      type: 'TOOL_CALL_END';
      messageId: string;
      toolId: string;
      output: unknown;
      status: 'success' | 'error';
    }
  | {
      type: 'STREAM_DONE';
      id: string;
      /**
       * Generic finalize merge (content/blocks/sources/tokens/attachments/
       * metadata). The single slot the terminal event carries — applied as
       * a `Partial<ChatMessage>` merge. No-op when absent.
       */
      patch?: Partial<ChatMessage>;
    }
  | { type: 'STREAM_CANCELLED'; id: string; partialText: string; label?: string }
  | { type: 'STREAM_ERROR'; id?: string; message: string }
  | { type: 'STREAM_CANCEL_PLACEHOLDER'; id: string }
  | { type: 'STREAM_RESUME_EXISTING' }
  | { type: 'MESSAGE_EDIT'; id: string; content: string }
  | { type: 'MESSAGE_DELETE'; id: string }
  | { type: 'MESSAGE_INJECT'; message: ChatMessage; position?: MessageInjectPosition }
  | { type: 'MESSAGE_PATCH'; id: string; patch: Partial<ChatMessage> }
  | { type: 'MESSAGES_CLEAR' }
  | { type: 'ERROR_SET'; error: string | null }
  | {
      type: 'ATTACHMENT_PROGRESS';
      messageId: string;
      attachmentId: string;
      progress?: number;
      status?: ChatAttachment['status'];
    };

/**
 * Where a MESSAGE_INJECT lands: `'prepend'` → 0, `{ afterId }` → right after
 * the anchor (append when the anchor isn't rendered — deleted or never
 * loaded), `'append'`/absent → the end.
 */
function insertIndexFor(
  position: MessageInjectPosition | undefined,
  messages: ChatMessage[],
): number {
  if (position === 'prepend') return 0;
  if (typeof position === 'object') {
    const idx = messages.findIndex((m) => m.id === position.afterId);
    return idx === -1 ? messages.length : idx + 1;
  }
  return messages.length;
}

function updateLastStreaming(
  messages: ChatMessage[],
  patch: (m: ChatMessage) => ChatMessage,
): ChatMessage[] {
  const idx = findLastStreamingIndex(messages);
  if (idx === -1) return messages;
  const next = messages.slice();
  next[idx] = patch(messages[idx]);
  return next;
}

function findLastStreamingIndex(messages: ChatMessage[]): number {
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    if (messages[i].isStreaming) return i;
  }
  return -1;
}

function patchMessageById(
  messages: ChatMessage[],
  id: string,
  patch: (m: ChatMessage) => ChatMessage,
): ChatMessage[] {
  const idx = messages.findIndex((m) => m.id === id);
  if (idx === -1) return messages;
  const next = messages.slice();
  next[idx] = patch(messages[idx]);
  return next;
}

export function reducer(state: ChatState, action: ChatAction): ChatState {
  switch (action.type) {
    case 'SESSION_SET':
      return {
        ...state,
        sessionId: action.sessionId,
        messages: action.messages ?? state.messages,
        hasMore: action.hasMore ?? state.hasMore,
        oldestCursor: action.cursor ?? state.oldestCursor,
        // When the session carries its messages (the autoCreateSession /
        // newSession path commits them here), the initial load is settled.
        // A bare `SESSION_SET` (resume path sets the id, then awaits
        // loadHistory → HISTORY_LOAD_DONE) leaves `historyLoaded` untouched.
        historyLoaded: action.messages !== undefined ? true : state.historyLoaded,
        error: null,
        errorTurnId: null,
      };

    case 'HISTORY_LOAD_START':
      return { ...state, isLoading: true, error: null };

    case 'HISTORY_LOAD_DONE':
      // Stale-page guard: a read that raced a session switch commits nothing.
      if (action.sessionId !== undefined && action.sessionId !== state.sessionId) {
        return state;
      }
      return {
        ...state,
        isLoading: false,
        historyLoaded: true,
        messages: action.messages,
        hasMore: action.hasMore,
        oldestCursor: action.cursor,
      };

    case 'SESSION_SWITCH':
      // Atomic transition to another session — the in-place alternative to the
      // legacy `key={sessionId}` full remount. One dispatch swaps identity and
      // transcript together, so the feed never renders a half-state (old
      // messages under a new id, or a blank frame between "clear" and "set").
      // Stream/tool state belongs to the OLD session and resets wholesale.
      return {
        ...initialState,
        sessionId: action.sessionId,
        messages: action.messages ?? [],
        historyLoaded: action.messages !== undefined,
        isLoading: action.messages === undefined,
      };

    case 'HISTORY_MORE_START':
      return { ...state, isLoadingMore: true };

    case 'HISTORY_MORE_DONE':
      return {
        ...state,
        isLoadingMore: false,
        // Older messages prepend to the top.
        messages: [...action.messages, ...state.messages],
        hasMore: action.hasMore,
        oldestCursor: action.cursor,
      };

    case 'MESSAGE_USER_ADD':
      // Do NOT clear `error` here: the prior turn's banner must stay readable
      // while the user types + sends the next message. It is retracted only
      // once the next answer actually begins (STREAM_START / first STREAM_CHUNK
      // of the new turn), keyed off `errorTurnId`.
      return {
        ...state,
        messages: [...state.messages, action.message],
      };

    case 'STREAM_START': {
      if (state.isStreaming) {
        if (typeof console !== 'undefined') {
          // eslint-disable-next-line no-console
          console.warn('[chat] STREAM_START while already streaming, ignoring');
        }
        return state;
      }
      const placeholder: ChatMessage = {
        id: action.id,
        role: 'assistant',
        content: '',
        createdAt: action.createdAt ?? Date.now(),
        isStreaming: true,
      };
      // A genuinely new answer turn is opening. If a stale error from a prior
      // turn is still showing, retract it now so the banner can't sit above the
      // new live answer. Only clear when this is a STRICTLY newer turn (a
      // different id than the one that errored) — a re-emitted STREAM_START for
      // the errored turn itself must not wipe its own still-unread banner.
      const clearStaleError = state.error && state.errorTurnId !== action.id;
      return {
        ...state,
        isStreaming: true,
        messages: [...state.messages, placeholder],
        ...(clearStaleError ? { error: null, errorTurnId: null } : {}),
      };
    }

    case 'STREAM_CHUNK': {
      const idx = findLastStreamingIndex(state.messages);
      const messages = updateLastStreaming(state.messages, (m) => ({
        ...m,
        content: m.content + action.delta,
      }));
      // Content is streaming in. Retract a stale error only when this chunk
      // lands on a REAL assistant message whose id differs from `errorTurnId`
      // — i.e. a strictly newer turn. A trailing chunk of the errored turn
      // itself (same id), or a chunk that lands on nothing (idx === -1), must
      // NOT wipe the still-unread banner.
      const landingId = idx === -1 ? null : state.messages[idx].id;
      const clearStaleError =
        state.error !== null && landingId !== null && landingId !== state.errorTurnId;
      return clearStaleError
        ? { ...state, error: null, errorTurnId: null, messages }
        : { ...state, messages };
    }

    case 'STREAM_TOOL_ACTIVITY': {
      const messages = updateLastStreaming(state.messages, (m) => ({
        ...m,
        toolActivity: action.tool,
      }));
      return { ...state, messages };
    }

    case 'TOOL_CALL_START': {
      const messages = patchMessageById(state.messages, action.messageId, (m) => ({
        ...m,
        toolCalls: [...(m.toolCalls ?? []), action.toolCall],
      }));
      return { ...state, messages };
    }

    case 'TOOL_CALL_DELTA': {
      const messages = patchMessageById(state.messages, action.messageId, (m) => {
        if (!m.toolCalls) return m;
        return {
          ...m,
          toolCalls: m.toolCalls.map((tc) =>
            tc.id === action.toolId
              ? { ...tc, streamingText: (tc.streamingText ?? '') + action.delta }
              : tc,
          ),
        };
      });
      return { ...state, messages };
    }

    case 'TOOL_CALL_END': {
      const messages = patchMessageById(state.messages, action.messageId, (m) => {
        if (!m.toolCalls) return m;
        return {
          ...m,
          toolCalls: m.toolCalls.map((tc) =>
            tc.id === action.toolId
              ? {
                  ...tc,
                  output: action.output,
                  status: action.status,
                  streamingText: undefined,
                  endedAt: Date.now(),
                }
              : tc,
          ),
        };
      });
      return { ...state, messages };
    }

    case 'STREAM_DONE': {
      const patched = patchMessageById(state.messages, action.id, (m) => ({
        ...m,
        // Generic finalize merge (content/blocks/sources/tokens/attachments/
        // metadata). No-op when `patch` is absent — `message_end` is then a
        // pure terminal that only clears `isStreaming`.
        ...(action.patch ?? {}),
        isStreaming: false,
      }));
      // Drop an assistant message that ended up completely empty (no text, no
      // tool calls). This happens in the HITL approval flow: the first stream
      // produces only tool calls + approval_required and no text body, so the
      // placeholder should not leave a blank bubble in the chat.
      const msg = patched.find((m) => m.id === action.id);
      const messages =
        msg && !msg.content && !(msg.toolCalls?.length)
          ? patched.filter((m) => m.id !== action.id)
          : patched;
      // Do NOT clear `error` here. STREAM_ERROR + a terminal message_end can
      // arrive for the SAME turn (e.g. router 404), so wiping `error` on the
      // terminal would erase the current turn's own banner before the user can
      // read it. A stale error from a prior turn is instead retracted when the
      // NEXT turn begins (STREAM_START / first STREAM_CHUNK), keyed off
      // `errorTurnId`.
      return { ...state, isStreaming: false, messages };
    }

    case 'STREAM_CANCELLED': {
      const suffix = action.label ?? '[cancelled]';
      const messages = patchMessageById(state.messages, action.id, (m) => ({
        ...m,
        isStreaming: false,
        content: action.partialText + (action.partialText ? `\n\n_${suffix}_` : `_${suffix}_`),
      }));
      return { ...state, isStreaming: false, messages };
    }

    case 'STREAM_ERROR': {
      const messages = action.id
        ? patchMessageById(state.messages, action.id, (m) => ({
            ...m,
            isStreaming: false,
            isError: true,
          }))
        : state.messages;
      // Tag the error with the turn it belongs to so its banner survives this
      // turn's own terminal / trailing chunks / the next user message, and is
      // retracted only when a strictly newer turn begins. When the error has no
      // id, fall back to the last streaming placeholder's id (that is the turn
      // that just failed) so STREAM_CHUNK / STREAM_START can still key off it.
      const erroredIdx = findLastStreamingIndex(state.messages);
      const errorTurnId =
        action.id ?? (erroredIdx === -1 ? null : state.messages[erroredIdx].id);
      return { ...state, isStreaming: false, error: action.message, errorTurnId, messages };
    }

    case 'STREAM_CANCEL_PLACEHOLDER':
      // Remove the freshly-created empty assistant placeholder (resume path).
      return {
        ...state,
        isStreaming: false,
        messages: state.messages.filter((m) => m.id !== action.id),
      };

    case 'STREAM_RESUME_EXISTING': {
      // Mark the last assistant message as streaming so resume chunks append to it.
      const lastIdx = (() => {
        for (let i = state.messages.length - 1; i >= 0; i--) {
          if (state.messages[i].role === 'assistant') return i;
        }
        return -1;
      })();
      if (lastIdx === -1) return { ...state, isStreaming: true };
      const msgs = state.messages.slice();
      msgs[lastIdx] = { ...msgs[lastIdx], isStreaming: true };
      return { ...state, isStreaming: true, messages: msgs };
    }

    case 'MESSAGE_EDIT': {
      const messages = patchMessageById(state.messages, action.id, (m) => ({
        ...m,
        content: action.content,
        version: (m.version ?? 0) + 1,
      }));
      return { ...state, messages };
    }

    case 'MESSAGE_DELETE':
      return {
        ...state,
        messages: state.messages.filter((m) => m.id !== action.id),
      };

    case 'MESSAGE_INJECT': {
      // De-dupe by id: if a message with this id already exists, merge
      // instead of duplicating. Avoids double-render when external code
      // re-emits the same payload (Centrifugo replay, SWR retry, …).
      const existingIdx = state.messages.findIndex((m) => m.id === action.message.id);
      if (existingIdx >= 0) {
        const messages = state.messages.slice();
        const prev = messages[existingIdx]!;
        messages[existingIdx] = {
          ...prev,
          ...action.message,
          version: (prev.version ?? 0) + 1,
        };
        return { ...state, messages };
      }
      // ONE insert path for every position: resolve the index, splice.
      const at = insertIndexFor(action.position, state.messages);
      const next = [
        ...state.messages.slice(0, at),
        action.message,
        ...state.messages.slice(at),
      ];
      return { ...state, messages: next };
    }

    case 'MESSAGE_PATCH': {
      const messages = patchMessageById(state.messages, action.id, (m) => ({
        ...m,
        ...action.patch,
        version: (m.version ?? 0) + 1,
      }));
      return { ...state, messages };
    }

    case 'MESSAGES_CLEAR':
      return {
        ...state,
        messages: [],
        isStreaming: false,
        error: null,
        errorTurnId: null,
      };

    case 'ERROR_SET':
      // An error settles the initial load — a failed bootstrap must not leave
      // a stuck spinner; clear `isLoading` and mark the load resolved so the
      // host can fall through to its error/empty surface.
      // Bootstrap/host-level error — not tied to a stream turn, so it has no
      // turn id. Reset any stale tag so a later STREAM_START/CHUNK won't fail to
      // clear it (or clear it prematurely).
      return {
        ...state,
        error: action.error,
        errorTurnId: null,
        isLoading: false,
        historyLoaded: true,
      };

    case 'ATTACHMENT_PROGRESS': {
      const messages = patchMessageById(state.messages, action.messageId, (m) => {
        if (!m.attachments) return m;
        return {
          ...m,
          attachments: m.attachments.map((a) =>
            a.id === action.attachmentId
              ? {
                  ...a,
                  progress: action.progress ?? a.progress,
                  status: action.status ?? a.status,
                }
              : a,
          ),
        };
      });
      return { ...state, messages };
    }

    default: {
      // Exhaustiveness check.
      const _exhaustive: never = action;
      void _exhaustive;
      return state;
    }
  }
}
