/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import type React from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useFollowupSuggestionsCLI } from '../hooks/useFollowupSuggestions.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import {
  ApprovalMode,
  Storage,
  createDebugLogger,
} from '@qwen-code/qwen-code-core';
import {
  parseInputForHighlighting,
  buildSegmentsForVisualSlice,
} from '../utils/highlight.js';
import { t } from '../../i18n/index.js';
import {
  clipboardHasImage,
  saveClipboardImage,
  cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
  useAgentViewState,
  useAgentViewActions,
} from '../contexts/AgentViewContext.js';
import {
  useBackgroundAgentViewState,
  useBackgroundAgentViewActions,
} from '../contexts/BackgroundAgentViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';

/**
 * Represents an attachment (e.g., pasted image) displayed above the input prompt
 */
export interface Attachment {
  id: string; // Unique identifier (timestamp)
  path: string; // Full file path
  filename: string; // Filename only (for display)
}

const debugLogger = createDebugLogger('INPUT_PROMPT');
export interface InputPromptProps {
  buffer: TextBuffer;
  onSubmit: (value: string) => void;
  userMessages: readonly string[];
  onClearScreen: () => void;
  config: Config;
  slashCommands: readonly SlashCommand[];
  commandContext: CommandContext;
  placeholder?: string;
  focus?: boolean;
  inputWidth: number;
  suggestionsWidth: number;
  shellModeActive: boolean;
  setShellModeActive: (value: boolean) => void;
  approvalMode: ApprovalMode;
  onEscapePromptChange?: (showPrompt: boolean) => void;
  onToggleShortcuts?: () => void;
  showShortcuts?: boolean;
  onSuggestionsVisibilityChange?: (visible: boolean) => void;
  vimHandleInput?: (key: Key) => boolean;
  isEmbeddedShellFocused?: boolean;
  /** Prompt suggestion text to display after response completes */
  promptSuggestion?: string | null;
  /** Called when prompt suggestion is dismissed (user typed) */
  onPromptSuggestionDismiss?: () => void;
}

// Re-export from shared utils for backwards compatibility
export { calculatePromptWidths } from '../utils/layoutUtils.js';

// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
const LARGE_PASTE_LINE_THRESHOLD = 10;

export const InputPrompt: React.FC<InputPromptProps> = ({
  buffer,
  onSubmit,
  userMessages,
  onClearScreen,
  config,
  slashCommands,
  commandContext,
  placeholder,
  focus = true,
  suggestionsWidth,
  shellModeActive,
  setShellModeActive,
  approvalMode,
  onEscapePromptChange,
  onToggleShortcuts,
  showShortcuts,
  onSuggestionsVisibilityChange,
  vimHandleInput,
  isEmbeddedShellFocused,
  promptSuggestion,
  onPromptSuggestionDismiss,
}) => {
  const isShellFocused = useShellFocusState();
  const uiState = useUIState();
  const uiActions = useUIActions();
  const { pasteWorkaround } = useKeypressContext();
  const { agents, agentTabBarFocused } = useAgentViewState();
  const { setAgentTabBarFocused } = useAgentViewActions();
  const {
    entries: bgEntries,
    dialogOpen: bgDialogOpen,
    pillFocused: bgPillFocused,
  } = useBackgroundAgentViewState();
  const { setPillFocused: setBgPillFocused } = useBackgroundAgentViewActions();
  const hasAgents = agents.size > 0;
  // Includes terminal entries — the pill stays open so users can reopen
  // the dialog to inspect final state after the last agent finishes.
  const hasBgAgents = bgEntries.length > 0;
  const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
  const [escPressCount, setEscPressCount] = useState(0);
  const [showEscapePrompt, setShowEscapePrompt] = useState(false);
  const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
  const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
  const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  // Attachment state for clipboard images
  const [attachments, setAttachments] = useState<Attachment[]>([]);
  const [isAttachmentMode, setIsAttachmentMode] = useState(false);
  const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
  // Large paste placeholder handling
  const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
    new Map(),
  );
  // Track active placeholder IDs for each charCount to enable reuse
  const activePlaceholderIds = useRef<Map<number, Set<number>>>(new Map());

  // Parse placeholder to extract charCount and ID
  const parsePlaceholder = useCallback(
    (placeholder: string): { charCount: number; id: number } | null => {
      const match = placeholder.match(
        /^\[Pasted Content (\d+) chars\](?: #(\d+))?$/,
      );
      if (!match) return null;
      const charCount = parseInt(match[1], 10);
      const id = match[2] ? parseInt(match[2], 10) : 1;
      return { charCount, id };
    },
    [],
  );

  // Free a placeholder ID when deleted so it can be reused
  const freePlaceholderId = useCallback((charCount: number, id: number) => {
    const activeIds = activePlaceholderIds.current.get(charCount);
    if (activeIds) {
      activeIds.delete(id);
      if (activeIds.size === 0) {
        activePlaceholderIds.current.delete(charCount);
      }
    }
  }, []);

  const [reverseSearchActive, setReverseSearchActive] = useState(false);
  const [commandSearchActive, setCommandSearchActive] = useState(false);
  const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
  const [cursorPosition, setCursorPosition] = useState<[number, number]>([
    0, 0,
  ]);
  const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
    useState<number>(-1);
  const shellHistory = useShellHistory(config.getProjectRoot());
  const shellHistoryData = shellHistory.history;

  const completion = useCommandCompletion(
    buffer,
    config.getTargetDir(),
    slashCommands,
    commandContext,
    reverseSearchActive,
    config,
    // Suppress completion when history navigation just occurred
    !justNavigatedHistory,
  );

  // Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text
  const midInputGhostTextRef = useRef<{
    text: string;
    insertPosition: number;
  } | null>(null);
  midInputGhostTextRef.current = completion.midInputGhostText;

  const reverseSearchCompletion = useReverseSearchCompletion(
    buffer,
    shellHistoryData,
    reverseSearchActive,
  );

  const commandSearchHistory = useMemo(
    () => [...userMessages].reverse(),
    [userMessages],
  );

  const commandSearchCompletion = useReverseSearchCompletion(
    buffer,
    commandSearchHistory,
    commandSearchActive,
  );

  // Prompt suggestion hook
  const followup = useFollowupSuggestionsCLI({
    onAccept: (suggestion) => {
      buffer.insert(suggestion);
    },
    config,
    isFocused: isShellFocused,
  });

  const resetCompletionState = completion.resetCompletionState;
  const resetReverseSearchCompletionState =
    reverseSearchCompletion.resetCompletionState;
  const resetCommandSearchCompletionState =
    commandSearchCompletion.resetCompletionState;

  const showCursor =
    focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;

  const resetEscapeState = useCallback(() => {
    if (escapeTimerRef.current) {
      clearTimeout(escapeTimerRef.current);
      escapeTimerRef.current = null;
    }
    setEscPressCount(0);
    setShowEscapePrompt(false);
  }, []);

  // Notify parent component about escape prompt state changes
  useEffect(() => {
    if (onEscapePromptChange) {
      onEscapePromptChange(showEscapePrompt);
    }
  }, [showEscapePrompt, onEscapePromptChange]);

  // Helper to generate unique placeholder for large pastes
  // Reuses IDs that have been freed up from deleted placeholders
  const nextLargePastePlaceholder = useCallback((charCount: number): string => {
    const activeIds = activePlaceholderIds.current.get(charCount) || new Set();

    // Find smallest available ID (starting from 1)
    let id = 1;
    while (activeIds.has(id)) {
      id++;
    }

    // Mark as active
    activeIds.add(id);
    activePlaceholderIds.current.set(charCount, activeIds);

    const base = `[Pasted Content ${charCount} chars]`;
    return id === 1 ? base : `${base} #${id}`;
  }, []);

  // Clear escape prompt timer on unmount
  useEffect(
    () => () => {
      if (escapeTimerRef.current) {
        clearTimeout(escapeTimerRef.current);
      }
      if (pasteTimeoutRef.current) {
        clearTimeout(pasteTimeoutRef.current);
      }
    },
    [],
  );

  // Ref to inputHistory.resetHistoryNav, populated after useInputHistory runs.
  // Needed because handleSubmitAndClear is passed into useInputHistory as
  // onSubmit, so we can't reference inputHistory directly here without a cycle.
  const resetHistoryNavRef = useRef<() => void>(() => {});

  const handleSubmitAndClear = useCallback(
    (submittedValue: string) => {
      // Expand any large paste placeholders to their full content before submitting
      let finalValue = submittedValue;
      if (pendingPastes.size > 0) {
        const placeholders = Array.from(pendingPastes.keys()).sort(
          (a, b) => b.length - a.length,
        );
        const escapedPlaceholders = placeholders.map((placeholderValue) =>
          placeholderValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
        );
        const placeholderRegex = new RegExp(escapedPlaceholders.join('|'), 'g');
        finalValue = finalValue.replace(
          placeholderRegex,
          (matchedPlaceholder) =>
            pendingPastes.get(matchedPlaceholder) ?? matchedPlaceholder,
        );
        setPendingPastes(new Map());
        activePlaceholderIds.current.clear();
      }
      if (shellModeActive) {
        shellHistory.addCommandToHistory(finalValue);
      }

      // Convert attachments to @references and prepend to the message
      if (attachments.length > 0) {
        const attachmentRefs = attachments
          .map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
          .join(' ');
        finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`;
      }

      // Clear the buffer *before* calling onSubmit to prevent potential re-submission
      // if onSubmit triggers a re-render while the buffer still holds the old value.
      buffer.setText('');
      onSubmit(finalValue);

      // Reset history navigation so the next Up-arrow starts from the newest
      // entry rather than advancing from whatever index the user picked.
      resetHistoryNavRef.current();

      // Dismiss follow-up suggestion after submit
      followup.dismiss();

      // Clear attachments after submit
      setAttachments([]);
      setIsAttachmentMode(false);
      setSelectedAttachmentIndex(-1);

      resetCompletionState();
      resetReverseSearchCompletionState();
    },
    [
      onSubmit,
      buffer,
      resetCompletionState,
      shellModeActive,
      shellHistory,
      resetReverseSearchCompletionState,
      attachments,
      config,
      pendingPastes,
      followup,
    ],
  );

  const customSetTextAndResetCompletionSignal = useCallback(
    (newText: string) => {
      buffer.setText(newText);
      setJustNavigatedHistory(true);
    },
    [buffer, setJustNavigatedHistory],
  );

  const inputHistory = useInputHistory({
    userMessages,
    onSubmit: handleSubmitAndClear,
    // History navigation (Ctrl+P/N) now always works since completion navigation
    // only uses arrow keys. Only disable in shell mode.
    isActive: !shellModeActive,
    currentQuery: buffer.text,
    onChange: customSetTextAndResetCompletionSignal,
  });

  resetHistoryNavRef.current = inputHistory.resetHistoryNav;

  // When an arena session starts (agents appear), reset history position so
  // that pressing down-arrow immediately focuses the agent tab bar instead
  // of cycling through input history.
  const prevHasAgentsRef = useRef(hasAgents);
  useEffect(() => {
    if (hasAgents && !prevHasAgentsRef.current) {
      inputHistory.resetHistoryNav();
    }
    prevHasAgentsRef.current = hasAgents;
  }, [hasAgents, inputHistory]);

  // Effect to reset completion if history navigation just occurred and set the text
  useEffect(() => {
    if (justNavigatedHistory) {
      resetCompletionState();
      resetReverseSearchCompletionState();
      resetCommandSearchCompletionState();
      setExpandedSuggestionIndex(-1);
      setJustNavigatedHistory(false);
    }
  }, [
    justNavigatedHistory,
    buffer.text,
    resetCompletionState,
    setJustNavigatedHistory,
    resetReverseSearchCompletionState,
    resetCommandSearchCompletionState,
  ]);

  // Handle clipboard image pasting with Ctrl+V
  const handleClipboardImage = useCallback(async (validated = false) => {
    try {
      const hasImage = validated || (await clipboardHasImage());
      if (hasImage) {
        const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
        if (imagePath) {
          // Clean up old images
          cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
            // Ignore cleanup errors
          });

          // Add as attachment instead of inserting @reference into text
          const filename = path.basename(imagePath);
          const newAttachment: Attachment = {
            id: String(Date.now()),
            path: imagePath,
            filename,
          };
          setAttachments((prev) => [...prev, newAttachment]);
        }
      }
    } catch (error) {
      debugLogger.error('Error handling clipboard image:', error);
    }
  }, []);

  // Handle deletion of an attachment from the list
  const handleAttachmentDelete = useCallback((index: number) => {
    setAttachments((prev) => {
      const newList = prev.filter((_, i) => i !== index);
      if (newList.length === 0) {
        setIsAttachmentMode(false);
        setSelectedAttachmentIndex(-1);
      } else {
        setSelectedAttachmentIndex(Math.min(index, newList.length - 1));
      }
      return newList;
    });
  }, []);

  const handleInput = useCallback(
    (key: Key): boolean => {
      // When the Arena tab bar or background pill has focus, block
      // non-printable keys so arrow keys and shortcuts don't interfere.
      // Printable characters fall through to BaseTextInput's default
      // handler so the first keystroke appears in the input immediately
      // (each surface's own handler releases focus on the same event).
      if (agentTabBarFocused || bgPillFocused) {
        if (
          key.sequence &&
          key.sequence.length === 1 &&
          !key.ctrl &&
          !key.meta
        ) {
          return false; // let BaseTextInput type the character
        }
        return true; // consume non-printable keys
      }

      // When the Background tasks dialog is open, swallow every key so
      // nothing reaches the composer buffer — the dialog's own keypress
      // handler owns selection, open/close, and stop actions. Unlike
      // the tab bar we do NOT let printable chars type through, because
      // the dialog doesn't auto-close on printable input and users
      // would leak text into the hidden composer.
      if (bgDialogOpen) {
        return true;
      }

      // TODO(jacobr): this special case is likely not needed anymore.
      // We should probably stop supporting paste if the InputPrompt is not
      // focused.
      /// We want to handle paste even when not focused to support drag and drop.
      if (!focus && !key.paste) {
        return true;
      }

      if (key.paste) {
        // Dismiss follow-up suggestion when user starts typing/pasting
        if (buffer.text.length === 0 && followup.state.isVisible) {
          followup.dismiss();
          onPromptSuggestionDismiss?.();
        }

        // Record paste time to prevent accidental auto-submission
        setRecentPasteTime(Date.now());

        // Clear any existing paste timeout
        if (pasteTimeoutRef.current) {
          clearTimeout(pasteTimeoutRef.current);
        }

        // Clear the paste protection after a safe delay
        pasteTimeoutRef.current = setTimeout(() => {
          setRecentPasteTime(null);
          pasteTimeoutRef.current = null;
        }, 500);

        // Handle large pastes by showing a placeholder
        const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
        const charCount = [...pasted].length; // Proper Unicode char count
        const lineCount = pasted.split('\n').length;

        // Ensure we never accidentally interpret paste as regular input.
        if (key.pasteImage) {
          handleClipboardImage(true);
        } else if (
          charCount > LARGE_PASTE_CHAR_THRESHOLD ||
          lineCount > LARGE_PASTE_LINE_THRESHOLD
        ) {
          const placeholder = nextLargePastePlaceholder(charCount);
          setPendingPastes((prev) => {
            const next = new Map(prev);
            next.set(placeholder, pasted);
            return next;
          });
          // Insert the placeholder as regular text
          buffer.insert(placeholder, { paste: false });
        } else {
          // Normal paste handling for small content
          buffer.handleInput(key);
        }
        return true;
      }

      if (vimHandleInput && vimHandleInput(key)) {
        return true;
      }

      // Handle feedback dialog keyboard interactions when dialog is open
      if (uiState.isFeedbackDialogOpen) {
        // If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
        if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
          return true;
        } else {
          // For any other key, close feedback dialog temporarily and continue with normal processing
          uiActions.temporaryCloseFeedbackDialog();
          // Continue processing the key for normal input handling
        }
      }

      // Helper: pop all queued messages into the input buffer,
      // preserving cursor position relative to existing text.
      const popQueueIntoInput = (): boolean => {
        const popped = uiActions.popAllQueuedMessages();
        if (!popped) return false;
        const currentText = buffer.text;
        if (currentText) {
          const currentCursorOffset = logicalPosToOffset(
            buffer.lines,
            buffer.cursor[0],
            buffer.cursor[1],
          );
          buffer.setText(`${popped}\n${currentText}`);
          buffer.moveToOffset(popped.length + 1 + currentCursorOffset);
        } else {
          buffer.setText(popped);
        }
        return true;
      };

      // Reset ESC count and hide prompt on any non-ESC key
      if (key.name !== 'escape') {
        if (escPressCount > 0 || showEscapePrompt) {
          resetEscapeState();
        }
      }

      if (
        key.sequence === '!' &&
        buffer.text === '' &&
        !completion.showSuggestions
      ) {
        // Hide shortcuts when toggling shell mode
        if (showShortcuts && onToggleShortcuts) {
          onToggleShortcuts();
        }
        setShellModeActive(!shellModeActive);
        buffer.setText(''); // Clear the '!' from input
        return true;
      }

      // Toggle keyboard shortcuts display with "?" when buffer is empty
      if (
        key.sequence === '?' &&
        buffer.text === '' &&
        !completion.showSuggestions &&
        onToggleShortcuts
      ) {
        onToggleShortcuts();
        return true;
      }

      // Hide shortcuts on any other key press
      if (showShortcuts && onToggleShortcuts) {
        onToggleShortcuts();
      }

      if (keyMatchers[Command.ESCAPE](key)) {
        const cancelSearch = (
          setActive: (active: boolean) => void,
          resetCompletion: () => void,
        ) => {
          setActive(false);
          resetCompletion();
          buffer.setText(textBeforeReverseSearch);
          const offset = logicalPosToOffset(
            buffer.lines,
            cursorPosition[0],
            cursorPosition[1],
          );
          buffer.moveToOffset(offset);
          setExpandedSuggestionIndex(-1);
        };

        if (reverseSearchActive) {
          cancelSearch(
            setReverseSearchActive,
            reverseSearchCompletion.resetCompletionState,
          );
          return true;
        }
        if (commandSearchActive) {
          cancelSearch(
            setCommandSearchActive,
            commandSearchCompletion.resetCompletionState,
          );
          return true;
        }

        if (shellModeActive) {
          setShellModeActive(false);
          resetEscapeState();
          return true;
        }

        if (completion.showSuggestions) {
          completion.resetCompletionState();
          setExpandedSuggestionIndex(-1);
          resetEscapeState();
          return true;
        }

        // Pop queued messages into input on ESC (before double-ESC clear)
        if (!isAttachmentMode && uiState.messageQueue.length > 0) {
          if (popQueueIntoInput()) {
            resetEscapeState();
            return true;
          }
          // returned false (queue already cleared) — fall through
        }

        // Handle double ESC for clearing input
        if (escPressCount === 0) {
          if (buffer.text === '') {
            return true;
          }
          setEscPressCount(1);
          setShowEscapePrompt(true);
          if (escapeTimerRef.current) {
            clearTimeout(escapeTimerRef.current);
          }
          escapeTimerRef.current = setTimeout(() => {
            resetEscapeState();
          }, 500);
        } else {
          // clear input and immediately reset state
          buffer.setText('');
          resetCompletionState();
          resetEscapeState();
        }
        return true;
      }

      // Ctrl+Y: Retry the last failed request.
      // This shortcut is available when:
      // - There is a failed request in the current session
      // - The stream is not currently responding or waiting for confirmation
      // If no failed request exists, a message will be shown to the user.
      if (keyMatchers[Command.RETRY_LAST](key)) {
        uiActions.handleRetryLastPrompt();
        return true;
      }

      if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
        setReverseSearchActive(true);
        setTextBeforeReverseSearch(buffer.text);
        setCursorPosition(buffer.cursor);
        return true;
      }

      if (keyMatchers[Command.CLEAR_SCREEN](key)) {
        onClearScreen();
        return true;
      }

      if (reverseSearchActive || commandSearchActive) {
        const isCommandSearch = commandSearchActive;

        const sc = isCommandSearch
          ? commandSearchCompletion
          : reverseSearchCompletion;

        const {
          activeSuggestionIndex,
          navigateUp,
          navigateDown,
          showSuggestions,
          suggestions,
        } = sc;
        const setActive = isCommandSearch
          ? setCommandSearchActive
          : setReverseSearchActive;
        const resetState = sc.resetCompletionState;

        if (showSuggestions) {
          if (keyMatchers[Command.NAVIGATION_UP](key)) {
            navigateUp();
            return true;
          }
          if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
            navigateDown();
            return true;
          }
          if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
            if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
              setExpandedSuggestionIndex(-1);
              return true;
            }
          }
          if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
            if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
              setExpandedSuggestionIndex(activeSuggestionIndex);
              return true;
            }
          }
          if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
            sc.handleAutocomplete(activeSuggestionIndex);
            resetState();
            setActive(false);
            return true;
          }
        }

        if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
          const textToSubmit =
            showSuggestions && activeSuggestionIndex > -1
              ? suggestions[activeSuggestionIndex].value
              : buffer.text;
          handleSubmitAndClear(textToSubmit);
          resetState();
          setActive(false);
          return true;
        }

        // Prevent up/down from falling through to regular history navigation
        if (
          keyMatchers[Command.NAVIGATION_UP](key) ||
          keyMatchers[Command.NAVIGATION_DOWN](key)
        ) {
          return true;
        }
      }

      // If the command is a perfect match, pressing enter should execute it.
      if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
        handleSubmitAndClear(buffer.text);
        return true;
      }

      // Handle Tab for prompt suggestions (when buffer is empty and no completion/search active)
      // Use explicit key.name === 'tab' instead of ACCEPT_SUGGESTION matcher,
      // because ACCEPT_SUGGESTION also matches Enter which must fall through to SUBMIT.
      if (
        key.name === 'tab' &&
        !key.paste &&
        !key.shift &&
        buffer.text.length === 0 &&
        !completion.showSuggestions &&
        !reverseSearchActive &&
        !commandSearchActive &&
        followup.state.isVisible &&
        followup.state.suggestion
      ) {
        followup.accept('tab');
        return true;
      }

      // Right arrow fills suggestion into input without submitting
      if (
        key.name === 'right' &&
        !key.ctrl &&
        !key.meta &&
        buffer.text.length === 0 &&
        followup.state.isVisible &&
        followup.state.suggestion
      ) {
        followup.accept('right');
        return true;
      }

      if (completion.showSuggestions) {
        if (completion.suggestions.length > 1) {
          if (keyMatchers[Command.COMPLETION_UP](key)) {
            completion.navigateUp();
            setExpandedSuggestionIndex(-1); // Reset expansion when navigating
            return true;
          }
          if (keyMatchers[Command.COMPLETION_DOWN](key)) {
            completion.navigateDown();
            setExpandedSuggestionIndex(-1); // Reset expansion when navigating
            return true;
          }
        }

        if (keyMatchers[Command.ACCEPT_SUGGESTION](key) && !key.paste) {
          if (completion.suggestions.length > 0) {
            const targetIndex =
              completion.activeSuggestionIndex === -1
                ? 0 // Default to the first if none is active
                : completion.activeSuggestionIndex;
            if (targetIndex < completion.suggestions.length) {
              completion.handleAutocomplete(targetIndex);
              setExpandedSuggestionIndex(-1); // Reset expansion after selection
            }
          }
          return true;
        }
      }

      // Accept mid-input ghost text with Tab (when no dropdown is visible)
      if (
        key.name === 'tab' &&
        !key.paste &&
        !key.shift &&
        !completion.showSuggestions &&
        midInputGhostTextRef.current
      ) {
        buffer.insert(midInputGhostTextRef.current.text);
        return true;
      }

      // Attachment mode handling - process before history navigation
      if (isAttachmentMode && attachments.length > 0) {
        if (key.name === 'left') {
          setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
          return true;
        }
        if (key.name === 'right') {
          setSelectedAttachmentIndex((i) =>
            Math.min(attachments.length - 1, i + 1),
          );
          return true;
        }
        if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
          // Exit attachment mode and return to input
          setIsAttachmentMode(false);
          setSelectedAttachmentIndex(-1);
          return true;
        }
        if (key.name === 'backspace' || key.name === 'delete') {
          handleAttachmentDelete(selectedAttachmentIndex);
          return true;
        }
        if (key.name === 'return' || key.name === 'escape') {
          setIsAttachmentMode(false);
          setSelectedAttachmentIndex(-1);
          return true;
        }
        // For other keys, exit attachment mode and let input handle them
        setIsAttachmentMode(false);
        setSelectedAttachmentIndex(-1);
        // Continue to process the key in input
      }

      // Enter attachment mode when pressing up at the first line with attachments
      if (
        !isAttachmentMode &&
        attachments.length > 0 &&
        !shellModeActive &&
        !reverseSearchActive &&
        !commandSearchActive &&
        buffer.visualCursor[0] === 0 &&
        buffer.visualScrollRow === 0 &&
        keyMatchers[Command.NAVIGATION_UP](key)
      ) {
        setIsAttachmentMode(true);
        setSelectedAttachmentIndex(attachments.length - 1);
        return true;
      }

      if (!shellModeActive) {
        if (keyMatchers[Command.REVERSE_SEARCH](key)) {
          setCommandSearchActive(true);
          setTextBeforeReverseSearch(buffer.text);
          setCursorPosition(buffer.cursor);
          return true;
        }

        // Pop all queued messages into input when pressing Up arrow at top of input
        if (
          !isAttachmentMode &&
          uiState.messageQueue.length > 0 &&
          keyMatchers[Command.NAVIGATION_UP](key) &&
          (buffer.allVisualLines.length === 1 ||
            (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
        ) {
          if (popQueueIntoInput()) return true;
          // returned false (queue already cleared) — fall through to history
        }

        if (keyMatchers[Command.HISTORY_UP](key)) {
          inputHistory.navigateUp();
          return true;
        }
        if (keyMatchers[Command.HISTORY_DOWN](key)) {
          inputHistory.navigateDown();
          return true;
        }
        // Handle arrow-up/down for history on single-line or at edges
        if (
          keyMatchers[Command.NAVIGATION_UP](key) &&
          (buffer.allVisualLines.length === 1 ||
            (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
        ) {
          inputHistory.navigateUp();
          return true;
        }
        if (
          keyMatchers[Command.NAVIGATION_DOWN](key) &&
          (buffer.allVisualLines.length === 1 ||
            buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
        ) {
          if (inputHistory.navigateDown()) {
            return true;
          }
          // Focus order on Down from an empty composer:
          // team tab bar (if any Arena agents) → Background tasks pill
          // (if any bg agents) → otherwise stay put. The pill itself
          // opens the dialog on Enter; the tab bar re-routes Down into
          // the pill once it has focus, so both surfaces remain reachable
          // in sequence.
          if (hasAgents) {
            setAgentTabBarFocused(true);
            return true;
          }
          if (hasBgAgents) {
            setBgPillFocused(true);
            return true;
          }
          return true;
        }
      } else {
        // Shell History Navigation
        if (keyMatchers[Command.NAVIGATION_UP](key)) {
          const prevCommand = shellHistory.getPreviousCommand();
          if (prevCommand !== null) buffer.setText(prevCommand);
          return true;
        }
        if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
          const nextCommand = shellHistory.getNextCommand();
          if (nextCommand !== null) buffer.setText(nextCommand);
          return true;
        }
      }

      if (keyMatchers[Command.SUBMIT](key)) {
        // Accept and submit prompt suggestion on Enter when input is truly empty
        if (
          buffer.text.length === 0 &&
          followup.state.isVisible &&
          followup.state.suggestion
        ) {
          const text = followup.state.suggestion;
          // Skip onAccept (buffer.insert) — we pass the text directly to
          // handleSubmitAndClear which clears the buffer synchronously.
          // Without skipOnAccept the microtask in accept() would re-insert
          // the suggestion into the buffer after it was already cleared.
          followup.accept('enter', { skipOnAccept: true });
          handleSubmitAndClear(text);
          return true;
        }
        if (buffer.text.trim()) {
          // Check if a paste operation occurred recently to prevent accidental auto-submission.
          // Only applies when pasteWorkaround is enabled (Windows or Node < 20), where bracketed
          // paste markers may not work reliably and Enter key events can leak from pasted text.
          if (pasteWorkaround && recentPasteTime !== null) {
            // Paste occurred recently, ignore this submit to prevent auto-execution
            return true;
          }

          const [row, col] = buffer.cursor;
          const line = buffer.lines[row];
          const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
          if (charBefore === '\\') {
            buffer.backspace();
            buffer.newline();
          } else {
            handleSubmitAndClear(buffer.text);
          }
        }
        return true;
      }

      // Ctrl+V for clipboard image paste
      if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
        handleClipboardImage();
        return true;
      }

      // Handle backspace with placeholder-aware deletion
      if (
        pendingPastes.size > 0 &&
        (key.name === 'backspace' ||
          key.sequence === '\x7f' ||
          (key.ctrl && key.name === 'h'))
      ) {
        const text = buffer.text;
        const [row, col] = buffer.cursor;

        // Calculate the offset where the cursor is
        let offset = 0;
        for (let i = 0; i < row; i++) {
          offset += buffer.lines[i].length + 1; // +1 for newline
        }
        offset += col;

        // Check if we're at the end of any placeholder
        for (const placeholder of pendingPastes.keys()) {
          const placeholderStart = offset - placeholder.length;
          if (
            placeholderStart >= 0 &&
            text.slice(placeholderStart, offset) === placeholder
          ) {
            // Delete the entire placeholder
            buffer.replaceRangeByOffset(placeholderStart, offset, '');
            // Remove from pendingPastes and free the ID for reuse
            setPendingPastes((prev) => {
              const next = new Map(prev);
              next.delete(placeholder);
              return next;
            });
            const parsed = parsePlaceholder(placeholder);
            if (parsed) {
              freePlaceholderId(parsed.charCount, parsed.id);
            }
            return true;
          }
        }
        // No placeholder matched — fall through to BaseTextInput's default backspace
      }

      // Ctrl+C with completion active — also reset completion state
      if (keyMatchers[Command.CLEAR_INPUT](key)) {
        if (buffer.text.length > 0) {
          resetCompletionState();
        }
        // Fall through to BaseTextInput's default CLEAR_INPUT handler
      }

      // All remaining keys (readline shortcuts, text input) handled by BaseTextInput
      // Dismiss follow-up suggestion only on printable character input
      if (
        buffer.text.length === 0 &&
        followup.state.isVisible &&
        key.sequence &&
        key.sequence.length === 1 &&
        !key.ctrl &&
        !key.meta
      ) {
        followup.recordKeystroke();
        followup.dismiss();
        onPromptSuggestionDismiss?.();
      }
      return false;
    },
    [
      focus,
      buffer,
      completion,
      shellModeActive,
      setShellModeActive,
      onClearScreen,
      inputHistory,
      handleSubmitAndClear,
      shellHistory,
      reverseSearchCompletion,
      handleClipboardImage,
      resetCompletionState,
      escPressCount,
      showEscapePrompt,
      resetEscapeState,
      vimHandleInput,
      reverseSearchActive,
      textBeforeReverseSearch,
      cursorPosition,
      recentPasteTime,
      commandSearchActive,
      commandSearchCompletion,
      onToggleShortcuts,
      showShortcuts,
      uiState,
      isAttachmentMode,
      attachments,
      selectedAttachmentIndex,
      handleAttachmentDelete,
      uiActions,
      pasteWorkaround,
      nextLargePastePlaceholder,
      pendingPastes,
      parsePlaceholder,
      freePlaceholderId,
      agentTabBarFocused,
      bgDialogOpen,
      bgPillFocused,
      hasAgents,
      hasBgAgents,
      setAgentTabBarFocused,
      setBgPillFocused,
      followup,
      onPromptSuggestionDismiss,
    ],
  );

  const renderLineWithHighlighting = useCallback(
    (opts: RenderLineOptions): React.ReactNode => {
      const {
        lineText,
        isOnCursorLine,
        cursorCol: cursorVisualColAbsolute,
        showCursor: showCursorOpt,
        absoluteVisualIndex,
        buffer: buf,
      } = opts;
      const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
      const [logicalLineIdx, logicalStartCol] = mapEntry;
      const logicalLine = buf.lines[logicalLineIdx] || '';
      const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);

      const visualStart = logicalStartCol;
      const visualEnd = logicalStartCol + cpLen(lineText);
      const segments = buildSegmentsForVisualSlice(
        tokens,
        visualStart,
        visualEnd,
      );

      const renderedLine: React.ReactNode[] = [];
      let charCount = 0;
      segments.forEach((seg, segIdx) => {
        const segLen = cpLen(seg.text);
        let display = seg.text;

        if (isOnCursorLine) {
          const segStart = charCount;
          const segEnd = segStart + segLen;
          if (
            cursorVisualColAbsolute >= segStart &&
            cursorVisualColAbsolute < segEnd
          ) {
            const charToHighlight = cpSlice(
              seg.text,
              cursorVisualColAbsolute - segStart,
              cursorVisualColAbsolute - segStart + 1,
            );
            const highlighted = showCursorOpt
              ? chalk.inverse(charToHighlight)
              : charToHighlight;
            display =
              cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
              highlighted +
              cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
          }
          charCount = segEnd;
        }

        const color =
          seg.type === 'command' || seg.type === 'file'
            ? theme.text.accent
            : theme.text.primary;

        renderedLine.push(
          <Text key={`token-${segIdx}`} color={color}>
            {display}
          </Text>,
        );
      });

      if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
        // Check for mid-input ghost text (only renders when cursor is at end of input)
        const ghostText = midInputGhostTextRef.current;
        if (ghostText && showCursorOpt && ghostText.text.length > 0) {
          // First ghost char: inverted (as cursor). Rest: dimmed gray.
          const firstChar = ghostText.text[0]!;
          const rest = ghostText.text.slice(firstChar.length);
          renderedLine.push(
            <Text key="ghost-cursor">{chalk.inverse(firstChar)}</Text>,
          );
          if (rest.length > 0) {
            renderedLine.push(
              <Text key="ghost-rest" color={theme.text.secondary}>
                {rest}
              </Text>,
            );
          }
          renderedLine.push(<Text key="ghost-zwsp">{`\u200B`}</Text>);
        } else {
          // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
          renderedLine.push(
            <Text key={`cursor-end-${cursorVisualColAbsolute}`}>
              {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
            </Text>,
          );
        }
      }

      return <Text>{renderedLine}</Text>;
    },
    [],
  );

  const getActiveCompletion = () => {
    if (commandSearchActive) return commandSearchCompletion;
    if (reverseSearchActive) return reverseSearchCompletion;
    return completion;
  };

  const activeCompletion = getActiveCompletion();
  const shouldShowSuggestions = activeCompletion.showSuggestions;

  // Notify parent about suggestions visibility changes
  useEffect(() => {
    if (onSuggestionsVisibilityChange) {
      onSuggestionsVisibilityChange(shouldShowSuggestions);
    }
  }, [shouldShowSuggestions, onSuggestionsVisibilityChange]);

  // Trigger prompt suggestion when prop changes
  useEffect(() => {
    followup.setSuggestion(promptSuggestion ?? null);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on prop change
  }, [promptSuggestion]);

  const showAutoAcceptStyling =
    !shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
  const showYoloStyling =
    !shellModeActive && approvalMode === ApprovalMode.YOLO;

  let statusColor: string | undefined;
  let statusText = '';
  if (shellModeActive) {
    statusColor = theme.ui.symbol;
    statusText = t('Shell mode');
  } else if (showYoloStyling) {
    statusColor = theme.status.errorDim;
    statusText = t('YOLO mode');
  } else if (showAutoAcceptStyling) {
    statusColor = theme.status.warningDim;
    statusText = t('Accepting edits');
  }

  const borderColor =
    isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
      ? (statusColor ?? theme.border.focused)
      : theme.border.default;

  const prefixNode = (
    <Text
      color={statusColor ?? theme.text.accent}
      aria-label={statusText || undefined}
    >
      {shellModeActive ? (
        reverseSearchActive ? (
          <Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
            (r:){' '}
          </Text>
        ) : (
          '!'
        )
      ) : commandSearchActive ? (
        <Text color={theme.text.accent}>(r:) </Text>
      ) : showYoloStyling ? (
        '*'
      ) : (
        '>'
      )}{' '}
    </Text>
  );

  return (
    <>
      {attachments.length > 0 && (
        <Box marginLeft={2} marginBottom={0}>
          <Text color={theme.text.secondary}>{t('Attachments: ')}</Text>
          {attachments.map((att, idx) => (
            <Text
              key={att.id}
              color={
                isAttachmentMode && idx === selectedAttachmentIndex
                  ? theme.status.success
                  : theme.text.secondary
              }
            >
              [{att.filename}]{idx < attachments.length - 1 ? ' ' : ''}
            </Text>
          ))}
        </Box>
      )}
      <BaseTextInput
        buffer={buffer}
        onSubmit={handleSubmitAndClear}
        onKeypress={handleInput}
        showCursor={showCursor}
        placeholder={
          followup.state.isVisible && followup.state.suggestion
            ? followup.state.suggestion
            : placeholder
        }
        prefix={prefixNode}
        borderColor={borderColor}
        topRightLabel={uiState.sessionName || undefined}
        isActive={!isEmbeddedShellFocused}
        renderLine={renderLineWithHighlighting}
      />
      {shouldShowSuggestions && (
        <Box marginLeft={2} marginRight={2}>
          <SuggestionsDisplay
            suggestions={activeCompletion.suggestions}
            activeIndex={activeCompletion.activeSuggestionIndex}
            isLoading={activeCompletion.isLoadingSuggestions}
            width={suggestionsWidth}
            scrollOffset={activeCompletion.visibleStartIndex}
            userInput={buffer.text}
            mode={
              buffer.text.startsWith('/') &&
              !reverseSearchActive &&
              !commandSearchActive
                ? 'slash'
                : 'reverse'
            }
            expandedIndex={expandedSuggestionIndex}
          />
        </Box>
      )}
      {/* Attachment hints - show when there are attachments and no suggestions visible */}
      {attachments.length > 0 && !shouldShowSuggestions && (
        <Box marginLeft={2} marginRight={2}>
          <Text color={theme.text.secondary}>
            {isAttachmentMode
              ? t('← → select, Delete to remove, ↓ to exit')
              : t('↑ to manage attachments')}
          </Text>
        </Box>
      )}
    </>
  );
};
