/**
 * Agent Skill Manager for renderer process
 * Manages agent skill suggestions and selection
 */

import type { InputFormatType, ColorValue } from '../types';
import type { IInitializable } from './interfaces/initializable';
import { FrontmatterPopupManager } from './frontmatter-popup-manager';
import { highlightMatch, splitKeywords } from './utils/highlight-utils';
import { escapeHtml } from './utils/html-utils';
import { electronAPI } from './services/electron-api';
import { extractTriggerQueryAtCursor } from './utils/trigger-query-extractor';
import { getCaretCoordinates, createMirrorDiv } from './mentions/dom-utils';
import { compareTiebreak } from '../lib/tiebreaker';


const COLOR_MAP: Record<string, string> = {
  grey: 'var(--color-neutral-400)', darkGrey: 'var(--color-neutral-500)', slate: 'var(--color-stone-400)', stone: 'var(--color-stone-400)',
  red: 'var(--color-red-400)', rose: 'var(--color-rose-400)',
  orange: 'var(--color-orange-400)', amber: 'var(--color-amber-400)',
  yellow: 'var(--color-yellow-300)', lime: 'var(--color-lime-400)',
  green: 'var(--color-green-400)', emerald: 'var(--color-emerald-400)', teal: 'var(--color-teal-400)',
  cyan: 'var(--color-cyan-400)', sky: 'var(--color-sky-400)', blue: 'var(--color-blue-400)',
  indigo: 'var(--color-indigo-400)', violet: 'var(--color-violet-400)', purple: 'var(--color-purple-400)',
  fuchsia: 'var(--color-fuchsia-400)', pink: 'var(--color-pink-400)',
};

function resolveColorValue(color: string | undefined, fallback?: string): string {
  if (!color) return fallback || '';
  const c = color.replace(/^["']|["']$/g, '');
  if (c.startsWith('#')) return c;
  return COLOR_MAP[c] || c;
}

interface AgentSkillItem {
  name: string;
  description: string;
  label?: string;  // Label text (e.g., from frontmatter)
  /**
   * Color for label and highlight
   * Supports both named colors and hex color codes:
   * - Named colors: 'grey', 'darkGrey', 'slate', 'stone', 'red', 'rose', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink'
   * - Hex codes: '#RGB' or '#RRGGBB' (e.g., '#FF6B35', '#F63')
   */
  color?: ColorValue;
  icon?: string;  // Codicon icon class name (e.g., "codicon-rocket")
  argumentHint?: string; // Hint text shown when editing arguments (after Tab selection)
  filePath: string;
  tooltip?: string;  // Tooltip popup content（ポップアップ表示用テキスト）
  inputFormat?: InputFormatType;  // 入力フォーマット（'name' | テンプレート e.g. '{filepath}', '{content}'）
  inputText?: string;  // テンプレート解決済みの入力テキスト
  source?: string;  // Source tool identifier (e.g., 'claude-code') for filtering
  displayName?: string;  // Human-readable source name for display (e.g., 'Claude Code')
  mtime?: number;  // File modification timestamp (mtimeMs)
  triggers?: string[];  // Trigger prefixes (e.g., ['/', '$'])
}

export class AgentSkillManager implements IInitializable {

  private suggestionsContainer: HTMLElement | null = null;
  private textarea: HTMLTextAreaElement | null = null;
  private mirrorDiv: HTMLDivElement | null = null;
  private skills: AgentSkillItem[] = [];
  private sortedSkillsByNameLength: AgentSkillItem[] = []; // Pre-sorted for checkForArgumentHintAtCursor
  private sortedLowerNamesWithSpace: string[] = []; // Pre-computed lowercase names with trailing space
  private filteredSkills: AgentSkillItem[] = [];
  private selectedIndex: number = 0;
  private isActive: boolean = false;
  private isEditingMode: boolean = false; // True when Tab selected a command and user is editing arguments
  private editingSkillName: string = ''; // The command name being edited
  private editingSkillStartPos: number = 0; // Position where the editing command starts
  private currentTriggerStartPos: number = 0; // Position of trigger character
  private onSkillInsert: (command: string) => void;
  private onBeforeOpenFile: (() => void) | undefined;
  private setDraggable: ((enabled: boolean) => void) | undefined;

  // Task #5: Pre-computed searchable fields cache (populated in loadSkills)
  private searchableFieldsCache: Map<AgentSkillItem, string> = new Map();
  // Task #3: Usage bonuses TTL cache
  private usageBonusesCache: Record<string, number> = {};
  private usageBonusesCacheTime: number = 0;
  private static readonly USAGE_BONUSES_CACHE_TTL = 5000; // 5 seconds

  // C3: Last keyword cache for checkForAgentSkill skip optimization
  private lastSkillKeywords: string = '';

  // D5: Reference to currently selected DOM element for differential update
  private selectedSkillElement: HTMLElement | null = null;

  // Configurable trigger prefixes (default: ['/'])
  private triggerPrefixes: string[] = ['/'];
  // Currently active trigger prefix (set when a trigger is detected)
  private activeTriggerPrefix: string = '/';

  // Frontmatter popup manager
  private frontmatterPopupManager: FrontmatterPopupManager;

  // Hint text callbacks for footer area
  private updateHintText: ((text: string) => void) | undefined;
  private getDefaultHintText: (() => string) | undefined;

  constructor(callbacks: {
    onSkillSelect?: (command: string) => void;
    onSkillInsert?: (command: string) => void;
    onBeforeOpenFile?: () => void;
    setDraggable?: (enabled: boolean) => void;
    updateHintText?: (text: string) => void;
    getDefaultHintText?: () => string;
  }) {
    this.onSkillInsert = callbacks.onSkillInsert || (() => {});
    this.onBeforeOpenFile = callbacks.onBeforeOpenFile;
    this.setDraggable = callbacks.setDraggable;
    this.updateHintText = callbacks.updateHintText;
    this.getDefaultHintText = callbacks.getDefaultHintText;
    this.frontmatterPopupManager = new FrontmatterPopupManager({
      getSuggestionsContainer: () => this.suggestionsContainer,
      getFilteredSkills: () => this.filteredSkills,
      getSelectedIndex: () => this.selectedIndex,
      ...(callbacks.onBeforeOpenFile ? { onBeforeOpenFile: callbacks.onBeforeOpenFile } : {}),
      ...(callbacks.setDraggable ? { setDraggable: callbacks.setDraggable } : {}),
      onSelectSkill: (command) => {
        // Find the command in filteredCommands to get the full command object
        const fullSkill = this.filteredSkills.find(cmd => cmd.name === command.name);
        if (fullSkill) {
          // Use Tab behavior (shouldPaste=false) to insert command with space for editing
          this.selectSkill(this.filteredSkills.indexOf(fullSkill));
        }
      }
    });
  }

  /**
   * Collect all unique trigger prefixes from loaded skills.
   * Called after loadSkills to update triggerPrefixes.
   */
  private collectTriggerPrefixes(): void {
    const prefixes = new Set<string>(['/']); // always include '/'
    for (const skill of this.skills) {
      if (skill.triggers) {
        for (const t of skill.triggers) {
          prefixes.add(t);
        }
      }
    }
    this.triggerPrefixes = [...prefixes];
  }

  /**
   * マネージャーを初期化する（IInitializable実装）
   * - DOM要素の取得
   * - イベントリスナーの設定
   */
  public initialize(): void {
    this.initializeElements();
    this.setupEventListeners();
  }

  /**
   * Show a notification for copied feedback in the app name area
   */
  private showCopiedNotification(): void {
    const appNameEl = document.getElementById('appName');
    if (!appNameEl) return;

    const originalText = appNameEl.textContent;
    appNameEl.textContent = '✓ Copied to clipboard';
    appNameEl.classList.add('app-name-success');

    setTimeout(() => {
      if (appNameEl) {
        appNameEl.textContent = originalText;
        appNameEl.classList.remove('app-name-success');
      }
    }, 1500);
  }

  public initializeElements(): void {
    this.suggestionsContainer = document.getElementById('agentSkillSuggestions');
    this.textarea = document.getElementById('textInput') as HTMLTextAreaElement;

    // Create mirror div for caret position calculation
    this.mirrorDiv = createMirrorDiv();

    // Create frontmatter popup element
    this.frontmatterPopupManager.createPopup();
  }

  public setupEventListeners(): void {
    if (!this.textarea) return;

    // Monitor input for agent skill detection and argumentHint display
    this.textarea.addEventListener('input', () => {
      this.checkForAgentSkill();
      // After checking for agent skill, also check if we need to show argumentHint
      // This handles the case when user deletes characters and cursor returns to argument position
      if (!this.isActive) {
        this.checkForArgumentHintAtCursor();
      }
    });

    // Handle keyboard navigation
    this.textarea.addEventListener('keydown', (e) => {
      if (this.isActive) {
        this.handleKeyDown(e);
      } else if (this.isEditingMode && e.ctrlKey && e.shiftKey && e.key === 'Enter') {
        // Ctrl+Shift+Enter to reveal file directory in Finder in editing mode
        e.preventDefault();
        e.stopPropagation();
        this.revealSkillFileDirectory(this.selectedIndex);
      } else if (this.isEditingMode && e.ctrlKey && e.key === 'Enter') {
        // Allow Ctrl+Enter to open file even in editing mode
        e.preventDefault();
        e.stopPropagation();
        this.openSkillFile(this.selectedIndex);
      }
    });

    // Check for argumentHint when cursor position changes (arrow keys, home, end)
    this.textarea.addEventListener('keyup', (e) => {
      if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
        this.checkForArgumentHintAtCursor();
      }
    });

    // Check for argumentHint when clicking in textarea
    this.textarea.addEventListener('click', () => {
      this.checkForArgumentHintAtCursor();
    });

    // Check for argumentHint when textarea receives focus
    this.textarea.addEventListener('focus', () => {
      this.checkForArgumentHintAtCursor();
    });

    // Hide suggestions on blur (with delay to allow click)
    this.textarea.addEventListener('blur', () => {
      setTimeout(() => {
        this.hideSuggestions();
      }, 200);
    });

    // Setup click and mousemove handling on suggestions container
    if (this.suggestionsContainer) {
      this.suggestionsContainer.addEventListener('click', async (e) => {
        const target = e.target as HTMLElement;
        const suggestionItem = target.closest('.agent-skill-suggestion-item') as HTMLElement;
        if (suggestionItem) {
          // Check if this is an argument-hint-only item
          if (suggestionItem.classList.contains('argument-hint-only')) {
            const argumentHint = suggestionItem.dataset.argumentHint;
            if (argumentHint) {
              try {
                await navigator.clipboard.writeText(argumentHint);
                // Show toast notification
                this.showCopiedNotification();
              } catch (error) {
                console.error('Failed to copy argumentHint to clipboard:', error);
              }
            }
            return; // Do NOT paste for argument-hint-only
          }

          // Normal suggestion handling
          const index = parseInt(suggestionItem.dataset.index || '0', 10);
          const command = this.filteredSkills[index];

          // Copy argumentHint to clipboard if it exists
          if (command?.argumentHint) {
            try {
              await navigator.clipboard.writeText(command.argumentHint);
              // Show toast notification
              this.showCopiedNotification();
            } catch (error) {
              console.error('Failed to copy argumentHint to clipboard:', error);
            }
          }

          this.selectSkill(index);
        }
      });

      // Enable hover styles only when mouse explicitly moves
      this.suggestionsContainer.addEventListener('mousemove', () => {
        this.suggestionsContainer?.classList.add('hover-enabled');
      });
    }
  }

  /**
   * Load commands from main process
   */
  public async loadSkills(): Promise<void> {
    try {
      if (electronAPI?.agentSkills?.get) {
        this.skills = await electronAPI.agentSkills.get();
      }
    } catch (error) {
      console.error('Failed to load agent skills:', error);
      this.skills = [];
    }
    // Pre-sort by name length descending for checkForArgumentHintAtCursor
    this.sortedSkillsByNameLength = [...this.skills].sort((a, b) => b.name.length - a.name.length);
    this.sortedLowerNamesWithSpace = this.sortedSkillsByNameLength.map(cmd => cmd.name.toLowerCase() + ' ');
    // Pre-compute searchable fields for each skill (Task #5)
    this.searchableFieldsCache = new Map();
    for (const cmd of this.skills) {
      this.searchableFieldsCache.set(cmd, [
        cmd.name.toLowerCase(),
        cmd.description.toLowerCase(),
        cmd.displayName?.toLowerCase() ?? '',
        cmd.label?.toLowerCase() ?? '',
      ].join(' '));
    }
    // Collect trigger prefixes from loaded skills
    this.collectTriggerPrefixes();
  }

  /**
   * Check if user is typing a agent skill at cursor position
   */
  private checkForAgentSkill(): void {
    if (!this.textarea) return;

    // Quick check: skip if no trigger prefix before cursor (avoids extractTriggerQueryAtCursor overhead)
    const cursorPos = this.textarea.selectionStart;
    const textBeforeCursor = this.textarea.value.substring(0, cursorPos);
    if (!this.isEditingMode && !this.triggerPrefixes.some(p => textBeforeCursor.includes(p))) {
      if (this.isActive) this.hideSuggestions();
      return;
    }

    const result = extractTriggerQueryAtCursor(
      this.textarea.value,
      cursorPos,
      this.triggerPrefixes,
      { allowSpaces: true }
    );

    // If in editing mode, check if user has started typing arguments
    if (!result) {
      if (this.isEditingMode) {
        // Check if the text still contains the command at the original position
        const text = this.textarea.value;
        const expectedSkill = `${this.activeTriggerPrefix}${this.editingSkillName}`;
        const skillAtPos = text.substring(
          this.editingSkillStartPos,
          this.editingSkillStartPos + expectedSkill.length
        );
        if (skillAtPos === expectedSkill) {
          // Check if user has started typing arguments (after the trailing space)
          const skillEndPos = this.editingSkillStartPos + expectedSkill.length;
          const afterSkill = text.substring(skillEndPos);

          // Hide UI if user has typed any argument, but keep editing mode state
          if (afterSkill !== ' ') {
            this.hideUI();
            return;
          }

          // Keep showing hint (only single space after command)
          return;
        }
      }
      this.hideSuggestions();
      return;
    }

    const { query, startPos } = result;

    // If in editing mode (Tab selected), check if command name still matches
    // With allowSpaces, query may include trailing space and arguments (e.g. "skillname " or "skillname args")
    if (this.isEditingMode) {
      if (query === this.editingSkillName) {
        // Exact match, keep showing argument hint
        return;
      }
      if (query.startsWith(this.editingSkillName + ' ')) {
        // User is at or past the argument position - hide UI but keep editing mode
        this.hideUI();
        return;
      }
      // Command name was modified, exit editing mode
      this.isEditingMode = false;
      this.editingSkillName = '';
      this.editingSkillStartPos = 0;
      // Continue to show suggestions based on new query
    }

    this.currentTriggerStartPos = startPos;
    this.activeTriggerPrefix = result.triggerChar;

    // C3: Skip full pipeline if splitKeywords result hasn't changed
    // D7: Compute keywords once here and pass to showSuggestions to avoid duplicate computation
    const lowerQuery = query.toLowerCase();
    const keywords = splitKeywords(lowerQuery);

    // Suppress suggestions once the user has completed a command name and is typing its argument
    if (keywords.length > 1) {
      for (const lowerNameWithSpace of this.sortedLowerNamesWithSpace) {
        if (lowerQuery.startsWith(lowerNameWithSpace)) {
          if (this.isActive) this.hideSuggestions();
          return;
        }
      }
    }

    const keywordsKey = keywords.join('\0');
    if (keywordsKey === this.lastSkillKeywords && this.isActive) {
      // Keywords unchanged and suggestions already visible - skip re-computation
      return;
    }
    this.lastSkillKeywords = keywordsKey;

    this.showSuggestions(query, keywords);
  }

  /**
   * Check if cursor is at the argument input position of an existing agent skill.
   * If so, show the argumentHint for that command.
   * Called when cursor position changes (click, arrow keys).
   */
  private async checkForArgumentHintAtCursor(): Promise<void> {
    if (!this.textarea) return;

    // Don't interfere if suggestions popup is already active
    if (this.isActive) return;

    const text = this.textarea.value;
    const cursorPos = this.textarea.selectionStart;
    const textBeforeCursor = text.substring(0, cursorPos);

    // Quick check: skip if no trigger prefix in text before cursor
    if (!this.triggerPrefixes.some(p => textBeforeCursor.includes(p))) return;

    // Load commands if not loaded
    if (this.skills.length === 0) {
      await this.loadSkills();
    }

    // Find a matching command only when cursor is right after "commandName " (argument position)
    const matchResult = textBeforeCursor.endsWith(' ')
      ? this.findCommandAtArgumentPosition(textBeforeCursor)
      : null;

    if (!matchResult) {
      // Cursor is not adjacent to a command — hide hint and clear editing state
      if (this.isEditingMode) {
        this.hideUI();
        this.resetState();
      }
      return;
    }

    const { skill: matchedSkill, startPos: commandStartPos, prefix: matchedPrefix } = matchResult;

    // Track which prefix was matched
    this.activeTriggerPrefix = matchedPrefix;

    // Show argumentHint for this command
    this.filteredSkills = [matchedSkill];
    this.selectedIndex = 0;
    this.isEditingMode = true;
    this.editingSkillName = matchedSkill.name;
    this.editingSkillStartPos = commandStartPos;
    this.currentTriggerStartPos = commandStartPos;

    this.showArgumentHintOnly(matchedSkill);
  }

  /**
   * Find a command whose "prefix + name + space" pattern matches the end of textBeforeCursor.
   * Returns null if no match with an argumentHint is found.
   */
  private findCommandAtArgumentPosition(textBeforeCursor: string): {
    skill: AgentSkillItem; startPos: number; prefix: string;
  } | null {
    for (const cmd of this.sortedSkillsByNameLength) {
      for (const prefix of this.triggerPrefixes) {
        const pattern = prefix + cmd.name + ' ';
        if (textBeforeCursor.endsWith(pattern)) {
          const patternStartPos = textBeforeCursor.length - pattern.length;
          const prevChar = patternStartPos > 0 ? textBeforeCursor[patternStartPos - 1] : '';
          if (prevChar === '' || prevChar === ' ' || prevChar === '\n' || prevChar === '\t') {
            if (cmd.argumentHint) {
              return { skill: cmd, startPos: patternStartPos, prefix };
            }
            return null; // Command found but has no argumentHint
          }
        }
      }
    }
    return null;
  }

  /**
   * Calculate match score for a command name
   * Higher score = better match
   */
  private getMatchScore(name: string, keywords: string[], description?: string): number {
    if (keywords.length === 0) return 0;

    const lowerName = name.toLowerCase();
    const lowerDesc = description?.toLowerCase() ?? '';

    let totalScore = 0;
    for (const kw of keywords) {
      if (lowerName === kw) {
        totalScore += 1000;
      } else if (lowerName.startsWith(kw)) {
        totalScore += 500;
      } else if (lowerName.includes(kw)) {
        totalScore += 200;
      } else if (lowerDesc.includes(kw)) {
        totalScore += 50;
      }
    }

    return totalScore / keywords.length;
  }

  /**
   * Build searchable fields string from a skill for keyword matching.
   * Uses pre-computed cache from loadSkills() for AgentSkillItem objects.
   */
  private getSearchableFields(cmd: AgentSkillItem): string {
    return this.searchableFieldsCache.get(cmd) ?? [
      cmd.name.toLowerCase(),
      cmd.description.toLowerCase(),
      cmd.displayName?.toLowerCase() ?? '',
      cmd.label?.toLowerCase() ?? '',
    ].join(' ');
  }

  /**
   * Show suggestions based on query
   * @param preComputedKeywords - Optional pre-computed keywords to avoid duplicate splitKeywords call (D7)
   */
  private async showSuggestions(query: string, preComputedKeywords?: string[]): Promise<void> {
    // Load commands if not loaded
    if (this.skills.length === 0) {
      await this.loadSkills();
    }

    // Filter commands - AND search with space-separated keywords
    // D7: Use pre-computed keywords if available to avoid duplicate computation
    const lowerQuery = query.toLowerCase();
    const keywords = preComputedKeywords ?? splitKeywords(lowerQuery);

    // Filter by active trigger prefix: only show skills whose triggers include the active trigger
    // Skills without triggers default to ['/']
    const triggerFilteredSkills = this.skills.filter(cmd => {
      const triggers = cmd.triggers ?? ['/'];
      return triggers.includes(this.activeTriggerPrefix);
    });

    if (keywords.length === 0) {
      // Empty query: show all skills matching trigger
      this.filteredSkills = [...triggerFilteredSkills];
    } else {
      // Task #6: AND filter + fallback in 1 pass
      const fallbackResults: AgentSkillItem[] | null = keywords.length > 1 ? [] : null;

      this.filteredSkills = triggerFilteredSkills.filter(cmd => {
        const fields = this.getSearchableFields(cmd);
        if (keywords.every(kw => fields.includes(kw))) {
          return true;
        }
        // Collect first-keyword matches as fallback candidates
        if (fallbackResults && fields.includes(keywords[0]!)) {
          fallbackResults.push(cmd);
        }
        return false;
      });

      if (this.filteredSkills.length === 0 && fallbackResults && fallbackResults.length > 0) {
        this.filteredSkills = fallbackResults;
      }
    }

    if (this.filteredSkills.length === 0) {
      this.hideSuggestions();
      return;
    }

    // Task #3: Get usage bonuses with TTL cache
    // Check if all filtered skills have cached bonuses (handle new skills entering the filter)
    let usageBonuses: Record<string, number> = {};
    const now = Date.now();
    const cacheValid = now - this.usageBonusesCacheTime < AgentSkillManager.USAGE_BONUSES_CACHE_TTL;
    const allSkillsCovered = cacheValid && this.filteredSkills.every(cmd => cmd.name in this.usageBonusesCache);
    if (allSkillsCovered) {
      usageBonuses = this.usageBonusesCache;
    } else {
      try {
        const skillNames = this.filteredSkills.map(cmd => cmd.name);
        if (electronAPI?.agentSkills?.getUsageBonuses) {
          usageBonuses = await electronAPI.agentSkills.getUsageBonuses(skillNames);
          this.usageBonusesCache = usageBonuses;
          this.usageBonusesCacheTime = now;
        }
      } catch (error) {
        console.error('Failed to get usage bonuses:', error);
      }
    }

    // Task #4: Pre-compute scores before sorting
    const scoreMap = new Map<AgentSkillItem, number>();
    for (const cmd of this.filteredSkills) {
      const matchScore = this.getMatchScore(cmd.name, keywords, cmd.description);
      const bonus = usageBonuses[cmd.name] ?? 0;
      scoreMap.set(cmd, matchScore + bonus);
    }

    this.filteredSkills.sort((a, b) => {
      const scoreDiff = (scoreMap.get(b) ?? 0) - (scoreMap.get(a) ?? 0);
      if (scoreDiff !== 0) return scoreDiff;

      // Tiebreak: prefer shorter names, then alphabetical
      return compareTiebreak(a, b, { criteria: ['length'] }, {
        length: (item) => item.name.length,
      }) || a.name.localeCompare(b.name);
    });

    this.isActive = true;
    this.selectedIndex = 0;
    this.renderSuggestions(query);
    this.positionAtCursor(this.currentTriggerStartPos);
  }

  /**
   * Render suggestions to the UI
   */
  private renderSuggestions(query: string): void {
    if (!this.suggestionsContainer) return;

    this.suggestionsContainer.innerHTML = '';
    this.suggestionsContainer.style.display = 'block';
    // Reset hover-enabled class when re-rendering (will be re-added on mousemove)
    this.suggestionsContainer.classList.remove('hover-enabled');
    // Reset scroll position to top when search text changes
    this.suggestionsContainer.scrollTop = 0;
    // D5: Clear selected element reference since DOM is being rebuilt
    this.selectedSkillElement = null;

    const fragment = document.createDocumentFragment();

    this.filteredSkills.forEach((cmd, index) => {
      const item = document.createElement('div');
      item.className = 'agent-skill-suggestion-item';
      if (index === this.selectedIndex) {
        item.classList.add('selected');
        this.selectedSkillElement = item;
      }
      item.dataset.index = index.toString();

      // Create codicon icon (loader guarantees codicon- prefix; fallback is defensive only)
      const iconSpan = document.createElement('span');
      const iconClass = cmd.icon || 'codicon-terminal';
      iconSpan.className = `file-icon codicon ${iconClass}`;
      iconSpan.style.color = resolveColorValue(cmd.color, 'var(--color-teal-400)');
      item.appendChild(iconSpan);

      // Create name element with highlighting
      const nameSpan = document.createElement('span');
      nameSpan.className = 'agent-skill-name';
      nameSpan.innerHTML = escapeHtml(this.activeTriggerPrefix) + highlightMatch(cmd.name, query, 'agent-skill-highlight');
      item.appendChild(nameSpan);

      // Create badge (label takes priority over source/displayName)
      if (cmd.label) {
        const labelBadge = document.createElement('span');
        labelBadge.className = 'agent-skill-label';
        if (cmd.color) {
          labelBadge.dataset.color = cmd.color;
        }
        labelBadge.textContent = cmd.label;
        item.appendChild(labelBadge);
      } else if (cmd.displayName && cmd.source !== 'custom') {
        const sourceBadge = document.createElement('span');
        sourceBadge.className = 'agent-skill-source';
        sourceBadge.dataset.source = cmd.source || cmd.displayName;
        if (cmd.color) {
          sourceBadge.dataset.color = cmd.color;
        }
        sourceBadge.textContent = cmd.displayName;
        item.appendChild(sourceBadge);
      }

      // Create description element with highlighting
      if (cmd.description) {
        const descSpan = document.createElement('span');
        descSpan.className = 'agent-skill-description';
        descSpan.innerHTML = highlightMatch(cmd.description, query, 'agent-skill-highlight');
        item.appendChild(descSpan);
      }

      if (cmd.tooltip) {
        const infoIcon = document.createElement('span');
        infoIcon.className = 'frontmatter-info-icon';
        infoIcon.textContent = 'ⓘ';

        // Show popup on info icon hover
        infoIcon.addEventListener('mouseenter', () => {
          this.frontmatterPopupManager.show(cmd, infoIcon);
        });

        infoIcon.addEventListener('mouseleave', () => {
          this.frontmatterPopupManager.scheduleHide();
        });

        item.appendChild(infoIcon);
      }

      fragment.appendChild(item);
    });

    this.suggestionsContainer.appendChild(fragment);

    // Update hint text for the initially selected item
    this.updateHintForSelectedItem();
  }

  /**
   * Position suggestions container at cursor position (like MentionManager)
   * @param triggerPosition - Position of the trigger character (/)
   */
  private positionAtCursor(triggerPosition: number): void {
    if (!this.suggestionsContainer || !this.textarea || !this.mirrorDiv) return;

    const coordinates = getCaretCoordinates(this.textarea, this.mirrorDiv, triggerPosition);
    if (!coordinates) return;

    const { top: caretTop, left: caretLeft } = coordinates;

    // Get main content bounds for relative positioning
    const mainContent = document.querySelector('.main-content') as HTMLElement;
    if (!mainContent) return;

    const mainContentRect = mainContent.getBoundingClientRect();
    const viewportHeight = window.innerHeight;

    // Convert viewport coordinates to main-content relative coordinates
    const relativeCaretTop = caretTop - mainContentRect.top;
    const relativeCaretLeft = caretLeft - mainContentRect.left;

    // Calculate available space
    const spaceBelow = viewportHeight - caretTop - 20;
    const spaceAbove = caretTop - mainContentRect.top;

    // Decide whether to show above or below cursor
    const showAbove = spaceBelow < 200 && spaceAbove > spaceBelow;

    // Dynamic max height based on available space (calculate first for accurate menu height)
    const dynamicMaxHeight = showAbove
      ? Math.max(100, spaceAbove - 8)
      : Math.max(100, spaceBelow - 8);

    // Calculate vertical position (relative to main-content)
    let top: number;
    if (!showAbove) {
      // Show below cursor
      top = relativeCaretTop + 20;
    } else {
      // Show above cursor
      // Set maxHeight first so scrollHeight reflects actual visible height
      this.suggestionsContainer.style.maxHeight = `${dynamicMaxHeight}px`;
      const menuHeight = Math.min(
        this.suggestionsContainer.scrollHeight,
        dynamicMaxHeight
      );
      top = relativeCaretTop - menuHeight - 4;
      if (top < 0) top = 0;
    }

    // Calculate horizontal position with boundary constraints (relative to main-content)
    const minMenuWidth = 500;
    const rightMargin = 8;
    let left = relativeCaretLeft;

    // Ensure menu doesn't overflow right edge
    const availableWidth = mainContentRect.width - left - rightMargin;
    if (availableWidth < minMenuWidth) {
      const shiftAmount = minMenuWidth - availableWidth;
      left = Math.max(8, left - shiftAmount);
    }

    // Dynamic max width
    const dynamicMaxWidth = Math.max(minMenuWidth, mainContentRect.width - left - rightMargin);

    // Apply positioning (relative to main-content)
    this.suggestionsContainer.style.top = `${top}px`;
    this.suggestionsContainer.style.left = `${left}px`;
    this.suggestionsContainer.style.maxHeight = `${dynamicMaxHeight}px`;
    this.suggestionsContainer.style.maxWidth = `${dynamicMaxWidth}px`;
    this.suggestionsContainer.style.right = 'auto';
    this.suggestionsContainer.style.bottom = 'auto';
  }

  /**
   * UIのみを非表示にする（状態は保持）
   */
  private hideUI(): void {
    this.isActive = false;
    if (this.suggestionsContainer) {
      this.suggestionsContainer.style.display = 'none';
      this.suggestionsContainer.textContent = '';
      this.suggestionsContainer.classList.remove('hover-enabled');
    }
    // D5: Clear selected element reference when DOM is cleared
    this.selectedSkillElement = null;
    // Also hide frontmatter popup
    this.frontmatterPopupManager.hide();
  }

  /**
   * 全状態をリセットする
   */
  private resetState(): void {
    this.isEditingMode = false;
    this.editingSkillName = '';
    this.editingSkillStartPos = 0;
    this.selectedIndex = 0;
  }

  /**
   * Hide suggestions
   */
  public hideSuggestions(): void {
    this.hideUI();
    this.resetState();
    // C3: Reset keyword cache on hide
    this.lastSkillKeywords = '';
    // Restore default hint text
    this.restoreDefaultHintText();
  }

  /**
   * Handle keyboard navigation
   * Supports: ArrowDown/Ctrl+n/Ctrl+j (next), ArrowUp/Ctrl+p/Ctrl+k (previous), Enter/Tab (select), Escape (close), Ctrl+i (toggle tooltip)
   */
  private handleKeyDown(e: KeyboardEvent): void {
    // Skip all key handling if IME is active (for Japanese/CJK input)
    if (e.isComposing) return;

    // Ctrl+i: Toggle auto-show tooltip
    if (e.ctrlKey && e.key === 'i') {
      e.preventDefault();
      e.stopPropagation();
      this.frontmatterPopupManager.toggleAutoShow();
      return;
    }

    // Ctrl+n or Ctrl+j: Move down (same as ArrowDown)
    if (e.ctrlKey && (e.key === 'n' || e.key === 'j')) {
      e.preventDefault();
      e.stopPropagation();
      this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredSkills.length - 1);
      this.updateSelection();
      return;
    }

    // Ctrl+p or Ctrl+k: Move up (same as ArrowUp)
    if (e.ctrlKey && (e.key === 'p' || e.key === 'k')) {
      e.preventDefault();
      e.stopPropagation();
      this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
      this.updateSelection();
      return;
    }

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        e.stopPropagation();
        this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredSkills.length - 1);
        this.updateSelection();
        break;

      case 'ArrowUp':
        e.preventDefault();
        e.stopPropagation();
        this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
        this.updateSelection();
        break;

      case 'Enter':
        e.preventDefault();
        e.stopPropagation();
        if (e.ctrlKey && e.shiftKey) {
          // Ctrl+Shift+Enter: Reveal file directory in Finder
          this.revealSkillFileDirectory(this.selectedIndex);
        } else if (e.ctrlKey) {
          // Ctrl+Enter: Open file in editor without inserting command
          this.openSkillFile(this.selectedIndex);
        } else {
          // Enter: Paste immediately
          this.selectSkill(this.selectedIndex);
        }
        break;

      case 'Tab':
        e.preventDefault();
        e.stopPropagation();
        this.selectSkill(this.selectedIndex);
        break;

      case 'Escape':
        e.preventDefault();
        e.stopPropagation();
        this.hideSuggestions();
        break;
    }
  }

  /**
   * Update visual selection (differential update via reference - O(1) instead of O(n))
   */
  private updateSelection(): void {
    if (!this.suggestionsContainer) return;

    // Clear previous selection via reference (O(1))
    if (this.selectedSkillElement) {
      this.selectedSkillElement.classList.remove('selected');
    }

    // Find new selection via data attribute (O(1) lookup)
    const newSelected = this.suggestionsContainer.querySelector(
      `[data-index="${this.selectedIndex}"]`
    ) as HTMLElement | null;

    if (newSelected) {
      newSelected.classList.add('selected');
      // Scroll into view if needed (use 'instant' to ensure scroll completes before popup positioning)
      newSelected.scrollIntoView({ block: 'nearest', behavior: 'instant' });
    }

    this.selectedSkillElement = newSelected;

    // Update hint text based on selected item's inputText
    this.updateHintForSelectedItem();

    // Update tooltip if auto-show is enabled
    // Use requestAnimationFrame to ensure scroll position is settled before calculating popup position
    requestAnimationFrame(() => {
      this.frontmatterPopupManager.showForSelectedItem();
    });
  }

  /**
   * Select a command and insert it into the textarea
   * @param index - The index of the command to select
   */
  private selectSkill(index: number): void {
    if (index < 0 || index >= this.filteredSkills.length) return;

    const command = this.filteredSkills[index];
    if (!command || !this.textarea) return;

    // Register command to global cache for quick access
    this.registerSkillToCache(command.name);

    // Determine what to insert based on inputFormat setting
    // inputText がある場合: テンプレート解決済みテキスト（inputFormat: '{filepath}' 等）
    // inputFormat が 'name' or undefined: 従来通り / 付き（'name' はビルトインのデフォルト値）
    // それ以外の inputFormat: / なし
    const skillText = command.inputText
      ? command.inputText
      : command.inputFormat && command.inputFormat !== 'name'
        ? command.name            // inputFormat 指定あり（テンプレート等） → prefix なし
        : `${this.activeTriggerPrefix}${command.name}`;     // デフォルト or 'name' → prefix あり

    // Set editing mode BEFORE inserting text.
    // replaceRangeWithUndo triggers a synchronous input event → checkForAgentSkill.
    // Without editing mode, the query (e.g. "model ") would re-trigger showSuggestions.
    this.editingSkillName = command.name;
    this.editingSkillStartPos = this.currentTriggerStartPos;
    this.isEditingMode = true;
    this.hideUI();

    // Insert with trailing space for editing
    const skillWithSpace = skillText + ' ';
    const start = this.currentTriggerStartPos;
    const end = this.textarea.selectionStart;
    this.replaceRangeWithUndo(start, end, skillWithSpace);
    this.onSkillInsert(skillWithSpace);

    // After insertion, show argumentHint if available
    if (command.argumentHint) {
      this.showArgumentHintOnly(command);
    }
  }

  /**
   * Register a command to the global cache for quick access
   */
  private async registerSkillToCache(skillName: string): Promise<void> {
    try {
      if (electronAPI?.agentSkills?.registerGlobal) {
        await electronAPI.agentSkills.registerGlobal(skillName);
        // Invalidate usage bonuses cache so next suggestion reflects the update
        this.usageBonusesCacheTime = 0;
      }
    } catch (error) {
      console.error('Failed to register agent skill to cache:', error);
    }
  }

  /**
   * Replace text range with undo support
   * Uses document.execCommand for native undo/redo support
   */
  private replaceRangeWithUndo(start: number, end: number, newText: string): void {
    if (!this.textarea) return;

    this.textarea.focus();
    this.textarea.setSelectionRange(start, end);

    const success = document.execCommand('insertText', false, newText);
    if (!success) {
      // Fallback if execCommand is not supported
      const value = this.textarea.value;
      this.textarea.value = value.substring(0, start) + newText + value.substring(end);
      this.textarea.setSelectionRange(start + newText.length, start + newText.length);
    }
  }

  /**
   * Show only the argumentHint for a selected command.
   * Used after command confirmation (Enter at text start) when argumentHint exists.
   * This provides guidance for argument input without being in editing mode.
   */
  private showArgumentHintOnly(command: AgentSkillItem): void {
    if (!this.suggestionsContainer || !command.argumentHint) return;

    // Hide frontmatter popup
    this.frontmatterPopupManager.hide();

    this.suggestionsContainer.innerHTML = '';
    this.suggestionsContainer.style.display = 'block';
    this.suggestionsContainer.classList.remove('hover-enabled');

    const item = document.createElement('div');
    item.className = 'agent-skill-suggestion-item argument-hint-only';
    item.dataset.argumentHint = command.argumentHint;

    // Create name element
    const nameSpan = document.createElement('span');
    nameSpan.className = 'agent-skill-name';
    nameSpan.textContent = `${this.activeTriggerPrefix}${command.name}`;
    item.appendChild(nameSpan);

    // Add 'arg' badge with gray color
    const argBadge = document.createElement('span');
    argBadge.className = 'agent-skill-source argument-hint-badge';
    argBadge.textContent = 'arg';
    item.appendChild(argBadge);

    // Create argumentHint element
    const hintSpan = document.createElement('span');
    hintSpan.className = 'agent-skill-description';
    hintSpan.textContent = command.argumentHint;
    item.appendChild(hintSpan);

    // Add copy icon
    const copyIcon = document.createElement('span');
    copyIcon.className = 'argument-hint-copy-icon';
    copyIcon.innerHTML = `
      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
        <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
      </svg>
    `;
    item.appendChild(copyIcon);

    this.suggestionsContainer.appendChild(item);

    // Keep tracking state for proper cleanup, but not in editing mode
    this.filteredSkills = [command];
    this.selectedIndex = 0;
    this.isActive = false;
    this.isEditingMode = true; // Use editing mode to keep showing the hint
    this.editingSkillName = command.name;
    this.editingSkillStartPos = this.currentTriggerStartPos; // Track command position

    // Position at command location for correct display
    this.positionAtCursor(this.currentTriggerStartPos);
  }

  /**
   * Check if suggestions are currently active
   */
  public isActiveMode(): boolean {
    return this.isActive;
  }

  /**
   * Check if a string looks like a URL, file path, or directory path
   */
  private isOpenablePath(text: string | undefined): boolean {
    if (!text) return false;
    // URL patterns
    if (/^https?:\/\//.test(text)) return true;
    // Absolute or home-relative paths (e.g., /usr/local, ~/ghq/...)
    if (/^[~/]/.test(text)) return true;
    return false;
  }

  /**
   * Update hint text based on selected item's inputText.
   * Shows path-specific hints when inputText is a URL, file path, or directory path.
   */
  private updateHintForSelectedItem(): void {
    if (!this.updateHintText) return;
    const command = this.filteredSkills[this.selectedIndex];
    if (!command) return;

    if (this.isOpenablePath(command.inputText)) {
      const target = command.inputText!;
      if (/^https?:\/\//.test(target)) {
        this.updateHintText('Ctrl+Enter: open URL in browser');
      } else {
        this.updateHintText('Ctrl+Enter: open path / Ctrl+Shift+Enter: reveal in Finder');
      }
    } else {
      this.updateHintText('Ctrl+Enter: open file / Ctrl+Shift+Enter: reveal in Finder');
    }
  }

  /**
   * Restore default hint text when suggestions are hidden
   */
  private restoreDefaultHintText(): void {
    if (this.updateHintText && this.getDefaultHintText) {
      this.updateHintText(this.getDefaultHintText());
    }
  }

  /**
   * Open the command's inputText path/URL, or fall back to opening the source file in editor.
   * When inputText is a URL, file path, or directory path, open it instead of the source file.
   */
  private async openSkillFile(index: number): Promise<void> {
    if (index < 0 || index >= this.filteredSkills.length) return;

    const command = this.filteredSkills[index];
    if (!command?.filePath) return;

    try {
      // Suppress blur event to prevent window from closing
      this.onBeforeOpenFile?.();
      // Enable draggable state while file is opening
      this.setDraggable?.(true);

      // If inputText is an openable path/URL, open that instead of the source file
      if (this.isOpenablePath(command.inputText)) {
        const target = command.inputText!;
        if (/^https?:\/\//.test(target)) {
          // Open URL in browser
          if (electronAPI?.shell?.openExternal) {
            await electronAPI.shell.openExternal(target);
          }
        } else {
          // Open file/directory path in editor
          if (electronAPI?.file?.openInEditor) {
            await electronAPI.file.openInEditor(target);
          }
        }
      } else {
        // Default: open the source file in editor
        if (electronAPI?.file?.openInEditor) {
          await electronAPI.file.openInEditor(command.filePath);
        }
      }
      // Note: Do not restore focus to PromptLine window
      // The opened file's application should stay in foreground
    } catch (err) {
      console.error('Failed to open command file in editor:', err);
      // Disable draggable state on error
      this.setDraggable?.(false);
    }
  }

  /**
   * Reveal the directory of a skill file (or inputText path) in Finder
   */
  private async revealSkillFileDirectory(index: number): Promise<void> {
    if (index < 0 || index >= this.filteredSkills.length) return;

    const command = this.filteredSkills[index];
    if (!command?.filePath) return;

    // Use inputText path if it's an openable path, otherwise use source file path
    const targetPath = (this.isOpenablePath(command.inputText) && !/^https?:\/\//.test(command.inputText!))
      ? command.inputText!
      : command.filePath;

    try {
      this.onBeforeOpenFile?.();
      this.setDraggable?.(true);

      if (electronAPI?.file?.revealInFinder) {
        const result = await electronAPI.file.revealInFinder(targetPath);
        if (result.success) {
          // Restore focus to PromptLine window after a short delay
          setTimeout(() => {
            electronAPI?.window?.focus?.().catch(() => {});
          }, 100);
          return;
        }
      }
      this.setDraggable?.(false);
    } catch (err) {
      console.error('Failed to reveal skill file directory in Finder:', err);
      this.setDraggable?.(false);
    }
  }

  /**
   * Get command source by command name
   * Returns undefined if command is not found, has no source, or exists in multiple sources (duplicate)
   * @param skillName - Command name without slash (e.g., "commit")
   * @returns Source identifier (e.g., "claude", "codex", "gemini", "custom") or undefined
   */
  public getSkillSource(skillName: string): string | undefined {
    const matchingSkills = this.skills.filter(cmd => cmd.name === skillName);

    // No command found or command has no source
    if (matchingSkills.length === 0) {
      return undefined;
    }

    // Check if command exists in multiple different sources (duplicate)
    const uniqueSources = new Set(matchingSkills.map(cmd => cmd.source).filter(Boolean));
    if (uniqueSources.size > 1) {
      return undefined; // Duplicate command in multiple sources - return undefined for gray highlight
    }

    return matchingSkills[0]?.source;
  }

  /**
   * Get color for a agent skill by name
   * @param skillName - Command name without slash (e.g., "commit")
   * @returns Color (e.g., "purple", "blue") or undefined
   */
  public getSkillColor(skillName: string): string | undefined {
    const matchingSkills = this.skills.filter(cmd => cmd.name === skillName);

    // No command found or command has no color
    if (matchingSkills.length === 0) {
      return undefined;
    }

    // Return first matching command's color
    return matchingSkills[0]?.color;
  }

  /**
   * Get all known command names for multi-word command detection in highlighting
   * @returns Array of command names (without slash prefix)
   */
  public getKnownSkillNames(): string[] {
    return this.skills.map(cmd => cmd.name);
  }

  public getTriggerPrefixes(): string[] {
    return this.triggerPrefixes;
  }

  /**
   * Invalidate command cache to force reload
   */
  public invalidateCache(): void {
    this.skills = [];
    this.searchableFieldsCache.clear();
    this.usageBonusesCache = {};
    this.usageBonusesCacheTime = 0;
    // C3: Reset keyword cache on invalidate
    this.lastSkillKeywords = '';
  }

  /**
   * P2: Prefetch skills in background to warm cache before user interaction
   */
  public async prefetchSkills(): Promise<void> {
    try {
      await this.loadSkills();
    } catch {
      // Prefetch failure is non-critical
    }
  }
}
