import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import {
  checkAppShortcut,
  getAppLevelActions,
  getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import {
  XTERM_PERFORMANCE_CONFIG,
  type XTermPlatform,
  resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import {
  shouldEnableNativeUserInputAutoScroll,
  shouldScrollOnTerminalInput,
  shouldScrollOnTerminalPaste,
} from "../../../domain/terminalScroll";
import {
  resolveHostTerminalFontFamilyId,
  resolveHostTerminalFontSize,
  resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
import { logger } from "../../../lib/logger";
import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
  clearTerminalViewport,
  isEraseViewportSequence,
  isEraseScrollbackSequence,
  preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import {
  createKittyKeyboardModeState,
  encodeKittyControlKey,
} from "./kittyKeyboardProtocol";
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
  nextTerminalFontSizeForAction,
  nextTerminalFontSizeForWheel,
  terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import {
  markExpectedTerminalCursorPositionReport,
  pasteTextIntoTerminal,
  shouldBroadcastTerminalUserInput,
  shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
import {
  type PromptLineBreakState,
} from "./promptLineBreak";
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import {
  getSingleBracketedPasteLine,
  getSinglePastedCommand,
  prepareSudoAutofillInput,
  type SudoPasswordAutofill,
} from "./terminalSudoAutofill";
import type {
  Host,
  KeyBinding,
  TerminalSession,
  TerminalSettings,
  TerminalTheme,
} from "../../../types";
import { matchesKeyBinding, type Snippet } from "../../../domain/models";

type TerminalBackendApi = {
  openExternalAvailable: () => boolean;
  openExternal: (url: string) => Promise<void>;
  writeToSession: (sessionId: string, data: string) => void;
  resizeSession: (sessionId: string, cols: number, rows: number) => void;
};

export type XTermRuntime = {
  term: XTerm;
  fitAddon: FitAddon;
  serializeAddon: SerializeAddon;
  searchAddon: SearchAddon;
  dispose: () => void;
  /** Current working directory detected via OSC 7 */
  currentCwd: string | undefined;
  keywordHighlighter: KeywordHighlighter;
  /**
   * Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
   * next frame. No-op when the DOM renderer is active. Used to recover from the
   * persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
   * fall into after font changes or device pixel ratio changes.
   */
  clearTextureAtlas: () => void;
  /**
   * Create the WebGL renderer if it was deferred (pane mounted hidden) and has
   * not been created yet. Idempotent; a no-op when WebGL is disabled or already
   * active. Called when a deferred pane first becomes visible.
   */
  ensureWebglRenderer: () => void;
};

export type CreateXTermRuntimeContext = {
  container: HTMLDivElement;
  host: Host;
  fontFamilyId: string;
  resolvedFontFamily: string;
  fontSize: number;
  terminalTheme: TerminalTheme;
  terminalSettingsRef: RefObject<TerminalSettings | undefined>;
  terminalBackend: TerminalBackendApi;
  sessionRef: RefObject<string | null>;

  hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
  keyBindingsRef: RefObject<KeyBinding[]>;
  onHotkeyActionRef: RefObject<
    ((action: string, event: KeyboardEvent) => void) | undefined
  >;
  onTerminalFontSizeChange?: (fontSize: number) => void;

  isBroadcastEnabledRef: RefObject<boolean | undefined>;
  onBroadcastInputRef: RefObject<
    ((data: string, sourceSessionId: string) => void) | undefined
  >;

  // Snippets for shortkey support
  snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string; noAutoRun?: boolean }[]>;
  onSnippetShortkeyRef?: RefObject<((snippet: Snippet) => void) | undefined>;

  sessionId: string;
  statusRef: RefObject<TerminalSession["status"]>;
  onCommandExecuted?: (
    command: string,
    hostId: string,
    hostLabel: string,
    sessionId: string,
  ) => void;
  onCommandSubmitted?: (
    command: string,
    hostId: string,
    hostLabel: string,
    sessionId: string,
  ) => void;
  commandBufferRef: RefObject<string>;
  promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
  sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
  setIsSearchOpen: Dispatch<SetStateAction<boolean>>;

  // Serial-specific options
  serialLocalEcho?: boolean;
  serialLineMode?: boolean;
  serialLineBufferRef?: RefObject<string>;
  onTerminalLogData?: (data: string) => void;

  // Callback when shell reports CWD change via OSC 7
  onCwdChange?: (cwd: string) => void;

  // Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
  onOsc52ReadRequest?: () => Promise<boolean>;

  // Autocomplete key event handler — returns false if event was consumed
  onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
  // Autocomplete input handler — called on every character input
  onAutocompleteInput?: (data: string) => void;

  // Set to true while we're programmatically restoring a selection so that
  // copy-on-select listeners can suppress redundant clipboard writes.
  isRestoringSelectionRef?: RefObject<boolean>;

  // Whether the pane is visible at creation time. When false, WebGL renderer
  // creation is deferred until the pane first becomes visible (batch-connect
  // background tabs) to avoid spinning up many WebGL contexts at once. Defaults
  // to visible (immediate WebGL) when omitted.
  initiallyVisible?: boolean;
};

const detectPlatform = (): XTermPlatform => {
  if (
    typeof process !== "undefined" &&
    (process.platform === "darwin" ||
      process.platform === "win32" ||
      process.platform === "linux")
  ) {
    return process.platform;
  }

  if (typeof navigator !== "undefined") {
    const ua = navigator.userAgent.toLowerCase();
    if (ua.includes("win")) return "win32";
    if (ua.includes("linux")) return "linux";
  }

  return "darwin";
};

const csiParamsInclude = (
  params: readonly (number | number[])[],
  target: number,
): boolean => params.some((param) => (
  Array.isArray(param)
    ? param.includes(target)
    : param === target
));

/**
 * Extract the primary font family from a CSS font-family string that may
 * include fallback fonts. Used by autocomplete and other helpers that need
 * the first face without the CJK / icon fallback stack.
 */
export const primaryFontFamily = (fontFamily: string): string => {
  // Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
  const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
  const first = match?.[0]?.trim();
  return first || fontFamily;
};

export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
  const platform = detectPlatform();
  const deviceMemoryGb =
    typeof navigator !== "undefined" &&
      typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
      ? (navigator as { deviceMemory?: number }).deviceMemory
      : undefined;

  const settings = ctx.terminalSettingsRef.current;
  const rendererType = settings?.rendererType ?? "auto";
  const bridge = netcattyBridge.get();
  const isLocalTerminalHost = ctx.host.protocol === "local";
  const windowsPty =
    platform === "win32" && isLocalTerminalHost
      ? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
      : undefined;

  const performanceConfig = resolveXTermPerformanceConfig({
    platform,
    deviceMemoryGb,
    rendererType,
  });

  const hostFontId = resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId) || "menlo";
  // Use fontStore for font lookup - guarantees non-empty result
  const fontObj = fontStore.getFontById(hostFontId);
  const fontFamily = ctx.resolvedFontFamily || fontObj.family;

  const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);

  const cursorStyle = settings?.cursorShape ?? "block";
  const cursorBlink = settings?.cursorBlink ?? true;
  // xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
  // wheel scrolling (events become arrow-key sequences).  The UI uses 0 to mean
  // "no limit", so map it to a large value instead.
  const rawScrollback = settings?.scrollback ?? 10000;
  const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
  const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
  const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
  const fontWeightBold = settings?.fontWeightBold ?? 700;
  const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
  const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
  const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
  const smoothScrollDuration = settings?.smoothScrolling
    ? performanceConfig.options.smoothScrollDuration
    : 0;
  const altIsMeta = settings?.altAsMeta ?? false;
  const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
  const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
  const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
  const kittyKeyboardMode = createKittyKeyboardModeState();

  const resolvedFontWeightBold = resolveFontWeightBold({
    fontFamilyCss: fontFamily,
    normalWeight: fontWeight,
    desiredBoldWeight: fontWeightBold,
    fontSize: effectiveFontSize,
  });

  const term = new XTerm({
    ...performanceConfig.options,
    ...(windowsPty ? { windowsPty } : {}),
    // Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
    ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
    // Rescale glyphs that would visually overlap into the next cell (CJK compliance)
    rescaleOverlappingGlyphs: true,
    fontSize: effectiveFontSize,
    fontFamily,
    fontWeight: fontWeight as
      | 100
      | 200
      | 300
      | 400
      | 500
      | 600
      | 700
      | 800
      | 900
      | "normal"
      | "bold",
    fontWeightBold: resolvedFontWeightBold as
      | 100
      | 200
      | 300
      | 400
      | 500
      | 600
      | 700
      | 800
      | 900
      | "normal"
      | "bold",
    lineHeight,
    cursorStyle,
    cursorBlink,
    scrollback,
    // Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
    allowProposedApi: true,
    drawBoldTextInBrightColors,
    minimumContrastRatio,
    smoothScrollDuration,
    scrollOnUserInput,
    macOptionClickForcesSelection: true,
    ...terminalAltKeyOptions(altIsMeta),
    wordSeparator,
    theme: {
      ...ctx.terminalTheme.colors,
      selectionBackground: ctx.terminalTheme.colors.selection,
      // Scrollbar theming (xterm 6.0) — derive from foreground color
      scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
      scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
      scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
    },
  });

  type MaybeRenderer = {
    constructor?: { name?: string };
    type?: string;
  };

  type IntrospectableTerminal = XTerm & {
    _core?: {
      _renderService?: {
        _renderer?: MaybeRenderer;
      };
    };
    options?: {
      rendererType?: string;
    };
  };

  const trackRenderer = (attempt = 0) => {
    const introspected = term as IntrospectableTerminal;
    const renderer = introspected._core?._renderService?._renderer;
    const candidates = [
      renderer?.type,
      renderer?.constructor?.name,
      introspected.options?.rendererType,
    ];
    const rendererName =
      candidates.find((value) => typeof value === "string" && value.length > 0) ||
      undefined;
    const normalized = rendererName
      ? rendererName.toLowerCase().includes("webgl")
        ? "webgl"
        : rendererName.toLowerCase().includes("canvas")
          ? "canvas"
          : rendererName
      : "unknown";
    const scopedWindow = window as Window & { __xtermRenderer?: string };
    scopedWindow.__xtermRenderer = normalized;
    if (normalized === "unknown" && attempt < 3) {
      setTimeout(() => trackRenderer(attempt + 1), 150);
    }
  };

  const fitAddon = new FitAddon();
  term.loadAddon(fitAddon);

  const serializeAddon = new SerializeAddon();
  term.loadAddon(serializeAddon);

  const searchAddon = new SearchAddon();
  term.loadAddon(searchAddon);

  term.open(ctx.container);

  let webglAddon: WebglAddon | null = null;
  let webglLoaded = false;
  const scopedWindow = window as Window & {
    __xtermWebGLLoaded?: boolean;
    __xtermRendererPreference?: string;
  };

  // Idempotent: creates the WebGL renderer on first call and no-ops afterwards
  // (or when WebGL is disabled for this device). Panes that mount hidden defer
  // this until they first become visible — see shouldDeferWebglUntilVisible —
  // so batch-connecting many hosts doesn't spin up every WebGL context at once.
  const loadWebglRenderer = () => {
    if (webglLoaded || !performanceConfig.useWebGLAddon) return;
    try {
      // WebglAddon constructor only accepts `preserveDrawingBuffer?: boolean`.
      // Passing an object here (legacy API assumption) unintentionally enables
      // preserveDrawingBuffer and can cause sporadic glyph artifacts/ghosting.
      webglAddon = new WebglAddon();
      webglAddon.onContextLoss(() => {
        logger.warn("[XTerm] WebGL context loss detected, disposing addon");
        webglAddon?.dispose();
      });
      term.loadAddon(webglAddon);
      webglLoaded = true;
    } catch (webglErr) {
      logger.warn(
        "[XTerm] WebGL addon failed, using DOM renderer. Error:",
        webglErr instanceof Error ? webglErr.message : webglErr,
      );
    }
    scopedWindow.__xtermWebGLLoaded = webglLoaded;
  };

  if (!performanceConfig.useWebGLAddon) {
    logger.info(
      "[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
    );
  } else if (
    shouldDeferWebglUntilVisible({
      useWebGLAddon: performanceConfig.useWebGLAddon,
      initiallyVisible: ctx.initiallyVisible ?? true,
    })
  ) {
    logger.info("[XTerm] Deferring WebGL addon until pane becomes visible");
  } else {
    loadWebglRenderer();
  }

  scopedWindow.__xtermWebGLLoaded = webglLoaded;
  scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
    ? "dom"
    : "webgl";

  // The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
  // (claude code / gemini cli / opencode and other full-screen agents), font
  // changes, and device pixel ratio changes can leave that atlas in a corrupted
  // state that persists for the life of the terminal — the "garbled / 花屏"
  // report in issue #1049 where only opening a brand-new terminal helps. Clearing
  // the atlas forces glyphs to re-rasterize at the correct scale on the next
  // frame. No-op for the DOM renderer.
  const clearWebglTextureAtlas = () => {
    if (!webglAddon) return;
    try {
      webglAddon.clearTextureAtlas();
    } catch (err) {
      logger.warn("[XTerm] clearTextureAtlas failed", err);
    }
  };

  // Recover the renderer when the device pixel ratio changes (moving the window
  // between monitors with different DPI, or changing OS display scaling — a
  // common Windows trigger). matchMedia change does not fire a normal resize, so
  // this is needed in addition to the resize handling below.
  let stopDprWatch: () => void = () => {};
  if (
    typeof window !== "undefined" &&
    typeof window.matchMedia === "function"
  ) {
    stopDprWatch = watchDevicePixelRatio({
      getDevicePixelRatio: () => window.devicePixelRatio || 1,
      matchMedia: (query) => window.matchMedia(query),
      onChange: () => {
        clearWebglTextureAtlas();
        try {
          fitAddon.fit();
        } catch (err) {
          logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
        }
      },
    });
  }

  const webLinksAddon = new WebLinksAddon((event, uri) => {
    const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
    let shouldOpen = false;
    switch (currentLinkModifier) {
      case "none":
        shouldOpen = true;
        break;
      case "ctrl":
        shouldOpen = event.ctrlKey;
        break;
      case "alt":
        shouldOpen = event.altKey;
        break;
      case "meta":
        shouldOpen = event.metaKey;
        break;
    }
    if (!shouldOpen) return;

    if (ctx.terminalBackend.openExternalAvailable()) {
      void ctx.terminalBackend.openExternal(uri);
    } else {
      const safeUri = String(uri || "");
      if (/^https?:\/\//i.test(safeUri)) {
        window.open(safeUri, "_blank", "noopener,noreferrer");
      } else {
        logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
      }
    }
  });
  term.loadAddon(webLinksAddon);

  // Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
  const unicodeGraphemes = new UnicodeGraphemesAddon();
  term.loadAddon(unicodeGraphemes);
  term.unicode.activeVersion = '15-graphemes';

  trackRenderer();

  const appLevelActions = getAppLevelActions();
  const terminalActions = getTerminalPassthroughActions();
  const broadcastUserPasteData = (data: string) => {
    if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
      ctx.onBroadcastInputRef.current(data, ctx.sessionId);
      return true;
    }
    return false;
  };
  const scrollToBottomAfterInput = (data: string) => {
    if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
      term.scrollToBottom();
    }
  };
  const currentTerminalFontSize = () => {
    const optionFontSize = term.options.fontSize;
    return typeof optionFontSize === "number" ? optionFontSize : effectiveFontSize;
  };
  const applyTerminalFontSize = (nextFontSize: number | null): boolean => {
    if (nextFontSize === null) return false;
    const currentFontSize = currentTerminalFontSize();
    if (nextFontSize !== currentFontSize) {
      term.options.fontSize = nextFontSize;
      clearWebglTextureAtlas();
      try {
        fitAddon.fit();
      } catch (err) {
        logger.warn("[XTerm] fit after font size change failed", err);
      }
      ctx.onTerminalFontSizeChange?.(nextFontSize);
    }
    return true;
  };
  const handleFontSizeWheel = (event: WheelEvent) => {
    const currentScheme = ctx.hotkeySchemeRef.current;
    const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
    const nextFontSize = nextTerminalFontSizeForWheel(
      event,
      currentTerminalFontSize(),
      isMac,
    );
    if (nextFontSize === null) return;
    event.preventDefault();
    event.stopPropagation();
    applyTerminalFontSize(nextFontSize);
  };
  ctx.container.addEventListener(
    "wheel",
    handleFontSizeWheel,
    terminalFontSizeWheelListenerOptions,
  );

  term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
    // Preserve mouse selection across keystrokes when enabled. xterm.js
    // unconditionally clears the selection on user input
    // (SelectionService.ts: coreService.onUserInput → clearSelection).
    // Capture the selection here, then re-apply it after xterm has
    // processed the key + cleared. The microtask runs after both
    // synchronous listeners, so by then either the selection is gone (and
    // we restore) or it's still there (we no-op).
    //
    // Both keydown AND keypress must be hooked: xterm routes Space
    // (keyCode 32 fails Keyboard.ts: `ev.keyCode >= 48`) and A–Z
    // (CoreBrowserTerminal.ts:_keyDown A–Z IME HACK) through the
    // `keypress` event, calling triggerDataEvent in _keyPress rather
    // than _keyDown. For those keys, keydown's microtask drains before
    // keypress fires, so hasSelection is still true → no-op. Attaching
    // to keypress gives us a second microtask that drains after
    // _keyPress clears the selection, so the restore runs.
    if (
      (e.type === "keydown" || e.type === "keypress") &&
      ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
      term.hasSelection()
    ) {
      const sel = term.getSelectionPosition();
      if (sel) {
        const length =
          (sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
        const savedStartX = sel.start.x;
        const savedStartY = sel.start.y;
        queueMicrotask(() => {
          if (term.hasSelection()) return;
          // Bail out if scrollback trim invalidated the row index.
          if (savedStartY >= term.buffer.active.length) return;
          const restoreFlag = ctx.isRestoringSelectionRef;
          if (restoreFlag) restoreFlag.current = true;
          try {
            term.select(savedStartX, savedStartY, length);
          } finally {
            if (restoreFlag) restoreFlag.current = false;
          }
        });
      }
    }

    if (e.type !== "keydown") {
      return true;
    }

    // Sudo password hint: while a hint is pending, Enter confirms (paste the
    // saved password + submit); any other visible key dismisses it so the user
    // can type the password manually. Checked before autocomplete so Enter
    // pastes the password instead of submitting an empty line.
    const sudoAutofill = ctx.sudoAutofillRef?.current;
    if (sudoAutofill?.isPromptPending()) {
      if (e.key === "Enter") {
        e.preventDefault();
        sudoAutofill.confirmFill();
        return false;
      }
      if (e.key === "Escape" || e.key === "Backspace") {
        e.preventDefault();
        sudoAutofill.cancelHint();
        return false; // dismiss without forwarding the byte to the no-echo prompt
      }
      if (e.key.length === 1) {
        sudoAutofill.cancelHint();
        // fall through: key becomes the first char of the manually typed password
      }
    }

    // Autocomplete key handler (must be checked before other handlers)
    if (ctx.onAutocompleteKeyEvent) {
      const consumed = ctx.onAutocompleteKeyEvent(e);
      if (!consumed) return false; // Event was consumed by autocomplete
    }

    const currentScheme = ctx.hotkeySchemeRef.current;
    // Use shared utility for platform detection when hotkey scheme is disabled
    const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());

    // Check snippet shortcuts first (even if hotkeys are disabled)
    const snippets = ctx.snippetsRef?.current;
    if (snippets && snippets.length > 0) {
      for (const snippet of snippets) {
        if (snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)) {
          const id = ctx.sessionRef.current;
          if (id && ctx.statusRef.current === "connected") {
            e.preventDefault();
            e.stopPropagation();
            const runSnippet = ctx.onSnippetShortkeyRef?.current;
            if (runSnippet) {
              void runSnippet(snippet as Snippet);
            }
            return false;
          }
          return true;
        }
      }
    }

    const currentBindings = ctx.keyBindingsRef.current;
    if (currentScheme !== "disabled" && currentBindings.length > 0) {
      const matched = checkAppShortcut(e, currentBindings, isMac);
      if (matched) {
        const { action } = matched;

        if (appLevelActions.has(action)) {
          return true; // Let app-level handler process it
        }

        if (terminalActions.has(action)) {
          e.preventDefault();
          e.stopPropagation();
          switch (action) {
            case "copy": {
              const selection = term.getSelection();
              if (selection) navigator.clipboard.writeText(selection);
              break;
            }
            case "paste": {
              navigator.clipboard.readText().then((text) => {
                const id = ctx.sessionRef.current;
                if (id) {
                  pasteTextIntoTerminal(term, text, {
                    scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
                    onPasteData: broadcastUserPasteData,
                  });
                }
              });
              break;
            }
            case "pasteSelection": {
              const selection = term.getSelection();
              const id = ctx.sessionRef.current;
              if (selection && id) {
                pasteTextIntoTerminal(term, selection, {
                  scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
                  onPasteData: broadcastUserPasteData,
                });
              }
              break;
            }
            case "selectAll": {
              term.selectAll();
              break;
            }
            case "clearBuffer": {
              clearTerminalViewport(term);
              break;
            }
            case "searchTerminal": {
              ctx.setIsSearchOpen(true);
              break;
            }
            case "increaseTerminalFontSize":
            case "decreaseTerminalFontSize":
            case "resetTerminalFontSize": {
              applyTerminalFontSize(
                nextTerminalFontSizeForAction(action, currentTerminalFontSize()),
              );
              break;
            }
          }
          return false;
        }
      }
    }

    const kittyControlSequence = encodeKittyControlKey(kittyKeyboardMode, e);
    if (kittyControlSequence) {
      const id = ctx.sessionRef.current;
      if (id) {
        e.preventDefault();
        e.stopPropagation();
        ctx.onAutocompleteInput?.(kittyControlSequence);
        ctx.terminalBackend.writeToSession(id, kittyControlSequence);
        if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
          ctx.onBroadcastInputRef.current(kittyControlSequence, ctx.sessionId);
        }
        scrollToBottomAfterInput(kittyControlSequence);
        return false;
      }
    }

    // macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
    // #826). After kitty mode so apps using the kitty protocol keep their own
    // arrow encoding; read live so the toggle applies without reconnecting.
    const wordJumpSequence = optionArrowWordJumpSequence(
      e,
      ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
      isMacPlatform(),
    );
    if (wordJumpSequence) {
      const id = ctx.sessionRef.current;
      if (id) {
        e.preventDefault();
        e.stopPropagation();
        ctx.onAutocompleteInput?.(wordJumpSequence);
        ctx.terminalBackend.writeToSession(id, wordJumpSequence);
        if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
          ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
        }
        scrollToBottomAfterInput(wordJumpSequence);
        return false;
      }
    }

    return true;
  });

  let cleanupMiddleClick: (() => void) | null = null;
  const middleClickPaste = settings?.middleClickPaste ?? true;
  if (middleClickPaste) {
    const handleMiddleClick = async (e: MouseEvent) => {
      if (e.button !== 1) return;
      e.preventDefault();
      try {
        const text = await navigator.clipboard.readText();
        if (text && ctx.sessionRef.current) {
          pasteTextIntoTerminal(term, text, {
            scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
            onPasteData: broadcastUserPasteData,
          });
        }
      } catch (err) {
        logger.warn("[Terminal] Failed to paste from clipboard:", err);
      }
    };

    ctx.container.addEventListener("auxclick", handleMiddleClick);
    cleanupMiddleClick = () =>
      ctx.container.removeEventListener("auxclick", handleMiddleClick);
  }

  fitAddon.fit();
  term.focus();

  const writeLocalTerminalData = (nextData: string) => {
    ctx.onTerminalLogData?.(nextData);
    term.write(nextData);
  };

  term.onData((data) => {
    const id = ctx.sessionRef.current;
    let dataToWrite = data;
    let handledSubmittedInput = false;
    const onBroadcastInput = ctx.onBroadcastInputRef.current;
    const broadcastDataBeforeSudo = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
    const willBroadcastInput = !!id && shouldBroadcastTerminalUserInput(term, broadcastDataBeforeSudo, {
      isBroadcastEnabled: ctx.isBroadcastEnabledRef.current,
      hasBroadcastInputHandler: !!onBroadcastInput,
    });
    if (ctx.statusRef.current === "connected" && (data === "\r" || data === "\n")) {
      const recordedCommand = recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
      handledSubmittedInput = true;
      if (!willBroadcastInput) {
        prepareSudoAutofillInput(data, recordedCommand, ctx.sudoAutofillRef?.current);
      }
    } else if (ctx.statusRef.current === "connected" && !willBroadcastInput) {
      const pastedCommand = getSinglePastedCommand(data);
      if (pastedCommand) {
        const recordedCommand = recordTerminalCommandExecution(
          `${ctx.commandBufferRef.current}${pastedCommand.command}`,
          ctx,
          term,
        );
        handledSubmittedInput = true;
        if (recordedCommand) {
          prepareSudoAutofillInput(
            `${recordedCommand}${pastedCommand.lineEnding}`,
            null,
            ctx.sudoAutofillRef?.current,
          );
        }
      }
    }

    if (id) {
      // Serial line mode: buffer input and send on Enter
      if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
        handleSerialLineModeInput(dataToWrite, {
          bufferRef: ctx.serialLineBufferRef,
          localEcho: ctx.serialLocalEcho,
          writeToSession: (nextData) => ctx.terminalBackend.writeToSession(id, nextData),
          writeToTerminal: writeLocalTerminalData,
        });
      } else {
        // Character mode (default): send immediately
        // When backspaceBehavior is configured, remap the Backspace key output
        let outData = dataToWrite;
        if (dataToWrite === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
          outData = "\x08";
        }
        ctx.terminalBackend.writeToSession(id, outData);

        // Local echo for serial connections only when explicitly enabled
        if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
          if (dataToWrite === "\r") {
            writeLocalTerminalData("\r\n");
          } else if (dataToWrite === "\x7f" || dataToWrite === "\b") {
            writeLocalTerminalData("\b \b");
          } else if (dataToWrite === "\x03") {
            writeLocalTerminalData("^C");
          } else if (dataToWrite.charCodeAt(0) >= 32 || dataToWrite.length > 1) {
            writeLocalTerminalData(dataToWrite);
          }
        }
      }

      // Use remapped data so broadcast peers also receive the correct byte
      const broadcastData = (dataToWrite === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
      if (willBroadcastInput) {
        onBroadcastInput?.(broadcastData, ctx.sessionId);
      }

      if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
        scrollToBottomAfterInput(data);
      }

      // Notify autocomplete of input
      ctx.onAutocompleteInput?.(data);

      if (ctx.statusRef.current === "connected") {
        if (handledSubmittedInput || data === "\r" || data === "\n") {
          // Command recording and sudo command preparation happen before the
          // input is written so sudo can receive a one-time prompt marker.
        } else if (data === "\x7f" || data === "\b") {
          ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
        } else if (data === "\x03") {
          ctx.commandBufferRef.current = "";
        } else if (data === "\x15") {
          ctx.commandBufferRef.current = "";
        } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
          ctx.commandBufferRef.current += data;
        } else if (data.length > 1 && !data.startsWith("\x1b")) {
          ctx.commandBufferRef.current += data;
        } else {
          const pastedLine = getSingleBracketedPasteLine(data);
          if (pastedLine) {
            ctx.commandBufferRef.current += pastedLine;
          }
        }
      }
    }
  });

  // Track current working directory via OSC 7 escape sequences
  // OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
  let currentCwd: string | undefined = undefined;

  const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
    if (isEraseViewportSequence(params)) {
      preserveTerminalViewportInScrollback(term);
      return false;
    }
    if (!isEraseScrollbackSequence(params)) {
      return false;
    }
    // CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
    // Honor it unless the user opts into the legacy "preserve history" behavior.
    const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
    return !wipeAllowed;
  });

  const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
    if (csiParamsInclude(params, 6)) {
      markExpectedTerminalCursorPositionReport(term);
    }
    return false;
  };

  const cursorPositionReportRequestDisposables = [
    term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
    term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
  ];

  const writeKittyKeyboardReply = (payload: string) => {
    const id = ctx.sessionRef.current;
    if (!id) return;
    ctx.terminalBackend.writeToSession(id, payload);
  };

  const kittyKeyboardDisposable = installKittyKeyboardProtocolHandlers(
    term.parser,
    kittyKeyboardMode,
    writeKittyKeyboardReply,
  );

  // Register OSC 7 handler using xterm.js parser
  // OSC 7 is the standard way for shells to report the current working directory
  const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
    try {
      // data is the content after "7;" - typically "file://hostname/path"
      if (data.startsWith('file://')) {
        // Extract path from file:// URL
        const url = new URL(data);
        const path = decodeURIComponent(url.pathname);
        if (path && path.length > 0) {
          currentCwd = path;
          ctx.onCwdChange?.(path);
          logger.debug('[XTerm] OSC 7 CWD update:', path);
        }
      } else if (data.startsWith('/')) {
        // Some shells send just the path without file:// prefix
        currentCwd = data;
        ctx.onCwdChange?.(data);
        logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
      }
    } catch (err) {
      logger.warn('[XTerm] Failed to parse OSC 7:', err);
    }
    return true; // Indicate we handled the sequence
  });

  // OSC 52 — clipboard integration
  // Format: 52;<target>;<base64-data>  (write)  or  52;<target>;?  (query/read)
  // <target> is typically "c" (clipboard) or "p" (primary selection)
  // Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
  const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
    const settings = ctx.terminalSettingsRef.current;
    const mode = settings?.osc52Clipboard ?? 'write-only';
    if (mode === 'off') return true;

    try {
      const semi = data.indexOf(';');
      if (semi < 0) return true;
      const target = data.substring(0, semi);
      // Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
      if (target !== 'c' && target !== '') return true;
      const payload = data.substring(semi + 1);

      if (payload === '?') {
        // Read request — allowed in read-write mode, or prompt user in prompt mode
        if (mode !== 'read-write' && mode !== 'prompt') {
          logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
          return true;
        }
        const sessionId = ctx.sessionRef.current;
        if (!sessionId) return true;
        // Use Electron bridge as primary, fall back to navigator.clipboard
        const readClipboard = async (): Promise<string> => {
          try {
            const bridge = netcattyBridge.get();
            if (bridge?.readClipboardText) return await bridge.readClipboardText();
          } catch { /* fall through to navigator.clipboard */ }
          return navigator.clipboard.readText();
        };
        const doRead = async () => {
          // In prompt mode, ask user first
          if (mode === 'prompt') {
            const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
            if (!allowed) {
              logger.debug('[XTerm] OSC 52 read denied by user');
              return;
            }
          }
          const text = await readClipboard();
          // Chunked base64 encoding to avoid stack overflow on large payloads
          const bytes = new TextEncoder().encode(text);
          let binary = '';
          for (let i = 0; i < bytes.length; i += 8192) {
            binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
          }
          const b64 = btoa(binary);
          ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
        };
        doRead().catch((err) => {
          logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
        });
        return true;
      }

      // Write: payload is base64-encoded UTF-8 text
      const binary = atob(payload);
      const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
      const text = new TextDecoder().decode(bytes);
      navigator.clipboard.writeText(text).catch((err) => {
        logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
      });
      logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
    } catch (err) {
      logger.warn('[XTerm] Failed to handle OSC 52:', err);
    }
    return true;
  });

  const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);

  let resizeTimeout: NodeJS.Timeout | null = null;
  const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
  term.onResize(({ cols, rows }) => {
    // A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
    // dimensions re-rasterize cleanly (issue #1049).
    clearWebglTextureAtlas();
    const id = ctx.sessionRef.current;
    if (!id) return;
    if (resizeTimeout) clearTimeout(resizeTimeout);
    resizeTimeout = setTimeout(() => {
      ctx.terminalBackend.resizeSession(id, cols, rows);
      resizeTimeout = null;
    }, resizeDebounceMs);
  });

  const keywordHighlighter = new KeywordHighlighter(term);
  keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);

  return {
    term,
    fitAddon,
    serializeAddon,
    searchAddon,
    keywordHighlighter,
    clearTextureAtlas: clearWebglTextureAtlas,
    ensureWebglRenderer: loadWebglRenderer,
    dispose: () => {
      ctx.container.removeEventListener(
        "wheel",
        handleFontSizeWheel,
        terminalFontSizeWheelListenerOptions,
      );
      cleanupMiddleClick?.();
      stopDprWatch();
      keywordHighlighter.dispose();
      eraseScrollbackDisposable.dispose();
      for (const disposable of cursorPositionReportRequestDisposables) {
        disposable.dispose();
      }
      kittyKeyboardDisposable.dispose();
      osc7Disposable.dispose();
      osc52Disposable.dispose();
      cursorPreferenceDisposable?.dispose();
      try {
        term.dispose();
      } catch (err) {
        logger.warn("[XTerm] dispose failed", err);
      }
      try {
        fitAddon.dispose();
      } catch (err) {
        logger.warn("[XTerm] fitAddon dispose failed", err);
      }
      try {
        serializeAddon.dispose();
      } catch (err) {
        logger.warn("[XTerm] serializeAddon dispose failed", err);
      }
      try {
        searchAddon.dispose();
      } catch (err) {
        logger.warn("[XTerm] searchAddon dispose failed", err);
      }
      try {
        webglAddon?.dispose();
      } catch (err) {
        logger.warn("[XTerm] webglAddon dispose failed", err);
      }
    },
    get currentCwd() {
      return currentCwd;
    },
  };
};
