/**
 * FileEditorCore — The shared editor surface used by both FileEditorModal
 * (fullscreen overlay) and FileEditorPanel (side panel).
 *
 * Extracted from FileEditorModal to enable dual-mount without duplicating
 * editor logic. Owns all editing state: content, syntax highlighting,
 * search, diff view, markdown preview, unsaved-changes guard.
 *
 * Key exports:
 * - `FileEditorCore`      — Main component (default export)
 * - `FileEditorCoreProps`  — Public prop interface
 *
 * Sub-components (module-private, carried over from FileEditorModal):
 * - `BreadcrumbBar`  — Breadcrumb path display
 * - `LineGutter`     — Synchronized line-number gutter
 * - `DiffView`       — Inline diff renderer
 * - `SearchBar`      — Floating Cmd+F search bar
 */

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import hljs from 'highlight.js';
import Button from './Button';
import type { GitStatus } from '../../types';
import type { FileTreeItem } from '../workspace-explorer/FileTreeNode';
import { fileIcon, fileIconColor, gitStatusBadge } from '../../utils/fileUtils';
import { computeLineDiff } from '../../utils/lineDiff';
import type { DiffLine } from '../../utils/lineDiff';
import api from '../../services/api';
import { copyToClipboard } from '../../utils/clipboard';
import { openExternal, openInSystemApp } from '../../utils/openExternal';
import MarkdownRenderer from './MarkdownRenderer';
import { detectLanguage, isDirtyState, findAllMatches } from './FileEditorModal';
import type { SearchMatch } from './FileEditorModal';
import { useReviewMode } from '../../hooks/useReviewMode';
import type { DiffContext, ReviewComment } from '../../hooks/useReviewMode';
import ReviewModeGutter from './ReviewModeGutter';
import ReviewFeedbackBar from './ReviewFeedbackBar';
import CommentPopover from './CommentPopover';

export interface FileEditorCoreProps {
  filePath: string;
  fileName: string;
  workspaceId: string;
  initialContent?: string;
  onSave: (content: string) => Promise<void>;
  onClose: () => void;
  gitStatus?: GitStatus;
  onAttachToChat?: (item: FileTreeItem) => void;
  isAttached?: boolean;
  readonly?: boolean;
  committedContent?: string;
  /** 'panel' keeps editor open after save; 'modal' closes after save. */
  variant: 'panel' | 'modal';
  /** Toggle between panel ↔ modal mode. */
  onToggleMode?: () => void;
  /** Called after save when diff is non-empty (L2 auto-diff).
   *  Second arg is the fileName captured at save time to avoid stale closures. */
  onSaveWithDiff?: (diffSummary: string, fileName?: string) => void;
  /** Called on every content change so parent can track live edits.
   *  Used to preserve content across panel ↔ modal mode switches. */
  onContentChange?: (content: string) => void;
}

/* ------------------------------------------------------------------ */
/*  BreadcrumbBar                                                       */
/* ------------------------------------------------------------------ */

function BreadcrumbBar({ filePath }: { filePath: string }) {
  const segments = filePath.split('/').filter(Boolean);
  return (
    <div
      className="flex items-center gap-1 text-xs min-w-0 overflow-hidden"
      style={{ direction: 'rtl' }}
      title={filePath}
    >
      <span style={{ direction: 'ltr', whiteSpace: 'nowrap' }} className="flex items-center gap-1">
        {segments.map((seg, i) => {
          const isLast = i === segments.length - 1;
          return (
            <span key={i} className="flex items-center gap-1 shrink-0">
              {i > 0 && (
                <span className="text-[var(--color-text-muted)]">&rsaquo;</span>
              )}
              <span
                className={isLast
                  ? 'font-semibold text-[var(--color-text)]'
                  : 'text-[var(--color-text-muted)]'
                }
              >
                {seg}
              </span>
            </span>
          );
        })}
      </span>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  LineGutter                                                          */
/* ------------------------------------------------------------------ */

function LineGutter({ lineCount, scrollTop, activeLineNumber }: {
  lineCount: number;
  scrollTop: number;
  activeLineNumber?: number;
}) {
  const gutterWidth = `${Math.max(3, String(lineCount).length) + 1}ch`;
  return (
    <div
      className="shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-background)] overflow-hidden"
      style={{ width: gutterWidth }}
    >
      <div
        className="font-mono text-xs leading-6 text-right pr-2 pt-4"
        style={{ transform: `translateY(-${scrollTop}px)` }}
      >
        {Array.from({ length: lineCount }, (_, i) => {
          const lineNum = i + 1;
          const isActive = lineNum === activeLineNumber;
          return (
            <div
              key={lineNum}
              className={isActive
                ? 'text-[var(--color-text)] bg-[var(--color-hover)]'
                : 'text-[var(--color-text-muted)]'
              }
            >
              {lineNum}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  DiffView                                                            */
/* ------------------------------------------------------------------ */

/** LINE_HEIGHT must match the editor textarea (leading-6 = 24px). */
const DIFF_LINE_HEIGHT = 24;

interface DiffViewProps {
  lines: DiffLine[];
  /** Currently active popover diff-line index (0-based). */
  activePopoverIndex?: number | null;
  /** Called when a diff line is clicked. Index is 0-based into `lines`. */
  onLineClick?: (index: number) => void;
  /** Called to add a new comment on a diff line. */
  onAddComment?: (lineStart: number, lineEnd: number, text: string, diffContext?: DiffContext) => void;
  /** Called to update an existing comment. */
  onUpdateComment?: (id: string, text: string) => void;
  /** Called to remove an existing comment. */
  onRemoveComment?: (id: string) => void;
  /** Called to close the popover. */
  onCancelPopover?: () => void;
  /** Get comment for a specific diff-line index (0-based). */
  getCommentForDiffIndex?: (index: number) => ReviewComment | undefined;
  /** Comment being edited (for existing comment editing). */
  editingComment?: ReviewComment | null;
}

function DiffView({
  lines,
  activePopoverIndex,
  onLineClick,
  onAddComment,
  onUpdateComment,
  onRemoveComment,
  onCancelPopover,
  getCommentForDiffIndex,
  editingComment,
}: DiffViewProps) {
  const maxOld = lines.reduce((m, l) => Math.max(m, l.oldLineNumber ?? 0), 0);
  const maxNew = lines.reduce((m, l) => Math.max(m, l.newLineNumber ?? 0), 0);
  const gutterW = `${Math.max(3, String(Math.max(maxOld, maxNew)).length)}ch`;
  const scrollRef = useRef<HTMLPreElement>(null);
  const isInteractive = !!onLineClick;

  // Track scroll position as state so CommentPopover re-renders on scroll
  const [diffScrollTop, setDiffScrollTop] = useState(0);
  const handleDiffScroll = useCallback(() => {
    if (scrollRef.current) {
      setDiffScrollTop(scrollRef.current.scrollTop);
    }
  }, []);

  // Active line for popover
  const activeLine = activePopoverIndex != null ? lines[activePopoverIndex] : null;

  return (
    <pre
      ref={scrollRef}
      onScroll={handleDiffScroll}
      className="absolute inset-0 m-0 overflow-auto font-mono text-sm leading-6 bg-[var(--color-background)]"
      data-testid="diff-view"
    >
      {lines.map((line, i) => {
        let bgClass = '';
        if (line.type === 'added') bgClass = 'bg-[var(--color-git-added)]/15';
        if (line.type === 'removed') bgClass = 'bg-[var(--color-git-deleted)]/15';
        const hasComment = getCommentForDiffIndex ? !!getCommentForDiffIndex(i) : false;
        const isPopoverTarget = activePopoverIndex === i;

        return (
          <div
            key={i}
            className={clsx(
              'flex',
              bgClass,
              isInteractive && 'cursor-pointer',
              isInteractive && !hasComment && !isPopoverTarget && 'hover:brightness-95 dark:hover:brightness-110',
              isPopoverTarget && 'ring-1 ring-[var(--color-primary)]/40',
              hasComment && !isPopoverTarget && 'ring-1 ring-amber-500/30',
            )}
            onClick={isInteractive ? () => onLineClick!(i) : undefined}
            role={isInteractive ? 'button' : undefined}
            aria-label={isInteractive ? `Comment on diff line ${i + 1}` : undefined}
          >
            <span
              className="shrink-0 text-right pr-1 text-[var(--color-text-muted)] select-none border-r border-[var(--color-border)]"
              style={{ width: gutterW }}
            >
              {line.oldLineNumber ?? ''}
            </span>
            <span
              className="shrink-0 text-right pr-1 pl-1 text-[var(--color-text-muted)] select-none border-r border-[var(--color-border)]"
              style={{ width: gutterW }}
            >
              {line.newLineNumber ?? ''}
            </span>
            <span className="shrink-0 w-4 text-center select-none text-[var(--color-text-muted)]">
              {line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}
            </span>
            <span className="flex-1 whitespace-pre-wrap break-words px-2">
              {line.content}
            </span>
            {hasComment && (
              <span className="shrink-0 pr-2 text-[10px] self-center" title="Has comment">💬</span>
            )}
          </div>
        );
      })}

      {/* CommentPopover for diff lines */}
      {activePopoverIndex != null && activeLine && onAddComment && onCancelPopover && scrollRef.current && (
        <CommentPopover
          lineNumber={activePopoverIndex + 1}
          initialText={editingComment?.text ?? ''}
          onSubmit={(text) => {
            if (editingComment && onUpdateComment) {
              onUpdateComment(editingComment.id, text);
            } else {
              // Use the real line number from the diff line for the comment model
              const lineNum = activeLine.newLineNumber ?? activeLine.oldLineNumber ?? activePopoverIndex + 1;
              onAddComment(lineNum, lineNum, text, {
                type: activeLine.type,
                oldLineNumber: activeLine.oldLineNumber,
                newLineNumber: activeLine.newLineNumber,
                content: activeLine.content,
              });
            }
          }}
          onCancel={onCancelPopover}
          onDelete={
            editingComment && onRemoveComment
              ? () => onRemoveComment(editingComment.id)
              : undefined
          }
          topOffset={activePopoverIndex * DIFF_LINE_HEIGHT - diffScrollTop}
          anchorRef={scrollRef as unknown as React.RefObject<HTMLDivElement>}
        />
      )}
    </pre>
  );
}

/* ------------------------------------------------------------------ */
/*  SearchBar                                                           */
/* ------------------------------------------------------------------ */

function SearchBar({
  searchQuery,
  onSearchChange,
  currentMatch,
  totalMatches,
  onNext,
  onPrevious,
  onClose,
}: {
  searchQuery: string;
  onSearchChange: (query: string) => void;
  currentMatch: number;
  totalMatches: number;
  onNext: () => void;
  onPrevious: () => void;
  onClose: () => void;
}) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      e.stopPropagation();
      onClose();
    } else if (e.key === 'Enter' && e.shiftKey) {
      e.preventDefault();
      onPrevious();
    } else if (e.key === 'Enter') {
      e.preventDefault();
      onNext();
    }
  };

  const matchDisplay = totalMatches > 0
    ? `${currentMatch + 1} of ${totalMatches}`
    : '0 of 0';

  return (
    <div
      className="absolute top-0 right-0 z-10 flex items-center gap-1 px-2 py-1.5 m-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] shadow-lg"
      data-testid="search-bar"
    >
      <input
        ref={inputRef}
        type="text"
        value={searchQuery}
        onChange={(e) => onSearchChange(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Find..."
        className="w-40 px-2 py-0.5 text-xs rounded bg-[var(--color-background)] text-[var(--color-text)] border border-[var(--color-border)] outline-none focus:border-[var(--color-primary)]"
        data-testid="search-input"
      />
      <span className="text-xs text-[var(--color-text-muted)] min-w-[4rem] text-center">
        {matchDisplay}
      </span>
      <button
        onClick={onPrevious}
        disabled={totalMatches === 0}
        className="p-0.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)] disabled:opacity-40"
        aria-label="Previous match"
      >
        <span className="material-symbols-outlined text-base">keyboard_arrow_up</span>
      </button>
      <button
        onClick={onNext}
        disabled={totalMatches === 0}
        className="p-0.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)] disabled:opacity-40"
        aria-label="Next match"
      >
        <span className="material-symbols-outlined text-base">keyboard_arrow_down</span>
      </button>
      <button
        onClick={onClose}
        className="p-0.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]"
        aria-label="Close search"
      >
        <span className="material-symbols-outlined text-base">close</span>
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  FileEditorCore                                                      */
/* ------------------------------------------------------------------ */

export default function FileEditorCore({
  filePath,
  fileName,
  workspaceId,
  initialContent = '',
  onSave,
  onClose,
  gitStatus,
  onAttachToChat,
  isAttached,
  readonly,
  committedContent,
  variant,
  onToggleMode,
  onSaveWithDiff,
  onContentChange,
}: FileEditorCoreProps) {
  const [content, setContent] = useState(initialContent);
  const [originalContent, setOriginalContent] = useState(committedContent ?? initialContent);
  // Tracks the last content successfully saved to disk, so hasUnsavedEdits
  // resets after save without destroying the committed (HEAD) baseline in
  // originalContent.  Null means "nothing saved yet — use initialContent".
  const [savedContent, setSavedContent] = useState<string | null>(null);
  const [isSaving, setIsSaving] = useState(false);
  const [showUnsavedWarning, setShowUnsavedWarning] = useState(false);
  const [showDiff, setShowDiff] = useState(false);
  const [showMarkdownPreview, setShowMarkdownPreview] = useState(false);
  const [showSvgPreview, setShowSvgPreview] = useState(false);
  const [showSearch, setShowSearch] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
  const [activeLineNumber, setActiveLineNumber] = useState<number | undefined>(undefined);
  const [attachFeedback, setAttachFeedback] = useState(false);
  const [copyPathFeedback, setCopyPathFeedback] = useState(false);
  const [scrollTop, setScrollTop] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const highlightRef = useRef<HTMLPreElement>(null);

  // Resolve workspace root once on mount — avoids API call on every button click
  const wsRootRef = useRef<string>('');
  useEffect(() => {
    let cancelled = false;
    api.get<{ file_path?: string; filePath?: string }>('/workspace')
      .then((resp) => {
        if (!cancelled) {
          wsRootRef.current = resp.data.file_path ?? resp.data.filePath ?? '';
        }
      })
      .catch(() => { /* workspace config unavailable — absolutePath will use relative */ });
    return () => { cancelled = true; };
  }, []);

  /** Build absolute path from cached workspace root + relative filePath.
   *  If filePath is already absolute, return it as-is. */
  const getAbsolutePath = useCallback(() => {
    if (filePath.startsWith('/')) return filePath;
    const ws = wsRootRef.current;
    return ws ? `${ws}/${filePath}` : filePath;
  }, [filePath]);

  const isDirty = isDirtyState(content, originalContent);
  const hasUnsavedEdits = isDirtyState(content, savedContent ?? initialContent);
  const language = detectLanguage(fileName);
  const isMarkdown = /\.md$/i.test(fileName);
  const isSvg = /\.svg$/i.test(fileName);

  // L3: Review mode — inline comments (used for both normal review and diff review)
  // filePath key enables sessionStorage persistence across tab switches (U10).
  const review = useReviewMode(content, filePath);
  const feedbackText = review.formatFeedback(fileName);

  // Diff-line comment tracking
  const [activeDiffPopoverIndex, setActiveDiffPopoverIndex] = useState<number | null>(null);
  const [editingDiffComment, setEditingDiffComment] = useState<ReviewComment | null>(null);

  // When a line is clicked in review mode gutter
  const handleReviewLineClick = useCallback(
    (lineNumber: number) => {
      const existing = review.getCommentForLine(lineNumber);
      if (existing) {
        // Edit existing comment
        review.setEditingCommentId(existing.id);
        review.setActivePopoverLine(lineNumber);
      } else {
        // New comment
        review.setEditingCommentId(null);
        review.setActivePopoverLine(lineNumber);
      }
    },
    [review],
  );

  // Compute diff lines — declared early because callbacks and memos below depend on it
  const diffLines = useMemo(() => {
    if (!showDiff) return [];
    return computeLineDiff(originalContent, content);
  }, [showDiff, originalContent, content]);

  // Derived: maps diff-line index → comment id
  const diffCommentMap = useMemo(() => {
    if (!showDiff || diffLines.length === 0) return new Map<number, string>();
    const map = new Map<number, string>();
    for (const comment of review.comments) {
      if (!comment.diffContext) continue;
      const idx = diffLines.findIndex((dl) =>
        dl.type === comment.diffContext!.type &&
        dl.content === comment.diffContext!.content &&
        dl.oldLineNumber === comment.diffContext!.oldLineNumber &&
        dl.newLineNumber === comment.diffContext!.newLineNumber
      );
      if (idx !== -1) {
        map.set(idx, comment.id);
      }
    }
    return map;
  }, [review.comments, showDiff, diffLines]);

  // When a diff line is clicked
  const handleDiffLineClick = useCallback(
    (diffIndex: number) => {
      const existingCommentId = diffCommentMap.get(diffIndex);
      if (existingCommentId) {
        const comment = review.comments.find((c) => c.id === existingCommentId);
        setEditingDiffComment(comment ?? null);
      } else {
        setEditingDiffComment(null);
      }
      setActiveDiffPopoverIndex(diffIndex);
    },
    [diffCommentMap, review.comments],
  );

  // Get comment for a specific diff-line index
  const getCommentForDiffIndex = useCallback(
    (diffIndex: number): ReviewComment | undefined => {
      const commentId = diffCommentMap.get(diffIndex);
      if (!commentId) return undefined;
      return review.comments.find((c) => c.id === commentId);
    },
    [diffCommentMap, review.comments],
  );

  // Add comment on a diff line — wraps review.addComment with diff context
  const handleDiffAddComment = useCallback(
    (lineStart: number, lineEnd: number, text: string, diffContext?: DiffContext) => {
      review.addComment(lineStart, lineEnd, text, diffContext);
      setActiveDiffPopoverIndex(null);
    },
    [review],
  );

  const handleDiffCancelPopover = useCallback(() => {
    setActiveDiffPopoverIndex(null);
    setEditingDiffComment(null);
  }, []);

  const handleReviewFeedbackSent = useCallback(() => {
    review.clearComments();
    setActiveDiffPopoverIndex(null);
    setEditingDiffComment(null);
    if (review.isReviewMode) {
      review.toggleReviewMode();
    }
  }, [review]);

  // --- Handlers ---

  const handleScroll = useCallback(() => {
    if (textareaRef.current && highlightRef.current) {
      const top = textareaRef.current.scrollTop;
      highlightRef.current.scrollTop = top;
      highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
      setScrollTop(top);
    }
  }, []);

  const handleCloseAttempt = useCallback(() => {
    if (hasUnsavedEdits) {
      setShowUnsavedWarning(true);
    } else {
      onClose();
    }
  }, [hasUnsavedEdits, onClose]);

  const handleSave = useCallback(async () => {
    setIsSaving(true);
    try {
      await onSave(content);
      // Track saved content separately so hasUnsavedEdits resets,
      // but originalContent (committed/HEAD baseline) stays intact for Show Changes.
      setSavedContent(content);

      // L2: Auto-diff feedback — fetch diff after save and notify parent
      if (onSaveWithDiff) {
        try {
          const diffResp = await api.get<{ summary: string; hunks: unknown[] }>(
            '/workspace/file/diff',
            { params: { path: filePath } },
          );
          if (diffResp.data.hunks && diffResp.data.hunks.length > 0) {
            onSaveWithDiff(diffResp.data.summary, fileName);
          }
        } catch {
          // Diff fetch failure is non-critical — save still succeeded
        }
      }

      // In modal mode: close after save (legacy behavior)
      // In panel mode: stay open so user can see Swarm's response
      if (variant === 'modal') {
        onClose();
      }
    } catch (error) {
      console.error('Failed to save file:', error);
    } finally {
      setIsSaving(false);
    }
  }, [content, onSave, onClose, variant, filePath, fileName, onSaveWithDiff]);

  const handleSearchClose = useCallback(() => {
    setShowSearch(false);
    setSearchQuery('');
    setCurrentMatchIndex(0);
    textareaRef.current?.focus();
  }, []);

  // --- Reset state when content changes (e.g. file switch in panel mode) ---
  useEffect(() => {
    setContent(initialContent);
    setOriginalContent(committedContent ?? initialContent);
    setSavedContent(null);
    setShowUnsavedWarning(false);
    setShowDiff(false);
    setShowMarkdownPreview(false);
    setShowSvgPreview(false);
    setShowSearch(false);
    setSearchQuery('');
    setCurrentMatchIndex(0);
    setActiveLineNumber(undefined);
    setAttachFeedback(false);
    // L3: Hard-reset review mode on file switch (no dependency on isReviewMode)
    review.resetReviewMode();
    // Clear diff comment state
    setActiveDiffPopoverIndex(null);
    setEditingDiffComment(null);
  }, [initialContent, committedContent, filePath, review.resetReviewMode]);

  // Syntax highlighting
  useEffect(() => {
    if (highlightRef.current && !showDiff) {
      const escaped = content
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');

      try {
        const highlighted = hljs.highlight(escaped, { language }).value;
        highlightRef.current.innerHTML = highlighted + '\n';
      } catch {
        highlightRef.current.textContent = content + '\n';
      }
    }
  }, [content, language, showDiff, showMarkdownPreview]);

  // Focus-gate helper: in panel mode, only handle keyboard events that
  // originate from within the editor surface. In modal mode, the overlay
  // blocks interaction with the rest of the page so global capture is safe.
  const isFocusWithinEditor = useCallback(
    (e: KeyboardEvent) => {
      if (variant === 'modal') return true;
      return !!rootRef.current?.contains(e.target as Node);
    },
    [variant],
  );

  // Escape key — close search first, then editor
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isFocusWithinEditor(e)) {
        if (showSearch) {
          handleSearchClose();
          return;
        }
        handleCloseAttempt();
      }
    };
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [showSearch, handleCloseAttempt, handleSearchClose, isFocusWithinEditor]);

  // Ctrl+S / Cmd+S to save
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's' && !showDiff && !readonly && isFocusWithinEditor(e)) {
        e.preventDefault();
        handleSave();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [showDiff, readonly, handleSave, isFocusWithinEditor]);

  // Cmd+F / Ctrl+F — open search
  // Always intercept when editor is visible: in modal mode the overlay blocks
  // other interactions anyway; in panel mode, the editor is the primary editing
  // surface so CMD+F should target it regardless of current focus.
  // Skipped in diff view and markdown preview (search bar operates on raw content only).
  useEffect(() => {
    if (showDiff || showMarkdownPreview || showSvgPreview) return; // no search in diff/preview modes
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
        e.preventDefault();
        e.stopPropagation();
        setShowSearch(true);
      }
    };
    document.addEventListener('keydown', handleKeyDown, true); // capture phase
    return () => document.removeEventListener('keydown', handleKeyDown, true);
  }, [showDiff, showMarkdownPreview, showSvgPreview]);

  const handleCancel = useCallback(() => {
    if (hasUnsavedEdits) {
      setShowUnsavedWarning(true);
    } else {
      onClose();
    }
  }, [hasUnsavedEdits, onClose]);

  const handleDiscardChanges = useCallback(() => {
    setShowUnsavedWarning(false);
    setContent(initialContent);
    onClose();
  }, [initialContent, onClose]);

  const handleContinueEditing = useCallback(() => {
    setShowUnsavedWarning(false);
  }, []);

  // --- Computed ---

  const lineCount = content.split('\n').length;

  const searchMatches = useMemo(() => {
    if (!searchQuery) return [];
    return findAllMatches(content, searchQuery);
  }, [content, searchQuery]);

  const handleSelect = useCallback(() => {
    if (!textareaRef.current) return;
    const pos = textareaRef.current.selectionStart;
    const textBefore = content.slice(0, pos);
    const line = textBefore.split('\n').length;
    setActiveLineNumber(line);
  }, [content]);

  const handleAttachToChat = useCallback(() => {
    if (!onAttachToChat || isAttached || attachFeedback) return;
    const item: FileTreeItem = {
      id: filePath,
      name: fileName,
      type: 'file',
      path: filePath,
      workspaceId,
      workspaceName: '',
    };
    try {
      onAttachToChat(item);
      setAttachFeedback(true);
      setTimeout(() => setAttachFeedback(false), 2000);
    } catch (err) {
      console.error('Failed to attach file to chat:', err);
    }
  }, [onAttachToChat, isAttached, attachFeedback, filePath, fileName, workspaceId]);

  const handleToggleDiff = useCallback(() => {
    setShowDiff((prev) => !prev);
  }, []);

  // Scroll textarea so the current search match is visible.
  // Does NOT steal focus — the search input keeps focus so user can
  // press Enter repeatedly to navigate matches.
  const scrollToMatch = useCallback((matchIndex: number) => {
    if (!textareaRef.current || searchMatches.length === 0) return;
    const match = searchMatches[matchIndex];
    if (!match) return;
    const ta = textareaRef.current;
    // Scroll to approximately 1/3 from top of viewport
    const lineHeight = 24; // leading-6 = 1.5rem = 24px
    const targetScroll = match.lineIndex * lineHeight - ta.clientHeight / 3;
    ta.scrollTop = Math.max(0, targetScroll);
  }, [searchMatches]);

  // When currentMatchIndex changes, scroll the viewport to that match.
  // Separated from the state setter to avoid side effects inside updaters.
  useEffect(() => {
    if (showSearch && searchMatches.length > 0) {
      scrollToMatch(currentMatchIndex);
    }
  }, [currentMatchIndex, showSearch, searchMatches.length, scrollToMatch]);

  const handleSearchNext = useCallback(() => {
    if (searchMatches.length === 0) return;
    setCurrentMatchIndex((prev) => (prev + 1) % searchMatches.length);
  }, [searchMatches.length]);

  const handleSearchPrevious = useCallback(() => {
    if (searchMatches.length === 0) return;
    setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length);
  }, [searchMatches.length]);

  return (
    <>
      <div
        ref={rootRef}
        className="bg-[var(--color-card)] border border-[var(--color-border)] shadow-2xl flex flex-col h-full w-full rounded-xl overflow-hidden"
        onMouseDown={(e) => e.stopPropagation()}
        data-testid="file-editor-core"
      >
        {/* Header */}
        <div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--color-border)] shrink-0">
          <div className="flex items-center gap-2 min-w-0 flex-1">
            <span
              className="material-symbols-outlined text-lg shrink-0"
              style={{ color: fileIconColor(fileName) }}
            >
              {fileIcon(fileName)}
            </span>
            {gitStatus && (() => {
              const badge = gitStatusBadge(gitStatus);
              if (!badge) return null;
              return (
                <span
                  className="text-[10px] font-bold px-1 py-0.5 rounded shrink-0"
                  style={{ color: badge.color, backgroundColor: badge.bg }}
                >
                  {badge.label}
                </span>
              );
            })()}
            <BreadcrumbBar filePath={filePath} />
            {hasUnsavedEdits && (
              <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning)] bg-opacity-20 text-[var(--color-warning)] shrink-0">
                Modified
              </span>
            )}
          </div>
          <div className="flex items-center gap-1 shrink-0 ml-2">
            {/* Markdown Preview toggle */}
            {isMarkdown && (
              <button
                onClick={() => { setShowMarkdownPreview((p) => !p); if (showDiff) setShowDiff(false); }}
                className={clsx(
                  'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                  showMarkdownPreview
                    ? 'bg-blue-500/20 text-[var(--color-primary)] font-medium'
                    : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]'
                )}
                data-testid="markdown-preview-toggle"
              >
                <span className="material-symbols-outlined text-sm">
                  {showMarkdownPreview ? 'edit' : 'visibility'}
                </span>
                {showMarkdownPreview ? 'Edit' : 'Preview'}
              </button>
            )}
            {/* SVG Preview toggle */}
            {isSvg && (
              <button
                onClick={() => { setShowSvgPreview((p) => !p); if (showDiff) setShowDiff(false); }}
                className={clsx(
                  'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                  showSvgPreview
                    ? 'bg-blue-500/20 text-[var(--color-primary)] font-medium'
                    : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]'
                )}
                data-testid="svg-preview-toggle"
              >
                <span className="material-symbols-outlined text-sm">
                  {showSvgPreview ? 'edit' : 'image'}
                </span>
                {showSvgPreview ? 'Edit' : 'Preview'}
              </button>
            )}
            {/* Review Mode toggle (L3) */}
            <button
              onClick={review.toggleReviewMode}
              className={clsx(
                'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                review.isReviewMode
                  ? 'bg-amber-500/20 text-amber-600 dark:text-amber-400 font-medium'
                  : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]'
              )}
              data-testid="review-mode-toggle"
              title="Toggle review mode — click line numbers to add comments"
            >
              <span className="material-symbols-outlined text-sm">rate_review</span>
              {review.isReviewMode ? 'Exit Review' : 'Review'}
              {review.comments.length > 0 && (
                <span className="ml-0.5 px-1 py-px rounded-full bg-amber-500/30 text-[10px] font-bold">
                  {review.comments.length}
                </span>
              )}
            </button>
            {/* Show Changes toggle */}
            <button
              onClick={handleToggleDiff}
              disabled={!isDirty && !showDiff}
              className={clsx(
                'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                showDiff
                  ? 'bg-blue-500/20 text-[var(--color-primary)] font-medium'
                  : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]',
                !isDirty && !showDiff && 'opacity-40 cursor-not-allowed'
              )}
              data-testid="show-changes-toggle"
            >
              <span className="material-symbols-outlined text-sm">
                {showDiff ? 'edit' : 'difference'}
              </span>
              {showDiff ? 'Back to Edit' : 'Show Changes'}
            </button>
            {/* Open externally — browser for HTML/SVG, system app for PDF/XML */}
            {(() => {
              const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
              const browserRenderable = ['html', 'htm', 'svg'];
              const externalRenderable = ['html', 'htm', 'pdf', 'svg', 'xml'];
              if (!externalRenderable.includes(ext)) return null;
              const useBrowser = browserRenderable.includes(ext);
              return (
                <button
                  onClick={async () => {
                    const absolutePath = getAbsolutePath();
                    try {
                      if (useBrowser) {
                        await openExternal(`file://${absolutePath}`);
                      } else {
                        await openInSystemApp(absolutePath);
                      }
                    } catch {
                      // Fallback: copy path to clipboard so user can paste in browser
                      await copyToClipboard(absolutePath);
                    }
                  }}
                  className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)] transition-colors"
                  data-testid="open-in-browser-btn"
                  title={useBrowser ? 'Open in browser' : 'Open with system app'}
                >
                  <span className="material-symbols-outlined text-sm">open_in_browser</span>
                  Open
                </button>
              );
            })()}
            {/* Copy absolute file path */}
            <button
              onClick={async () => {
                if (copyPathFeedback) return;
                const absolutePath = getAbsolutePath();
                const ok = await copyToClipboard(absolutePath);
                if (ok) {
                  setCopyPathFeedback(true);
                  setTimeout(() => setCopyPathFeedback(false), 2000);
                } else {
                  console.warn('[FileEditor] copyToClipboard failed for:', absolutePath);
                }
              }}
              className={clsx(
                'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                copyPathFeedback
                  ? 'text-[var(--color-success)] cursor-default'
                  : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]'
              )}
              title="Copy absolute file path"
              data-testid="copy-path-btn"
            >
              <span className="material-symbols-outlined text-sm">
                {copyPathFeedback ? 'check' : 'content_copy'}
              </span>
              {copyPathFeedback ? 'Copied' : 'Copy Path'}
            </button>
            {/* Attach to Chat */}
            {onAttachToChat && (
              <button
                onClick={handleAttachToChat}
                disabled={isAttached || attachFeedback}
                className={clsx(
                  'flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors',
                  isAttached || attachFeedback
                    ? 'text-[var(--color-success)] cursor-default'
                    : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)]'
                )}
                data-testid="attach-to-chat-btn"
              >
                <span className="material-symbols-outlined text-sm">
                  {isAttached || attachFeedback ? 'check_circle' : 'attach_file'}
                </span>
                {isAttached || attachFeedback ? 'Attached' : 'Attach'}
              </button>
            )}
            {/* Mode toggle: panel ↔ modal */}
            {onToggleMode && (
              <button
                onClick={onToggleMode}
                className="p-1 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)] transition-colors"
                aria-label={variant === 'panel' ? 'Pop out to modal' : 'Dock as panel'}
                title={variant === 'panel' ? 'Pop out to fullscreen' : 'Dock as side panel'}
                data-testid="mode-toggle"
              >
                <span className="material-symbols-outlined text-lg">
                  {variant === 'panel' ? 'open_in_full' : 'close_fullscreen'}
                </span>
              </button>
            )}
            {/* Close */}
            <button
              onClick={handleCloseAttempt}
              className="p-1 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-hover)] transition-colors"
              aria-label="Close"
            >
              <span className="material-symbols-outlined">close</span>
            </button>
          </div>
        </div>

        {/* Readonly banner */}
        {readonly && (
          <div
            className="flex items-center gap-2 px-4 py-2 text-xs border-b border-[var(--color-border)] bg-amber-500/10 text-amber-700 dark:text-amber-300 shrink-0"
            data-testid="readonly-banner"
          >
            <span>System Default — This file is managed by SwarmAI and refreshed on every startup.</span>
          </div>
        )}

        {/* Editor area */}
        <div className="flex-1 relative overflow-hidden flex">
          {showSearch && (
            <SearchBar
              searchQuery={searchQuery}
              onSearchChange={(q) => { setSearchQuery(q); setCurrentMatchIndex(0); }}
              currentMatch={currentMatchIndex}
              totalMatches={searchMatches.length}
              onNext={handleSearchNext}
              onPrevious={handleSearchPrevious}
              onClose={handleSearchClose}
            />
          )}

          {showDiff ? (
            <div className="flex-1 relative overflow-hidden">
              <DiffView
                lines={diffLines}
                activePopoverIndex={activeDiffPopoverIndex}
                onLineClick={handleDiffLineClick}
                onAddComment={handleDiffAddComment}
                onUpdateComment={review.updateComment}
                onRemoveComment={review.removeComment}
                onCancelPopover={handleDiffCancelPopover}
                getCommentForDiffIndex={getCommentForDiffIndex}
                editingComment={editingDiffComment}
              />
            </div>
          ) : showMarkdownPreview ? (
            <div className="flex-1 relative overflow-auto p-6 bg-[var(--color-background)]">
              <MarkdownRenderer
                content={content}
                className="max-w-4xl mx-auto"
                basePath={filePath.includes('/') ? filePath.replace(/\/[^/]*$/, '') : ''}
              />
            </div>
          ) : showSvgPreview ? (
            <div className="flex-1 relative overflow-auto p-6 bg-[var(--color-background)] flex items-center justify-center">
              <img
                className="max-w-full max-h-full w-auto h-auto"
                src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(content)}`}
                alt="SVG preview"
                data-testid="svg-preview"
              />
            </div>
          ) : (
            <>
              {review.isReviewMode ? (
                <ReviewModeGutter
                  lineCount={lineCount}
                  scrollTop={scrollTop}
                  comments={review.comments}
                  activePopoverLine={review.activePopoverLine}
                  editingCommentId={review.editingCommentId}
                  onLineClick={handleReviewLineClick}
                  onAddComment={review.addComment}
                  onUpdateComment={review.updateComment}
                  onRemoveComment={review.removeComment}
                  onCancelPopover={() => {
                    review.setActivePopoverLine(null);
                    review.setEditingCommentId(null);
                  }}
                  getCommentForLine={review.getCommentForLine}
                />
              ) : (
                <LineGutter
                  lineCount={lineCount}
                  scrollTop={scrollTop}
                  activeLineNumber={activeLineNumber}
                />
              )}
              <div className="flex-1 relative overflow-hidden">
                <pre
                  ref={highlightRef}
                  className={clsx(
                    'absolute inset-0 m-0 p-4 overflow-y-scroll overflow-x-hidden',
                    'font-mono text-sm leading-6 whitespace-pre-wrap break-words',
                    'pointer-events-none',
                    'bg-[var(--color-background)]',
                    '[word-break:break-all]'
                  )}
                  style={{ tabSize: 4 }}
                  aria-hidden="true"
                />
                {/* Search highlight overlay */}
                {showSearch && searchMatches.length > 0 && (
                  <pre
                    className={clsx(
                      'absolute inset-0 m-0 p-4 overflow-y-scroll overflow-x-hidden',
                      'font-mono text-sm leading-6 whitespace-pre-wrap break-words',
                      'pointer-events-none z-[1]',
                      '[word-break:break-all]'
                    )}
                    style={{ scrollBehavior: 'auto', tabSize: 4 }}
                    aria-hidden="true"
                  >
                    {content.split('\n').map((line, lineIdx) => {
                      const lineMatches = searchMatches.filter((m: SearchMatch) => m.lineIndex === lineIdx);
                      if (lineMatches.length === 0) return <div key={lineIdx}>{'\n'}</div>;
                      const parts: React.ReactNode[] = [];
                      let cursor = 0;
                      lineMatches.forEach((m: SearchMatch, mi: number) => {
                        if (m.startOffset > cursor) {
                          parts.push(<span key={`t${mi}`} className="invisible">{line.slice(cursor, m.startOffset)}</span>);
                        }
                        const isCurrentMatch = searchMatches.indexOf(m) === currentMatchIndex;
                        parts.push(
                          <mark
                            key={`m${mi}`}
                            className={isCurrentMatch
                              ? 'bg-[var(--color-warning)] text-[var(--color-text)] rounded-sm'
                              : 'bg-[var(--color-warning)]/30 text-transparent rounded-sm'
                            }
                            data-testid={isCurrentMatch ? 'current-search-match' : undefined}
                          >
                            {line.slice(m.startOffset, m.startOffset + m.length)}
                          </mark>
                        );
                        cursor = m.startOffset + m.length;
                      });
                      if (cursor < line.length) {
                        parts.push(<span key="tail" className="invisible">{line.slice(cursor)}</span>);
                      }
                      return <div key={lineIdx}>{parts}{'\n'}</div>;
                    })}
                  </pre>
                )}
                <textarea
                  ref={textareaRef}
                  value={content}
                  onChange={(e) => { if (!readonly && !review.isReviewMode) { setContent(e.target.value); onContentChange?.(e.target.value); } }}
                  onScroll={handleScroll}
                  onSelect={handleSelect}
                  onClick={handleSelect}
                  onKeyUp={handleSelect}
                  readOnly={readonly || review.isReviewMode}
                  className={clsx(
                    'absolute inset-0 m-0 p-4 resize-none appearance-none',
                    'font-mono text-sm leading-6 whitespace-pre-wrap break-words',
                    'bg-transparent text-transparent caret-[var(--color-text)]',
                    'border-none outline-none',
                    'overflow-y-scroll overflow-x-hidden',
                    '[word-break:break-all]',
                    readonly && 'cursor-default'
                  )}
                  style={{ tabSize: 4 }}
                  spellCheck={false}
                  autoCapitalize="off"
                  autoCorrect="off"
                  data-testid="file-editor-textarea"
                />
              </div>
            </>
          )}
        </div>

        {/* Diff review hint — shown when in diff mode with no comments yet */}
        {showDiff && review.comments.length === 0 && (
          <div className="flex items-center gap-2 px-4 py-1.5 border-t border-[var(--color-border)] bg-[var(--color-hover)]/50 shrink-0">
            <span className="material-symbols-outlined text-sm text-[var(--color-text-muted)]">touch_app</span>
            <span className="text-xs text-[var(--color-text-muted)]">
              Click any line to add review comments
            </span>
          </div>
        )}

        {/* Review Feedback Bar (L3) — shown in review mode OR diff mode with comments */}
        {(review.isReviewMode || (showDiff && review.comments.length > 0)) && (
          <ReviewFeedbackBar
            commentCount={review.comments.length}
            feedbackText={feedbackText}
            onFeedbackSent={handleReviewFeedbackSent}
            onClearComments={() => {
              review.clearComments();
              setActiveDiffPopoverIndex(null);
              setEditingDiffComment(null);
            }}
          />
        )}

        {/* Footer */}
        <div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--color-border)] shrink-0">
          <div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
            {language !== 'plaintext' && (
              <span className="px-2 py-1 rounded bg-[var(--color-hover)]">
                {language}
              </span>
            )}
          </div>
          <div className="flex items-center gap-2">
            <Button
              variant="secondary"
              size="sm"
              onClick={handleCancel}
              disabled={isSaving}
              data-testid="file-editor-cancel"
            >
              {variant === 'panel' ? 'Close' : 'Cancel'}
            </Button>
            <Button
              variant="primary"
              size="sm"
              onClick={handleSave}
              isLoading={isSaving}
              disabled={!hasUnsavedEdits || showDiff || readonly}
              data-testid="file-editor-save"
            >
              Save
            </Button>
          </div>
        </div>
      </div>

      {/* Unsaved Changes Warning */}
      {showUnsavedWarning && (
        <div
          className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/50"
          onMouseDown={(e) => e.stopPropagation()}
        >
          <div className="w-full max-w-md bg-[var(--color-card)] border border-[var(--color-border)] rounded-xl shadow-2xl p-6">
            <div className="flex items-center gap-3 mb-4">
              <span className="material-symbols-outlined text-2xl text-[var(--color-warning)]">
                warning
              </span>
              <h3 className="text-lg font-semibold text-[var(--color-text)]">
                Unsaved Changes
              </h3>
            </div>
            <p className="text-[var(--color-text-muted)] mb-6">
              You have unsaved changes in <strong>{fileName}</strong>. Do you want to discard them?
            </p>
            <div className="flex justify-end gap-2">
              <Button
                variant="secondary"
                size="sm"
                onClick={handleContinueEditing}
                data-testid="unsaved-warning-continue"
              >
                Continue Editing
              </Button>
              <Button
                variant="danger"
                size="sm"
                onClick={handleDiscardChanges}
                data-testid="unsaved-warning-discard"
              >
                Discard Changes
              </Button>
            </div>
          </div>
        </div>
      )}
    </>
  );
}
