import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUserVisibleModels } from 'state/useModels';
import { useChat, type UseChatType } from '../../../hooks/useChat';
import { useAttachments } from '../useAttachments';
import { useSlashCommands } from '../useSlashCommands';
import { usePromptHistory } from './usePromptHistory';

/** A prompt written mid-turn, held with the attachments it was composed with. */
export type QueuedPrompt = {
  id: string;
  /** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */
  raw: string;
  prefix: string;
  ids: string[];
  images: { filename: string; dataUrl: string }[];
};

type UseEmbeddableChatParams = {
  sessionId?: string;
  initialModel?: string | null;
  initialMessage?: {
    text: string;
    attachmentIds?: string[];
    images?: { filename: string; dataUrl: string }[];
    cwd?: { root?: string; path: string };
  };
  defaultInput?: string;
  promptPrefix?: string;
  cwd?: { root?: string; path: string };
  replaceUrl?: boolean;
  autoSend?: boolean;
  chat?: UseChatType;
  context?: string;
  contextId?: string;
};

export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
  const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;

  // Up/Down in the composer walk the last few prompts, shell-style. Per tab and shared by every chat in
  // it — see usePromptHistory.
  const history = usePromptHistory();

  const internalChat = useChat(params.sessionId, params.initialModel, {
    replaceUrl: params.replaceUrl ?? false,
    context: params.context,
    contextId: params.contextId,
  });
  const chat = externalChat ?? internalChat;

  const {
    messages,
    historicalCount,
    streamingText,
    isConnected,
    isGenerating,
    sessionId,
    model,
    selectedModel,
    hasStarted,
    thinkingLevel,
    setSelectedModel,
    setThinkingLevel,
    sendPrompt,
    stopGeneration: stopTurn,
    loadOlder,
    hasMoreOlder,
    isLoadingOlder,
  } = chat;

  const availableModels = useUserVisibleModels();
  const attachmentManager = useAttachments({ sessionId });
  const slashCommands = useSlashCommands({ sessionId });

  // Detect when agent finishes a message (isGenerating: true → false)
  const wasGeneratingRef = useRef(false);
  useEffect(() => {
    if (isGenerating) {
      wasGeneratingRef.current = true;
    } else if (wasGeneratingRef.current) {
      wasGeneratingRef.current = false;
      onMessageComplete?.();
      // The turn that was blocking the queue has ended, so the next prompt goes now. Each delivery starts
      // a turn, whose completion runs this again — that is the whole drain loop, one prompt per turn.
      void drainQueueRef.current();
    }
  }, [isGenerating]);

  // Prompts written while a turn was running, waiting their turn. The ref is what the drain reads — it
  // runs from an effect and from async continuations, both of which would otherwise see the array as it
  // was a render ago — and the state is only so the tray can show them.
  const queuedRef = useRef<QueuedPrompt[]>([]);
  const [queued, setQueued] = useState<QueuedPrompt[]>([]);
  const setQueue = (next: QueuedPrompt[]) => {
    queuedRef.current = next;
    setQueued(next);
  };
  const drainQueueRef = useRef<() => Promise<void>>(async () => {});

  const [input, setInput] = useState(defaultInput);
  const [showJumpToBottom, setShowJumpToBottom] = useState(false);
  const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const scrollViewportRef = useRef<HTMLDivElement | null>(null);
  const bottomRef = useRef<HTMLDivElement | null>(null);
  const userScrolledRef = useRef(false);
  const didInitialScrollRef = useRef(false);
  // When the reader last touched the scroller. Scroll *position* cannot tell a deliberate scroll-up from
  // the list growing under an animation or a row measuring taller than its estimate, and reading it as
  // intent is what used to switch auto-scroll off mid-turn and leave it off.
  const gestureAtRef = useRef(0);
  // Latest values for the (once-attached) scroll listener, without re-subscribing on every page load.
  const loadOlderRef = useRef(loadOlder);
  loadOlderRef.current = loadOlder;
  const hasMoreOlderRef = useRef(hasMoreOlder);
  hasMoreOlderRef.current = hasMoreOlder;
  const isLoadingOlderRef = useRef(isLoadingOlder);
  isLoadingOlderRef.current = isLoadingOlder;

  const resetComposerHeight = () => {
    if (textareaRef.current) textareaRef.current.style.height = 'auto';
  };

  /**
   * Page older messages in when the loaded ones do not fill the viewport.
   *
   * Scroll-up paging is driven by a scroll listener, and a scroll listener only fires on a list that
   * actually scrolls. That was always true and never mattered, because the turn you were looking at
   * rendered in full and was tall enough on its own. Folding on reload broke it: a window of twenty
   * messages can be one turn with eighteen tool calls, which collapses to three short rows — no overflow,
   * no scroll event, and paging simply never starts. The symptom is a transcript you cannot scroll back
   * through at all, which reads as lost history rather than as a stalled fetch.
   *
   * Terminates: each pass either fills the viewport or exhausts the transcript, and `hasMoreOlder` goes
   * false at the top. It runs after every prepend, so several small pages in a row are fine.
   */
  useEffect(() => {
    const viewport = scrollViewportRef.current;
    if (!viewport || !didInitialScrollRef.current) return;
    if (!hasMoreOlder || isLoadingOlder) return;
    // A pixel of slack: a list exactly as tall as its viewport cannot be scrolled either.
    if (viewport.scrollHeight > viewport.clientHeight + 1) return;
    void loadOlder();
  }, [messages, hasMoreOlder, isLoadingOlder, loadOlder]);

  /**
   * Hand a batch to the agent as ONE message. Returns whether it actually started a turn — a slash
   * command the client handled itself (`/clear`, `/model`) never reaches the agent, so the drain below
   * must keep going rather than wait for a completion that will not come.
   *
   * Several queued prompts become one message rather than one turn each. They are almost always one
   * thought arriving in pieces — a correction, then the thing you forgot — and answering them separately
   * makes the agent reply to the first without knowing the second exists, then re-answer with it. Joined
   * with a blank line between, in the order they were written, which is how they read.
   *
   * Attachments are captured when each prompt is composed, not here, so a queued message keeps the files
   * that were on it; the batch carries all of them together.
   */
  const deliverBatch = async (items: QueuedPrompt[]): Promise<boolean> => {
    const text = items
      .map((item) => item.raw.trim())
      .filter(Boolean)
      .join('\n\n');
    if (!text) return false;

    // Only a lone prompt can be a slash command. Joined to anything else it is text that happens to start
    // with a slash, and running it as a command would silently drop everything queued behind it.
    if (items.length === 1 && text.startsWith('/')) {
      const result = await slashCommands.execute(text);
      if (result.handled) {
        setCommandFeedback(result.feedback);
        return false;
      }
    }
    setCommandFeedback(null);

    const prefix = items.map((item) => item.prefix).join('');
    const ids = items.flatMap((item) => item.ids);
    const images = items.flatMap((item) => item.images);

    let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
    if (prefix) prompt = `${prefix}${prompt}`;

    // Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory
    // system prompt each turn, so it needs the cwd every time. It stays constant for a session.
    sendPrompt(
      prompt,
      !sessionId && ids.length > 0 ? ids : undefined,
      images.length > 0 ? images : undefined,
      cwd,
      undefined,
      thinkingLevel,
      promptPrefix ? text : undefined,
    );
    userScrolledRef.current = false;
    return true;
  };

  // Takes the whole queue at once, so the loop runs twice at most: again only if the batch turned out to
  // be a handled slash command (no turn started) and something was queued while it ran.
  const drainQueue = async () => {
    while (queuedRef.current.length > 0) {
      const batch = queuedRef.current;
      setQueue([]);
      if (await deliverBatch(batch)) return;
    }
  };
  // The completion effect is declared above this, and must call the current one rather than the closure
  // it was created with — `queuedRef` would be right but `slashCommands`/`thinkingLevel` would be stale.
  drainQueueRef.current = drainQueue;

  /**
   * Deliver the queue INTO the running turn instead of waiting for it to end.
   *
   * The agent's input is a streaming iterable, and a message pushed onto it mid-turn is picked up at the
   * next step boundary — the running turn reads it and carries on with the added context. That was worth
   * verifying rather than assuming: a probe pushed a sentinel four seconds into a turn busy with `sleep`
   * calls, and the turn's own final answer quoted the late instruction and obeyed it. One turn, one
   * result.
   *
   * So this needs no interrupt and no "carry on, but…" preamble wrapped around your words. Nothing is
   * abandoned mid-flight, and the agent is never told to stop something it was part-way through.
   *
   * The path is the same `deliverBatch` the drain uses; officer pushes onto the live session rather than
   * opening a new one whenever `_claudeKill` is set, which is exactly the injection.
   */
  const sendQueuedNow = async () => {
    if (queuedRef.current.length === 0) return;
    const batch = queuedRef.current;
    setQueue([]);
    await deliverBatch(batch);
  };

  const handleSend = async () => {
    const text = input.trim();
    if (!text) return;

    // A slash command typed while nothing is running still executes immediately, and deliberately before
    // attachments are touched: `/clear` should not eat the files sitting in the tray.
    if (!isGenerating && text.startsWith('/')) {
      const result = await slashCommands.execute(text);
      if (result.handled) {
        setCommandFeedback(result.feedback);
        history.record(input); // a slash command is a prompt you sent — recallable like any other
        setInput('');
        resetComposerHeight();
        return;
      }
    }

    const { prefix, ids, images } = attachmentManager.processAttachments();
    const item: QueuedPrompt = { id: `${Date.now()}-${queuedRef.current.length}`, raw: input, prefix, ids, images };

    // The composer empties either way. Queuing that did not clear the box would read as "it didn't take",
    // and you would send it twice.
    attachmentManager.clearAttachments();
    history.record(input); // verbatim, not the trimmed prompt or the attachment-prefixed one
    setInput('');
    resetComposerHeight();

    if (isGenerating) {
      setQueue([...queuedRef.current, item]);
      return;
    }
    await deliverBatch([item]);
  };

  /**
   * Stop the turn, and leave the composer alone.
   *
   * This used to hand the sent prompt back, on the theory — carried over from Claude Code's terminal —
   * that interrupting means the agent never read it, so you should get it back to say differently. That
   * is not what happens here. The prompt is delivered and read before Escape can land; the transcript
   * keeps it and the agent answers it on the next turn. Restoring it therefore refilled the composer
   * with something already sent, and sending it again sent it twice.
   *
   * Escape means one of two things — "stop, I'll say it differently" or just "stop" — and neither wants
   * the old text back. Focus returns to the composer, which serves both.
   */
  const stopGeneration = () => {
    stopTurn();
    // Stop means stop, including whatever was lined up behind this turn. Ending a turn is exactly the
    // signal the drain waits for, so leaving the queue alone would have fired the next prompt the instant
    // you pressed the button meant to halt things. Nothing is really lost — a queued prompt was recorded
    // in the prompt history when it was written, so Up brings it straight back.
    setQueue([]);
    requestAnimationFrame(() => textareaRef.current?.focus());
  };

  // Escape is the stop button, as it is in Claude Code itself. Bound to the chat's own subtree rather
  // than the document: two chat panels can be generating at once, and a document listener in each would
  // make one Escape stop both.
  const handleEscape = (ev: KeyboardEvent<HTMLElement>) => {
    if (ev.key !== 'Escape' || !isGenerating) return;
    ev.preventDefault();
    stopGeneration();
  };

  /**
   * Recall a previous prompt, the way a shell does.
   *
   * Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft
   * the arrows still move the caret — there is a line above to go to, and taking the key would strand the
   * cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant.
   * `step` returning null (empty history, or already at the oldest) leaves the key alone too.
   */
  const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
    const field = ev.currentTarget;
    const { selectionStart, selectionEnd, value } = field;
    if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
    const onEdge =
      direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
    if (!onEdge) return;

    const next = history.step(direction, value);
    if (next === null) return;

    ev.preventDefault();
    setInput(next);
    // Caret to the end and the box resized, both after the value lands — a recalled prompt is usually
    // taller than the one line the composer resets to, and a caret left at 0 would put the next
    // keystroke in front of the text you just recalled.
    requestAnimationFrame(() => {
      const el = textareaRef.current;
      if (!el) return;
      el.setSelectionRange(next.length, next.length);
      el.style.height = 'auto';
      el.style.height = `${el.scrollHeight}px`;
    });
  };

  const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
    if (ev.key === 'Escape') {
      // Handled here so a standalone InputArea still stops on Escape; stopped from bubbling so the
      // container's copy of the same handler doesn't fire a second stop for one keypress.
      ev.stopPropagation();
      handleEscape(ev);
      return;
    }
    if (ev.key === 'ArrowUp') return recallPrompt(ev, -1);
    if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
    if (ev.key === 'Enter' && !ev.shiftKey) {
      ev.preventDefault();
      // Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke
      // was free: `handleSend` has always returned immediately on empty input, so nothing is displaced.
      if (!input.trim() && queuedRef.current.length > 0) {
        void sendQueuedNow();
        return;
      }
      handleSend();
    }
  };

  const jumpToBottom = () => {
    userScrolledRef.current = false;
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  const appendToInput = (text: string) => {
    setInput((prev) => prev + (prev.length > 0 ? ' ' : '') + text);
  };

  // Auto-resize textarea
  const resizeTextarea = () => {
    const textarea = textareaRef.current;
    if (!textarea) return;
    textarea.style.height = 'auto';
    textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
  };

  useEffect(resizeTextarea, [input]);

  // Re-measure when textarea width changes (e.g. panel animation)
  useEffect(() => {
    const textarea = textareaRef.current;
    if (!textarea) return;
    let rafId = 0;
    let prevWidth = textarea.clientWidth;
    const observer = new ResizeObserver(() => {
      const width = textarea.clientWidth;
      if (width === prevWidth) return;
      prevWidth = width;
      cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(resizeTextarea);
    });
    observer.observe(textarea);
    return () => {
      observer.disconnect();
      cancelAnimationFrame(rafId);
    };
  }, []);

  /**
   * Pin to the bottom, unless the reader has deliberately scrolled away.
   *
   * Instant, never smoothed. A smooth scroll takes hundreds of milliseconds during which the view is *not*
   * at the bottom, and a turn producing output interrupts it long before it arrives — so the chase never
   * finished, and every frame of it read as the reader having scrolled up. Instant lands in one event and
   * cannot be outrun by the content it is chasing.
   */
  const stickToBottom = useCallback(() => {
    if (userScrolledRef.current) return;
    bottomRef.current?.scrollIntoView({ behavior: 'auto' });
  }, []);

  // Two frames on the first pass, so the jump repeats once the tail window has measured itself. Prepending
  // older messages doesn't fire this — the reader has scrolled up by then and `stickToBottom` declines.
  useEffect(() => {
    stickToBottom();
    if (didInitialScrollRef.current) return;
    didInitialScrollRef.current = true;
    requestAnimationFrame(stickToBottom);
  }, [messages, streamingText, stickToBottom]);

  // Detect user scrolling up; near the top, lazy-load the next older window (reverse infinite scroll).
  useEffect(() => {
    const viewport = scrollViewportRef.current;
    if (!viewport) return;

    // Anything that can move a scroller by hand: the wheel, a drag on the bar, a finger, the keyboard.
    // Momentum after a flick needs no window of its own — the gesture itself has already carried the view
    // off the bottom and set the flag, and only arriving back at the bottom clears it.
    const GESTURE_MS = 700;
    const markGesture = () => {
      gestureAtRef.current = performance.now();
    };

    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = viewport;
      const atBottom = scrollHeight - scrollTop - clientHeight < 60;
      // Being at the bottom is unambiguous, so it always re-arms. Leaving the bottom only counts when the
      // reader did it: otherwise a row measuring taller than its 150px estimate, or a fold shrinking, reads
      // as "they want to stay up here" and auto-scroll never comes back on.
      if (atBottom) userScrolledRef.current = false;
      else if (performance.now() - gestureAtRef.current < GESTURE_MS) userScrolledRef.current = true;
      setShowJumpToBottom(!atBottom);
      // Only after the initial bottom-anchor has run, so the mount's scrollTop=0 doesn't trigger a fetch.
      if (didInitialScrollRef.current && hasMoreOlderRef.current && !isLoadingOlderRef.current && scrollTop < 200) {
        loadOlderRef.current();
      }
    };

    viewport.addEventListener('scroll', handleScroll);
    viewport.addEventListener('wheel', markGesture, { passive: true });
    viewport.addEventListener('touchmove', markGesture, { passive: true });
    viewport.addEventListener('mousedown', markGesture);
    viewport.addEventListener('keydown', markGesture);
    return () => {
      viewport.removeEventListener('scroll', handleScroll);
      viewport.removeEventListener('wheel', markGesture);
      viewport.removeEventListener('touchmove', markGesture);
      viewport.removeEventListener('mousedown', markGesture);
      viewport.removeEventListener('keydown', markGesture);
    };
  }, []);

  // Focus textarea on mount
  useEffect(() => {
    textareaRef.current?.focus();
  }, []);

  // Auto-send initial message
  const initialSentRef = useRef(false);
  useEffect(() => {
    if (initialMessage && isConnected && !initialSentRef.current) {
      initialSentRef.current = true;
      if (params.initialModel) setSelectedModel(params.initialModel);
      sendPrompt(
        initialMessage.text,
        initialMessage.attachmentIds,
        initialMessage.images,
        initialMessage.cwd,
        undefined,
      );
    }
  }, [initialMessage, isConnected]);

  // Auto-send when autoSend is enabled
  const autoSentRef = useRef(false);
  useEffect(() => {
    if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
      autoSentRef.current = true;
      handleSend();
    }
  }, [autoSend, isConnected, messages.length, input]);

  return {
    messages,
    historicalCount,
    streamingText,
    isConnected,
    isGenerating,
    sessionId,
    model,
    selectedModel,
    hasStarted,
    thinkingLevel,
    setSelectedModel,
    setThinkingLevel,
    sendPrompt,
    stopGeneration,
    input,
    setInput,
    handleSend,
    handleKeyDown,
    handleEscape,
    queued,
    sendQueuedNow,
    unqueue: (id: string) => setQueue(queuedRef.current.filter((item) => item.id !== id)),
    appendToInput,
    attachments: attachmentManager.attachments,
    attachWebpage: attachmentManager.attachWebpage,
    attachImage: attachmentManager.attachImage,
    removeAttachment: attachmentManager.removeAttachment,
    availableModels,
    showJumpToBottom,
    jumpToBottom,
    stickToBottom,
    commandFeedback,
    textareaRef,
    scrollViewportRef,
    bottomRef,
    hasMoreOlder,
    isLoadingOlder,
  };
}

export type UseEmbeddableChatType = ReturnType<typeof useEmbeddableChat>;
