import {
  Suspense,
  lazy,
  memo,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, Code, Save } from "lucide-react";
import type { editor as MonacoNs } from "monaco-editor";
import {
  selectActiveFileTabPath,
  useAppStore,
} from "../../stores/useAppStore";
import {
  resolveHotkeyAction,
} from "../../hotkeys/bindings";
import { isMacHotkeyPlatform } from "../../hotkeys/platform";
import { tooltipWithHotkey } from "../../hotkeys/display";
import { fileBufferKey } from "../../stores/slices/fileTreeSlice";
import {
  loadDiffFiles,
  readAgentManagedFile,
  readWorkspaceFileBytes,
  readWorkspaceFileForViewer,
  writeWorkspaceFile,
} from "../../services/tauri";
import {
  agentFileKindI18nKey,
  classifyAgentFile,
} from "../../utils/agentFiles";
import { WorkspacePanelHeader } from "../shared/WorkspacePanelHeader";
import { PaneToolbar } from "../shared/PaneToolbar";
import { SegmentedControl } from "../shared/SegmentedControl";
import { IconButton } from "../shared/IconButton";
import { SessionTabs } from "../chat/SessionTabs";
import { MessageMarkdown } from "../chat/MessageMarkdown";
import { MarkdownImageBaseProvider } from "../chat/MarkdownImage";
import { DiscardUnsavedChangesConfirm } from "../files/DiscardUnsavedChangesConfirm";
import { imageMediaType, isImagePath } from "../../utils/fileIcons";
import { useFilePathActions } from "../files/useFilePathActions";
import { EditorMenubar } from "./editor-menubar/EditorMenubar";
import styles from "./FileViewer.module.css";

const MonacoEditor = lazy(() =>
  import("./MonacoEditor").then((m) => ({ default: m.MonacoEditor })),
);

const MARKDOWN_EXT = /\.(md|markdown|mdx)$/i;
/** Hard cap above which Edit mode is disabled. Aligns with the backend's
 *  viewer-text cap so the user gets the disabled-button affordance before
 *  hitting a backend truncation. */
const EDIT_SIZE_LIMIT_BYTES = 5 * 1024 * 1024;

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export const FileViewer = memo(function FileViewer() {
  const { t } = useTranslation("chat");
  const workspaceId = useAppStore((s) => s.selectedWorkspaceId);
  const path = useAppStore(selectActiveFileTabPath);

  if (!workspaceId || !path) return null;

  return <FileViewerInner workspaceId={workspaceId} path={path} t={t} />;
});

interface FileViewerInnerProps {
  workspaceId: string;
  path: string;
  t: ReturnType<typeof useTranslation<"chat">>["t"];
}

function FileViewerInner({ workspaceId, path, t }: FileViewerInnerProps) {
  const bufferKey = fileBufferKey(workspaceId, path);
  const bufferState = useAppStore((s) => s.fileBuffers[bufferKey]);
  const revealTarget = useAppStore((s) => {
    const target = s.fileRevealTargetByWorkspace[workspaceId];
    return target?.path === path ? target : null;
  });
  const setFileBufferLoaded = useAppStore((s) => s.setFileBufferLoaded);
  const setFileBufferLoadError = useAppStore((s) => s.setFileBufferLoadError);
  const setFileBufferContent = useAppStore((s) => s.setFileBufferContent);
  const setFileBufferSaved = useAppStore((s) => s.setFileBufferSaved);
  const setDiffFiles = useAppStore((s) => s.setDiffFiles);
  const setFileTabPreview = useAppStore((s) => s.setFileTabPreview);
  const closeFileTab = useAppStore((s) => s.closeFileTab);
  const clearFileRevealTarget = useAppStore((s) => s.clearFileRevealTarget);
  const requestFileTreeRefresh = useAppStore((s) => s.requestFileTreeRefresh);
  const addToast = useAppStore((s) => s.addToast);
  const keybindings = useAppStore((s) => s.keybindings);
  const { undoLastFilePathOperation } = useFilePathActions(workspaceId);

  const [savingKey, setSavingKey] = useState<string | null>(null);
  const [closePending, setClosePending] = useState(false);
  const saving = savingKey === bufferKey;
  const viewerRef = useRef<HTMLDivElement | null>(null);
  // Live reference to Monaco's editor instance. Owned here so the
  // EditorMenubar can dispatch actions (Find, Format, Undo, …) against
  // the same editor the user is typing in. MonacoEditor populates this
  // via `onEditorReady` and clears it on unmount; the menubar's handler
  // bag null-guards every read so a click during the brief unmount
  // window is a no-op rather than a crash.
  const editorRef = useRef<MonacoNs.IStandaloneCodeEditor | null>(null);
  const handleEditorReady = useCallback(
    (editor: MonacoNs.IStandaloneCodeEditor | null) => {
      editorRef.current = editor;
    },
    [],
  );

  const isMarkdown = MARKDOWN_EXT.test(path);
  const isImage = isImagePath(path);
  // Agent-managed files (plans, memory) are opened by absolute path and
  // live outside the worktree. They load through the allow-listed
  // `read_agent_managed_file` route and render strictly read-only.
  const agentFile = useMemo(() => classifyAgentFile(path), [path]);

  // Initial load. Triggered when the buffer entry exists but isn't loaded
  // yet (`loaded=false`). The slice seeds an unloaded entry on `openFileTab`,
  // so this fires exactly once per opened tab. Subsequent tab switches
  // back to this path skip the fetch — the buffer's already in the store.
  const loadVersionRef = useRef(0);
  useEffect(() => {
    if (!bufferState || bufferState.loaded) return;
    const version = ++loadVersionRef.current;

    if (agentFile) {
      // Agent-managed files load through the allow-listed backend route.
      // They are always markdown, never images.
      readAgentManagedFile(path)
        .then((res) => {
          if (version !== loadVersionRef.current) return;
          setFileBufferLoaded(workspaceId, path, {
            baseline: res.content ?? "",
            isBinary: res.is_binary,
            isSymlink: res.is_symlink,
            sizeBytes: res.size_bytes,
            truncated: res.truncated,
            imageBytesB64: null,
          });
        })
        .catch((e) => {
          if (version !== loadVersionRef.current) return;
          setFileBufferLoadError(workspaceId, path, String(e));
        });
    } else if (isImage) {
      readWorkspaceFileBytes(workspaceId, path)
        .then((res) => {
          if (version !== loadVersionRef.current) return;
          setFileBufferLoaded(workspaceId, path, {
            baseline: "",
            isBinary: false,
            // Images don't render the gutter — `isSymlink` only matters
            // when we'd otherwise paint a noisy diff. Default to false.
            isSymlink: false,
            sizeBytes: res.size_bytes,
            truncated: res.truncated,
            imageBytesB64: res.bytes_b64,
          });
        })
        .catch((e) => {
          if (version !== loadVersionRef.current) return;
          setFileBufferLoadError(workspaceId, path, String(e));
        });
    } else {
      readWorkspaceFileForViewer(workspaceId, path)
        .then((res) => {
          if (version !== loadVersionRef.current) return;
          const baseline = res.content ?? "";
          setFileBufferLoaded(workspaceId, path, {
            baseline,
            isBinary: res.is_binary,
            isSymlink: res.is_symlink,
            sizeBytes: res.size_bytes,
            truncated: res.truncated,
            imageBytesB64: null,
          });
        })
        .catch((e) => {
          if (version !== loadVersionRef.current) return;
          if (isMissingWorkspaceFileError(e)) {
            setFileBufferLoaded(workspaceId, path, {
              baseline: "",
              isBinary: false,
              isSymlink: false,
              sizeBytes: 0,
              truncated: false,
              imageBytesB64: null,
            });
            return;
          }
          setFileBufferLoadError(workspaceId, path, String(e));
        });
    }
  }, [
    bufferState,
    isImage,
    agentFile,
    workspaceId,
    path,
    setFileBufferLoaded,
    setFileBufferLoadError,
  ]);

  const dirty = !!bufferState && bufferState.buffer !== bufferState.baseline;

  const requestCloseFileTab = useCallback(() => {
    if (dirty) {
      setClosePending(true);
    } else {
      closeFileTab(workspaceId, path);
    }
  }, [closeFileTab, dirty, path, workspaceId]);

  // Files we render in the editor but won't let the user mutate. The
  // truncated banner below the editor explains the truncated case; the
  // others are obvious from the rendered output (image, "preview not
  // available" for binary) or rare enough not to warrant chrome (oversize
  // files between the edit cap and the viewer cap render read-only with
  // no banner — Monaco's read-only cursor signals it).
  //
  // Agent-managed files are always read-only: they're inspected from chat,
  // not edited, and there's no in-app save route back to their home dir.
  const editDisabled =
    !!agentFile ||
    (!!bufferState &&
      bufferState.loaded &&
      (isImage ||
        bufferState.isBinary ||
        bufferState.sizeBytes > EDIT_SIZE_LIMIT_BYTES ||
        bufferState.truncated));

  const handleBufferChange = useCallback(
    (next: string) => {
      setFileBufferContent(workspaceId, path, next);
    },
    [workspaceId, path, setFileBufferContent],
  );
  const handleRevealTargetApplied = useCallback(
    (nonce: number) => {
      clearFileRevealTarget(workspaceId, nonce);
    },
    [clearFileRevealTarget, workspaceId],
  );

  const handleSave = useCallback(async () => {
    if (!bufferState || !dirty || saving) return;
    const requestedWorkspaceId = workspaceId;
    const requestedPath = path;
    const requestedBufferKey = bufferKey;
    const snapshot = bufferState.buffer;
    setSavingKey(requestedBufferKey);
    try {
      await writeWorkspaceFile(requestedWorkspaceId, requestedPath, snapshot);
      // The user may have switched tabs mid-save. Always update the
      // baseline of the path we actually wrote — the saved file is canonical
      // regardless of which tab is now active. Just don't show the toast on
      // a different tab/workspace to avoid confusing the user about which
      // file saved.
      setFileBufferSaved(requestedWorkspaceId, requestedPath, snapshot);
      requestFileTreeRefresh(requestedWorkspaceId);
      loadDiffFiles(requestedWorkspaceId)
        .then((result) => {
          if (useAppStore.getState().selectedWorkspaceId !== requestedWorkspaceId) {
            return;
          }
          setDiffFiles(result.files, result.merge_base, result.staged_files, result.commits);
        })
        .catch((err) =>
          console.error("Failed to refresh diff after file save:", err),
        );
      const state = useAppStore.getState();
      if (
        state.selectedWorkspaceId === requestedWorkspaceId &&
        selectActiveFileTabPath(state) === requestedPath
      ) {
        addToast(t("file_save_success"));
      }
    } catch (e) {
      console.error("Save failed:", e);
      // Mirror the success guard: only surface the failure toast in the
      // workspace+tab the user actually triggered the save from. The buffer
      // stays dirty regardless so the user doesn't lose work, and the
      // console.error above gives an unconditional record of the failure.
      const state = useAppStore.getState();
      if (
        state.selectedWorkspaceId === requestedWorkspaceId &&
        selectActiveFileTabPath(state) === requestedPath
      ) {
        addToast(t("file_save_failed", { error: String(e) }));
      }
    } finally {
      setSavingKey((current) =>
        current === requestedBufferKey ? null : current,
      );
    }
  }, [
    bufferState,
    bufferKey,
    dirty,
    saving,
    workspaceId,
    path,
    setFileBufferSaved,
    setDiffFiles,
    requestFileTreeRefresh,
    addToast,
    t,
  ]);

  // Agent-managed markdown is read-only but still gets the source/preview
  // toggle — inspecting a plan or memory note as rendered markdown (à la
  // the inline plan card) is the common case.
  const showMarkdownToggle = isMarkdown && (!editDisabled || !!agentFile);
  const showMarkdownPreview =
    showMarkdownToggle &&
    bufferState?.preview === "preview" &&
    bufferState?.loaded;
  const showSourceEditor =
    !isImage && !bufferState?.isBinary && !showMarkdownPreview;
  const showSaveButton =
    dirty && !editDisabled && !isImage && !bufferState?.isBinary && !showMarkdownPreview;

  const previewMode = bufferState?.preview ?? "source";
  const togglePreview = useCallback(() => {
    setFileTabPreview(
      workspaceId,
      path,
      previewMode === "preview" ? "source" : "preview",
    );
  }, [workspaceId, path, previewMode, setFileTabPreview]);

  // Cmd/Ctrl+Shift+V — toggle source/preview for the active markdown file
  // tab, mirroring the VS Code shortcut. The handler bows out when:
  //   * an overlay owns focus (settings, command palette, fuzzy finder,
  //     modal),
  //   * focus is in a typing target outside Monaco — chat composer textarea,
  //     terminal xterm textarea, contenteditable. On those Ctrl+Shift+V is
  //     commonly the "paste without formatting" shortcut and we don't want
  //     to hijack it. Monaco is exempt because that's the source view this
  //     shortcut is meant to flip out of — its hidden <textarea> would
  //     otherwise swallow the keystroke.
  //   * the event is a key-repeat. Holding the keys would otherwise toggle
  //     repeatedly, which is useless and disorienting.
  useEffect(() => {
    if (!showMarkdownToggle) return;
    const handler = (e: KeyboardEvent) => {
      if (e.repeat) return;
      const state = useAppStore.getState();
      if (
        resolveHotkeyAction(e, "file-viewer", state.keybindings) !==
        "file-viewer.toggle-markdown-preview"
      ) {
        return;
      }
      if (
        state.settingsOpen ||
        state.activeModal ||
        state.commandPaletteOpen ||
        state.fuzzyFinderOpen
      ) {
        return;
      }
      const el = document.activeElement as HTMLElement | null;
      const tag = el?.tagName?.toLowerCase();
      const editable =
        tag === "input" || tag === "textarea" || el?.isContentEditable;
      const inMonaco = !!el?.closest(".monaco-editor");
      if (editable && !inMonaco) return;
      e.preventDefault();
      togglePreview();
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [showMarkdownToggle, togglePreview]);

  // Undo file operations (rename / delete / create) is the only
  // file-viewer-scope hotkey left here — close-tab moved to a global
  // action that routes through `requestCloseFileTabNonceByWorkspace`
  // below, so a single Cmd+W binding handles file / diff / chat.
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.repeat) return;
      const state = useAppStore.getState();
      const action = resolveHotkeyAction(e, "file-viewer", state.keybindings);
      if (action !== "file-viewer.undo-file-operation") return;
      if (
        state.settingsOpen ||
        state.activeModal ||
        state.commandPaletteOpen ||
        state.fuzzyFinderOpen
      ) {
        return;
      }
      const el = document.activeElement as HTMLElement | null;
      if (!el || !viewerRef.current?.contains(el)) return;
      const tag = el.tagName?.toLowerCase();
      const inMonaco = !!el.closest(".monaco-editor");
      // Inside Monaco, Ctrl/Cmd+Z is an editor command (text undo) — let
      // Monaco handle it instead of triggering filesystem undo.
      if (inMonaco) return;
      if (tag === "input" || tag === "textarea" || el.isContentEditable) {
        return;
      }
      e.preventDefault();
      void undoLastFilePathOperation();
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [undoLastFilePathOperation]);

  // Bridge between the global Cmd+W handler and this viewer's
  // dirty-aware close path. The global handler bumps the per-workspace
  // nonce; the FileViewer reacts by running the same `requestCloseFileTab`
  // its own close button uses, so the unsaved-changes prompt fires
  // identically whether the user clicks × or hits the keystroke.
  //
  // Handled-nonce tracking is keyed by workspace and initializes from the
  // current value. A nonce is a "new close request" signal, not persistent
  // desired state; consuming an old value would immediately close every file
  // reopened after the first Cmd+W/tab-close request in that workspace.
  const closeFileTabNonce = useAppStore(
    (s) => s.requestCloseFileTabNonceByWorkspace[workspaceId] ?? 0,
  );
  const handledCloseNonces = useRef<Map<string, number>>(new Map());
  useEffect(() => {
    const handled = handledCloseNonces.current.get(workspaceId) ?? 0;
    if (!handledCloseNonces.current.has(workspaceId)) {
      handledCloseNonces.current.set(workspaceId, closeFileTabNonce);
      return;
    }
    if (closeFileTabNonce === 0) return;
    if (closeFileTabNonce === handled) return;
    handledCloseNonces.current.set(workspaceId, closeFileTabNonce);
    requestCloseFileTab();
  }, [closeFileTabNonce, requestCloseFileTab, workspaceId]);

  const isMac = isMacHotkeyPlatform();

  // Resolution context for relative `<img>` references inside the rendered
  // markdown. Workspace-relative paths in a README — e.g. `./assets/logo.png`
  // — get joined onto the directory of this file so `MarkdownImage` can
  // load them through the existing `read_workspace_file_bytes` command.
  const markdownImageBase = useMemo(() => {
    const slash = path.lastIndexOf("/");
    return { workspaceId, dir: slash === -1 ? "" : path.slice(0, slash) };
  }, [workspaceId, path]);

  return (
    <div
      ref={viewerRef}
      className={styles.viewer}
      tabIndex={-1}
      onPointerDownCapture={(event) => {
        const target = event.target as HTMLElement | null;
        if (
          target?.closest(
            "button,input,textarea,a,[contenteditable='true'],.monaco-editor",
          )
        ) {
          return;
        }
        viewerRef.current?.focus({ preventScroll: true });
      }}
    >
      <WorkspacePanelHeader />
      <SessionTabs workspaceId={workspaceId} />
      <EditorMenubar
        // Remount on file switch so the menubar's `useEditorActions`
        // bag captures the new file's editor / baseline cleanly — the
        // same `key={path}` pattern the Monaco editor already uses.
        key={`menubar/${workspaceId}/${path}`}
        workspaceId={workspaceId}
        path={path}
        dirty={dirty}
        // Two gates for the menubar:
        //   * `hasEditor`: a live Monaco model is mounted. Read-only
        //     commands (Find, Go to Line, Copy File Contents) are safe
        //     against oversize/truncated/read-only files — they all
        //     still render the source editor, just with `readOnly:true`.
        //   * `canMutate`: Monaco accepts edits. Gates Save, Undo,
        //     Redo, Replace, Format Document.
        // Both clear for images / binary preview / markdown preview
        // where Monaco isn't rendered at all.
        hasEditor={
          !!bufferState?.loaded &&
          !isImage &&
          !bufferState.isBinary &&
          !showMarkdownPreview
        }
        canMutate={
          !!bufferState?.loaded &&
          !isImage &&
          !bufferState.isBinary &&
          !editDisabled &&
          !showMarkdownPreview
        }
        editorRef={editorRef}
        onSave={handleSave}
        onCloseTab={requestCloseFileTab}
      />
      <PaneToolbar
        path={path}
        dirty={dirty}
        badge={
          agentFile ? (
            <span
              className={styles.agentBadge}
              title={t("file_agent_managed_readonly")}
            >
              {t(agentFileKindI18nKey(agentFile.kind))}
            </span>
          ) : undefined
        }
        actions={
          <>
            {showMarkdownToggle && (
              <SegmentedControl
                ariaLabel={t("file_markdown_view_mode_aria")}
                value={bufferState!.preview}
                onChange={(p) => setFileTabPreview(workspaceId, path, p)}
                options={[
                  {
                    value: "source",
                    icon: <Code size={14} aria-hidden="true" />,
                    tooltip: tooltipWithHotkey(
                      t("file_tooltip_source"),
                      "file-viewer.toggle-markdown-preview",
                      keybindings,
                      isMac,
                    ),
                  },
                  {
                    value: "preview",
                    icon: <BookOpen size={14} aria-hidden="true" />,
                    tooltip: tooltipWithHotkey(
                      t("file_tooltip_markdown_preview"),
                      "file-viewer.toggle-markdown-preview",
                      keybindings,
                      isMac,
                    ),
                  },
                ]}
              />
            )}
            {showSaveButton && (
              <IconButton
                onClick={handleSave}
                tooltip={t("file_tooltip_save")}
                disabled={saving}
              >
                <Save size={14} aria-hidden="true" />
              </IconButton>
            )}
          </>
        }
      />
      <div className={styles.content}>
        {!bufferState || !bufferState.loaded ? (
          <div className={styles.center}>{t("file_loading")}</div>
        ) : bufferState.loadError ? (
          <div className={styles.center}>
            {t("file_load_failed", { error: bufferState.loadError })}
          </div>
        ) : isImage && bufferState.truncated ? (
          // Partial image bytes won't decode cleanly — surface a clear message
          // instead of letting the browser render a broken-image placeholder.
          <div className={styles.center}>
            {t("file_image_truncated", { size: formatBytes(bufferState.sizeBytes) })}
          </div>
        ) : isImage && bufferState.imageBytesB64 ? (
          <ImageView
            bytesB64={bufferState.imageBytesB64}
            sizeBytes={bufferState.sizeBytes}
            filename={path}
          />
        ) : bufferState.isBinary ? (
          <div className={styles.center}>{t("file_preview_not_available")}</div>
        ) : showMarkdownPreview ? (
          <div className={styles.markdownBody}>
            <MarkdownImageBaseProvider value={markdownImageBase}>
              <MessageMarkdown content={bufferState.buffer} />
            </MarkdownImageBaseProvider>
          </div>
        ) : showSourceEditor ? (
          <Suspense fallback={<div className={styles.center}>{t("file_loading")}</div>}>
            <MonacoEditor
              key={path}
              workspaceId={workspaceId}
              // Drive Monaco from the store's buffer so external file
              // changes (agent edits, disk rewrites) flow in via
              // `applyExternalFileChange` → controlled-`value` →
              // `executeEdits` (preserves cursor + undo).
              value={bufferState.buffer}
              filename={path}
              revealTarget={revealTarget}
              onRevealTargetApplied={handleRevealTargetApplied}
              isSymlink={bufferState.isSymlink}
              disableGitGutter={!!agentFile}
              readOnly={editDisabled}
              onChange={handleBufferChange}
              onSave={handleSave}
              onEditorReady={handleEditorReady}
            />
          </Suspense>
        ) : (
          <div className={styles.center}>{t("file_preview_not_available")}</div>
        )}
        {bufferState?.truncated && (
          <div className={styles.truncatedBanner}>
            {t("file_truncated_banner", {
              size: formatBytes(bufferState.sizeBytes),
            })}
          </div>
        )}
      </div>
      {closePending && (
        <DiscardUnsavedChangesConfirm
          onConfirm={() => {
            setClosePending(false);
            closeFileTab(workspaceId, path);
          }}
          onClose={() => setClosePending(false)}
        />
      )}
    </div>
  );
}

function isMissingWorkspaceFileError(error: unknown): boolean {
  return String(error) === "WORKSPACE_FILE_NOT_FOUND";
}

interface ImageViewProps {
  bytesB64: string;
  sizeBytes: number;
  filename: string;
}

function ImageView({ bytesB64, sizeBytes, filename }: ImageViewProps) {
  const mediaType = imageMediaType(filename) ?? "image/png";
  const dataUrl = `data:${mediaType};base64,${bytesB64}`;
  const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(
    null,
  );
  return (
    <div className={styles.imageWrap}>
      <img
        src={dataUrl}
        alt={filename}
        className={styles.image}
        onLoad={(e) => {
          const img = e.currentTarget;
          setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
        }}
      />
      {dimensions && (
        <div className={styles.imageMeta}>
          {dimensions.w} × {dimensions.h} · {formatBytes(sizeBytes)}
        </div>
      )}
    </div>
  );
}
