
import { Terminal as XTerm, IDecoration, IDisposable, IMarker, IBufferLine } from "@xterm/xterm";
import { KeywordHighlightRule } from "../../types";

import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";

/** Pre-compiled rule with regex ready for matching */
interface CompiledRule {
  regex: RegExp;
  color: string;
}

interface CachedDecorationRange {
  x: number;
  width: number;
  color: string;
}

/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);

/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;

/**
 * Manages terminal decorations for keyword highlighting.
 * Uses xterm.js Decoration API to overlay styles without modifying the data stream.
 * This ensures zero impact on scrolling performance ("lazy" highlighting).
 */
export class KeywordHighlighter implements IDisposable {
  private term: XTerm;
  private compiledRules: CompiledRule[] = [];
  private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
  private debounceTimer: NodeJS.Timeout | null = null;
  private animationFrameId: number | null = null;
  private lastRefreshTime: number = 0;
  private matchCache = new Map<string, CachedDecorationRange[]>();
  private enabled: boolean = false;
  private disposables: IDisposable[] = [];
  private lastViewportY: number = -1;

  constructor(term: XTerm) {
    this.term = term;

    // Hook into terminal events to trigger highlighting
    this.disposables.push(
      // When user scrolls, refresh visible area
      this.term.onScroll(() => {
        this.triggerRefresh("debounced");
      }),
      // When new data is written, refresh on the next frame so highlights land
      // with the freshly rendered content instead of trailing behind it.
      this.term.onWriteParsed(() => {
        this.triggerRefresh("immediate");
      }),
      // Also refresh on resize as viewport content changes
      this.term.onResize(() => this.triggerRefresh("debounced")),
      // onRender fires after each render cycle - catch scrolls that onScroll might miss
      this.term.onRender(() => {
        // Only trigger refresh if viewport position changed
        const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
        if (currentViewportY !== this.lastViewportY) {
          this.lastViewportY = currentViewportY;
          this.triggerRefresh("debounced");
        }
      })
    );
  }

  public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
    this.enabled = enabled;
    this.matchCache.clear();

    // Pre-compile all patterns into regexes for better performance
    // This avoids creating new RegExp objects on every viewport refresh
    this.compiledRules = [];
    for (const rule of rules) {
      if (!rule.enabled || rule.patterns.length === 0) continue;
      for (const pattern of rule.patterns) {
        try {
          this.compiledRules.push({
            regex: new RegExp(pattern, "gi"),
            color: rule.color,
          });
        } catch (err) {
          console.error("Invalid regex pattern:", pattern, err);
        }
      }
    }

    // Clear existing and force an immediate refresh if enabling
    this.clearDecorations();
    if (this.enabled && this.compiledRules.length > 0) {
      this.triggerRefresh("immediate");
    }
  }

  public dispose() {
    this.clearDecorations();
    this.disposables.forEach(d => d.dispose());
    this.disposables = [];
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }
    if (this.animationFrameId !== null) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
    }
    this.matchCache.clear();
  }

  private triggerRefresh(mode: "immediate" | "debounced") {
    if (!this.enabled || this.compiledRules.length === 0) return;

    // Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
    // These apps manage their own highlighting and have rapid repaints.
    if (this.term.buffer.active.type === 'alternate') {
      if (this.decorations.length > 0) {
        this.clearDecorations();
      }
      return;
    }

    if (mode === "immediate") {
      // Throttle: skip if a rAF is already pending.
      // Don't clear the debounce timer here — in a hidden tab rAF never
      // fires, so the fallback timer is the only path that will run.
      if (this.animationFrameId !== null) {
        return;
      }
      const now = performance.now();
      const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
      if (now - this.lastRefreshTime < minInterval) {
        // Too soon — fall through to debounced path instead of dropping
        this.triggerRefresh("debounced");
        return;
      }
      this.animationFrameId = requestAnimationFrame(() => {
        this.animationFrameId = null;
        // rAF fired — cancel the fallback timer to avoid a redundant refresh
        if (this.debounceTimer) {
          clearTimeout(this.debounceTimer);
          this.debounceTimer = null;
        }
        this.executeRefresh();
      });
      // Arm a debounced fallback: rAF does not fire in background/hidden
      // tabs (Chromium throttles it), so the timer ensures highlights
      // still update for ongoing output.  If rAF fires first it cancels
      // this timer (see above), preventing a double refresh.
      if (!this.debounceTimer) {
        this.debounceTimer = setTimeout(() => {
          this.debounceTimer = null;
          this.executeRefresh();
        }, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
      }
      return;
    }

    if (this.animationFrameId !== null) {
      return;
    }

    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
    this.debounceTimer = setTimeout(() => {
      this.debounceTimer = null;
      this.executeRefresh();
    }, delay);
  }

  /** Shared refresh execution for both rAF and timer callbacks. */
  private executeRefresh() {
    // Cancel any stale rAF that will never fire (e.g. hidden tab)
    if (this.animationFrameId !== null) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
    }
    // Re-check state: may have changed since the refresh was scheduled
    if (!this.enabled || this.compiledRules.length === 0) return;
    if (this.term.buffer.active.type === 'alternate') {
      if (this.decorations.length > 0) this.clearDecorations();
      return;
    }
    this.lastRefreshTime = performance.now();
    this.refreshViewport();
  }

  private clearDecorations() {
    this.decorations.forEach(({ decoration, marker }) => {
      decoration.dispose();
      marker.dispose();
    });
    this.decorations = [];
  }

  /**
   * Build a mapping from string character index to terminal cell column.
   * This handles wide characters (CJK, emoji) and combining characters correctly.
   *
   * For example, with "A中B":
   * - String indices: 0='A', 1='中', 2='B'
   * - Cell columns:   0='A', 1='中'(width 2), 3='B'
   * - Result map: [0, 1, 3, 4] (includes end position)
   */
  private buildStringToCellMap(line: IBufferLine): number[] {
    const map: number[] = [];
    let cellCol = 0;

    for (let col = 0; col < line.length; col++) {
      const cell = line.getCell(col);
      if (!cell) break;

      const chars = cell.getChars();
      const width = cell.getWidth();

      // Skip continuation cells (width 0) - these are the 2nd cell of wide characters
      if (width === 0) continue;

      if (chars.length > 0) {
        // Map each character in this cell to the current cell column
        for (let i = 0; i < chars.length; i++) {
          map.push(cellCol);
        }
      } else {
        // Empty cell (codepoint 0) — translateToString() outputs a space
        // for it, so we must push one entry to keep the map aligned.
        map.push(cellCol);
      }

      cellCol += width;
    }

    // Add final position for calculating end column of matches
    map.push(cellCol);

    return map;
  }

  private refreshViewport() {
    // Safety check just in case
    if (!this.term?.buffer?.active) return;

    const buffer = this.term.buffer.active;
    const viewportY = buffer.viewportY;
    const rows = this.term.rows;
    const cursorY = buffer.cursorY;
    const baseY = buffer.baseY;
    const cursorAbsoluteY = baseY + cursorY;

    // Clear old decorations to avoid duplicates/memory leaks
    this.clearDecorations();

    // Iterate only over the visible rows
    for (let y = 0; y < rows; y++) {
      const lineY = viewportY + y;
      const line = buffer.getLine(lineY);
      if (!line) continue;

      const lineText = line.translateToString(true); // true = trim right whitespace
      if (!lineText) continue;

      const cachedRanges = this.getCachedRanges(line, lineText);
      if (cachedRanges.length === 0) continue;

      // Calculate offset relative to the absolute cursor position
      // offset = targetLineAbs - (baseY + cursorY)
      const offset = lineY - cursorAbsoluteY;

      for (const range of cachedRanges) {
        const marker = this.term.registerMarker(offset);

        if (marker) {
          const deco = this.term.registerDecoration({
            marker,
            x: range.x,
            width: range.width,
            foregroundColor: range.color,
          });

          if (deco) {
            this.decorations.push({ decoration: deco, marker });
          } else {
            // If decoration failed, cleanup marker
            marker.dispose();
          }
        }
      }
    }
  }

  private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
    const cached = this.matchCache.get(lineText);
    if (cached) {
      // LRU: move to end
      this.matchCache.delete(lineText);
      this.matchCache.set(lineText, cached);
      return cached;
    }

    const ranges = this.scanLine(line, lineText);
    this.matchCache.set(lineText, ranges);

    const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
    if (this.matchCache.size > maxEntries) {
      const oldestKey = this.matchCache.keys().next().value;
      if (oldestKey !== undefined) {
        this.matchCache.delete(oldestKey);
      }
    }

    return ranges;
  }

  private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
    // ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
    // so we can skip the expensive buildStringToCellMap call entirely.
    const asciiOnly = RE_ASCII_ONLY.test(lineText);
    let cellMap: number[] | null = null;
    let ranges: CachedDecorationRange[] | null = null;

    // Process each pre-compiled rule
    for (const { regex, color } of this.compiledRules) {
      // Reset regex state for reuse (global flag maintains lastIndex)
      regex.lastIndex = 0;
      let match;

      while ((match = regex.exec(lineText)) !== null) {
        const strStart = match.index;
        const strEnd = strStart + match[0].length;

        let cellStartCol: number;
        let cellEndCol: number;

        if (asciiOnly) {
          cellStartCol = strStart;
          cellEndCol = strEnd;
        } else {
          // Lazily build cellMap only when a match is found
          if (cellMap === null) {
            cellMap = this.buildStringToCellMap(line);
          }
          cellStartCol = cellMap[strStart] ?? strStart;
          cellEndCol = cellMap[strEnd] ?? strEnd;
        }

        const cellWidth = cellEndCol - cellStartCol;

        // Skip if width is 0 or negative (shouldn't happen, but be safe)
        if (cellWidth <= 0) continue;

        if (ranges === null) {
          ranges = [];
        }
        ranges.push({
          x: cellStartCol,
          width: cellWidth,
          color,
        });
      }
    }

    return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
  }
}
