/**
 * @fileoverview Bash Tool Parser - Detects active Bash tool commands with file paths
 *
 * This module parses terminal output from Claude Code sessions to detect:
 * - Bash tool invocations (● Bash(command) pattern)
 * - File paths within commands (for tail, cat, head, grep, watch, less)
 * - Tool completion (✓ or ✗ status)
 *
 * When a file-viewing command is detected, emits events with clickable paths
 * that can be used to open live log viewer windows.
 *
 * @module bash-tool-parser
 */
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js';
// ========== Configuration Constants ==========
/**
 * Maximum number of active tools to track per session.
 * Older tools are removed when this limit is reached.
 */
const MAX_ACTIVE_TOOLS = 20;
/**
 * Debounce interval for event emissions (milliseconds).
 * Prevents UI jitter from rapid consecutive updates.
 */
const EVENT_DEBOUNCE_MS = 50;
/**
 * Maximum line buffer size to prevent unbounded growth from long lines.
 */
const MAX_LINE_BUFFER_SIZE = 64 * 1024;
// ========== Pre-compiled Regex Patterns ==========
/**
 * Matches Bash tool invocation line from Claude Code output.
 * Pattern: ● Bash(command) or ● Bash(command) timeout: 5m 0s
 * The tool name can appear with or without the bullet point.
 *
 * Capture groups:
 * - 1: The command being executed
 * - 2: Optional timeout string
 */
const BASH_TOOL_START_PATTERN = /(?:^|\s)●?\s*Bash\((.+?)\)(?:\s+timeout:\s*([^\n]+))?/;
/**
 * Matches tool completion indicators.
 * ✓ indicates success, ✗ indicates failure.
 */
const TOOL_COMPLETION_PATTERN = /(?:✓|✗)\s+Bash/;
/**
 * Commands that view/stream file content (worth tracking for live viewing).
 * These are the commands where clicking to open a log viewer makes sense.
 */
const FILE_VIEWER_COMMANDS = /^(?:tail|cat|head|less|grep|watch|multitail)\s+/;
/**
 * Alternative: Commands with -f flag (follow mode) are especially interesting
 */
const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
/**
 * Extracts file paths from a command string.
 * Matches paths starting with / or ~ followed by path characters.
 * Excludes common non-path patterns like flags.
 *
 * Note: This is a simpler approach - we run it on each command string
 * rather than trying to match globally.
 */
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g;
/**
 * Pattern to detect paths that are likely not real files (flags, etc.)
 */
const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
/**
 * Pattern to detect command suggestions in plain text output.
 * Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
 * This catches commands Claude mentions but doesn't execute.
 */
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/;
/**
 * Pattern to detect log file paths mentioned in text (even without commands).
 * Matches paths ending in .log, .txt, .out, or in common log directories.
 */
const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
// ========== BashToolParser Class ==========
/**
 * Parses Claude Code terminal output to detect Bash tool commands with file paths.
 * Emits events when file-viewing commands are detected, allowing the UI to
 * display clickable paths for opening live log viewers.
 *
 * @example
 * ```typescript
 * const parser = new BashToolParser({ sessionId: 'abc123' });
 * parser.on('toolStart', (tool) => {
 *   console.log(`New tool: ${tool.command}`);
 *   console.log(`File paths: ${tool.filePaths.join(', ')}`);
 * });
 * parser.processTerminalData(terminalOutput);
 * ```
 */
export class BashToolParser extends EventEmitter {
    _sessionId;
    _enabled;
    _activeTools = new Map();
    _lineBuffer = '';
    _lastToolId = null;
    _workingDir;
    _homeDir;
    // Centralized resource cleanup for auto-remove timers
    cleanup = new CleanupManager();
    // Flag to prevent operations after destroy
    _destroyed = false;
    // Debouncing
    _updateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
    constructor(config) {
        super();
        this._sessionId = config.sessionId;
        this._enabled = config.enabled ?? true;
        this._workingDir = config.workingDir || process.cwd();
        this._homeDir = process.env.HOME || '/home/user';
    }
    // ========== Public Accessors ==========
    /** Whether the parser is currently enabled */
    get enabled() {
        return this._enabled;
    }
    /** Session ID this parser belongs to */
    get sessionId() {
        return this._sessionId;
    }
    /** Currently active tools */
    get activeTools() {
        return Array.from(this._activeTools.values());
    }
    /** Current working directory used for path resolution */
    get workingDir() {
        return this._workingDir;
    }
    // ========== Path Normalization ==========
    /**
     * Normalize a file path to its canonical form.
     * - Expands ~ to home directory
     * - Resolves relative paths against working directory
     * - Normalizes . and .. components
     * - Removes trailing slashes
     */
    normalizePath(path) {
        if (!path)
            return '';
        let normalized = path.trim();
        // Expand ~ to home directory
        if (normalized.startsWith('~/')) {
            normalized = this._homeDir + normalized.slice(1);
        }
        else if (normalized === '~') {
            normalized = this._homeDir;
        }
        // If not absolute, resolve against working directory
        if (!normalized.startsWith('/')) {
            normalized = this._workingDir + '/' + normalized;
        }
        // Normalize path components (resolve . and ..)
        const parts = normalized.split('/');
        const stack = [];
        for (const part of parts) {
            if (part === '' || part === '.') {
                continue;
            }
            else if (part === '..') {
                if (stack.length > 1) {
                    stack.pop();
                }
            }
            else {
                stack.push(part);
            }
        }
        return '/' + stack.join('/');
    }
    /**
     * Extract just the filename from a path.
     */
    getFilename(path) {
        const parts = path.split('/');
        return parts[parts.length - 1] || '';
    }
    /**
     * Check if a path is a "shallow root path" - an absolute path with only one
     * component after root (e.g., /test.txt, /file.log).
     * These are often typos where the user meant a relative path.
     */
    isShallowRootPath(path) {
        if (!path.startsWith('/'))
            return false;
        const parts = path.split('/').filter((p) => p !== '');
        return parts.length === 1;
    }
    /**
     * Check if a path is inside (or is) the working directory.
     */
    isPathInWorkingDir(path) {
        const normalized = this.normalizePath(path);
        return normalized.startsWith(this._workingDir + '/') || normalized === this._workingDir;
    }
    /**
     * Smart path equivalence check.
     * Two paths are considered equivalent if:
     * 1. They normalize to the same path (standard case)
     * 2. One is a "shallow root path" (e.g., /test.txt) and the other is the
     *    same filename inside the working directory - the shallow root path
     *    is likely a typo and they probably meant the same file.
     */
    pathsAreEquivalent(path1, path2) {
        const norm1 = this.normalizePath(path1);
        const norm2 = this.normalizePath(path2);
        // Standard check: exact normalized match
        if (norm1 === norm2)
            return true;
        // Smart check: shallow root path vs working dir path with same filename
        const file1 = this.getFilename(norm1);
        const file2 = this.getFilename(norm2);
        if (file1 !== file2)
            return false; // Different filenames, can't be equivalent
        const shallow1 = this.isShallowRootPath(path1);
        const shallow2 = this.isShallowRootPath(path2);
        const inWorkDir1 = this.isPathInWorkingDir(norm1);
        const inWorkDir2 = this.isPathInWorkingDir(norm2);
        // If one is shallow root (e.g., /test.txt) and other is in working dir
        // with same filename, treat as equivalent (user likely made a typo)
        if (shallow1 && inWorkDir2)
            return true;
        if (shallow2 && inWorkDir1)
            return true;
        return false;
    }
    /**
     * Given multiple paths, deduplicate and return the "best" paths.
     * Uses smart equivalence checking:
     * - Standard normalization for relative vs absolute paths
     * - Detects likely typos (e.g., /file.txt when workingDir/file.txt exists)
     * - Prefers paths inside the working directory
     * - Prefers longer, more explicit paths
     */
    deduplicatePaths(paths) {
        if (paths.length <= 1)
            return paths;
        const result = [];
        const seenNormalized = new Set();
        // Sort paths: prefer paths in working dir first, then by length (longer first)
        const sortedPaths = [...paths].sort((a, b) => {
            const aInWorkDir = this.isPathInWorkingDir(a);
            const bInWorkDir = this.isPathInWorkingDir(b);
            if (aInWorkDir && !bInWorkDir)
                return -1;
            if (bInWorkDir && !aInWorkDir)
                return 1;
            return b.length - a.length; // Longer paths first
        });
        for (const path of sortedPaths) {
            const normalized = this.normalizePath(path);
            // Check if we've already seen an equivalent path
            let isDuplicate = false;
            for (const existing of result) {
                if (this.pathsAreEquivalent(path, existing)) {
                    isDuplicate = true;
                    break;
                }
            }
            if (!isDuplicate && !seenNormalized.has(normalized)) {
                result.push(path);
                seenNormalized.add(normalized);
            }
        }
        return result;
    }
    // ========== Public Methods ==========
    /**
     * Update the working directory (e.g., when session changes directory).
     */
    setWorkingDir(workingDir) {
        this._workingDir = workingDir;
    }
    /**
     * Check if a file path is already being tracked by an active tool.
     * Uses path normalization to detect equivalent paths.
     * For example, "/test.txt" and "/home/user/test.txt" when workingDir
     * is "/home/user" would NOT be considered equivalent (different files),
     * but "test.txt" and "/home/user/test.txt" WOULD be (same file).
     */
    isFilePathTracked(filePath) {
        const normalizedNew = this.normalizePath(filePath);
        return Array.from(this._activeTools.values()).some((t) => {
            if (t.status !== 'running')
                return false;
            return t.filePaths.some((existingPath) => {
                const normalizedExisting = this.normalizePath(existingPath);
                return normalizedExisting === normalizedNew;
            });
        });
    }
    /**
     * Get all tracked paths (normalized) for debugging.
     */
    getTrackedPaths() {
        const paths = [];
        for (const tool of this._activeTools.values()) {
            if (tool.status === 'running') {
                for (const path of tool.filePaths) {
                    paths.push({ raw: path, normalized: this.normalizePath(path) });
                }
            }
        }
        return paths;
    }
    /**
     * Enables the parser.
     */
    enable() {
        this._enabled = true;
    }
    /**
     * Disables the parser.
     */
    disable() {
        this._enabled = false;
    }
    /**
     * Resets the parser state, clearing all tracked tools.
     */
    reset() {
        this._activeTools.clear();
        this._lineBuffer = '';
        this._lastToolId = null;
        this.emitUpdate();
    }
    /**
     * Process terminal data to detect Bash tool patterns.
     * Call this with each chunk of PTY output.
     *
     * @param data - Raw terminal data (may include ANSI codes)
     */
    processTerminalData(data) {
        if (!this._enabled || this._destroyed)
            return;
        // Append to line buffer (raw data — lines will be stripped individually in processLine)
        this._lineBuffer += data;
        // Prevent unbounded growth
        if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
            const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
            this._lineBuffer =
                trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
        }
        // Process complete lines
        const lines = this._lineBuffer.split('\n');
        // Keep the last incomplete line in buffer
        this._lineBuffer = lines.pop() || '';
        for (const line of lines) {
            this.processLine(line);
        }
    }
    /**
     * Process pre-stripped terminal data (ANSI codes already removed).
     * Use this when the caller has already stripped ANSI to avoid redundant regex work.
     */
    processCleanData(data) {
        if (!this._enabled || this._destroyed)
            return;
        this._lineBuffer += data;
        if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
            const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
            this._lineBuffer =
                trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
        }
        const lines = this._lineBuffer.split('\n');
        this._lineBuffer = lines.pop() || '';
        for (const line of lines) {
            this.processCleanLine(line);
        }
    }
    // ========== Private Methods ==========
    /**
     * Process a single line of terminal output (raw — will strip ANSI).
     */
    processLine(line) {
        const cleanLine = stripAnsi(line);
        this.processCleanLine(cleanLine);
    }
    /**
     * Process a single pre-stripped line of terminal output.
     */
    processCleanLine(cleanLine) {
        if (this._handleToolStart(cleanLine))
            return;
        if (this._handleToolCompletion(cleanLine))
            return;
        if (this._handleTextCommand(cleanLine))
            return;
        this._handleLogFileMention(cleanLine);
    }
    _handleToolStart(cleanLine) {
        const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
        if (!startMatch)
            return false;
        const command = startMatch[1];
        const timeout = startMatch[2]?.trim();
        if (!this.isFileViewerCommand(command))
            return true;
        const filePaths = this.extractFilePaths(command);
        // Skip if any file path is already tracked (cross-pattern dedup)
        if (filePaths.some((fp) => this.isFilePathTracked(fp)))
            return true;
        if (filePaths.length > 0) {
            const tool = this._createActiveTool(command, filePaths, 'running', timeout);
            // Enforce max tools limit
            if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
                // Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
                let oldestKey;
                let oldestTime = Infinity;
                for (const [key, entry] of this._activeTools) {
                    if (entry.startedAt < oldestTime) {
                        oldestTime = entry.startedAt;
                        oldestKey = key;
                    }
                }
                if (oldestKey) {
                    this._activeTools.delete(oldestKey);
                }
            }
            this._activeTools.set(tool.id, tool);
            this._lastToolId = tool.id;
            this.emit('toolStart', tool);
            this.scheduleUpdate();
        }
        return true;
    }
    _handleToolCompletion(cleanLine) {
        if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId)
            return false;
        const tool = this._activeTools.get(this._lastToolId);
        if (tool && tool.status === 'running') {
            tool.status = 'completed';
            this.emit('toolEnd', tool);
            this.scheduleUpdate();
            this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
        }
        this._lastToolId = null;
        return true;
    }
    _handleTextCommand(cleanLine) {
        const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
        if (!textCmdMatch)
            return false;
        const filePath = textCmdMatch[2];
        // Don't add if file path already tracked (cross-pattern dedup)
        if (this.isFilePathTracked(filePath))
            return true;
        const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
        this._activeTools.set(tool.id, tool);
        this.emit('toolStart', tool);
        this.scheduleUpdate();
        // Auto-remove suggestions after 30 seconds
        this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
        return true;
    }
    _handleLogFileMention(cleanLine) {
        LOG_FILE_MENTION_PATTERN.lastIndex = 0;
        let logMatch;
        while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
            const filePath = logMatch[1].replace(/[,;:]+$/, ''); // Clean trailing punctuation
            // Skip if it looks invalid
            if (INVALID_PATH_PATTERN.test(filePath))
                continue;
            // Skip if file path already tracked (cross-pattern dedup)
            if (this.isFilePathTracked(filePath))
                continue;
            const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
            this._activeTools.set(tool.id, tool);
            this.emit('toolStart', tool);
            this.scheduleUpdate();
            // Auto-remove after 60 seconds
            this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
        }
    }
    _createActiveTool(command, filePaths, status, timeout) {
        return {
            id: uuidv4(),
            command,
            filePaths,
            timeout,
            startedAt: Date.now(),
            status,
            sessionId: this._sessionId,
        };
    }
    _scheduleAutoRemove(toolId, delayMs, description) {
        this.cleanup.setTimeout(() => {
            if (this._destroyed)
                return;
            this._activeTools.delete(toolId);
            this.scheduleUpdate();
        }, delayMs, { description });
    }
    /**
     * Check if a command is a file-viewing command worth tracking.
     */
    isFileViewerCommand(command) {
        // Commands that typically view files
        if (FILE_VIEWER_COMMANDS.test(command)) {
            return true;
        }
        // Any command with -f (follow) flag is interesting
        if (FOLLOW_MODE_PATTERN.test(` ${command} `)) {
            return true;
        }
        return false;
    }
    /**
     * Extract file paths from a command string.
     * Returns deduplicated paths, preferring more complete/absolute versions.
     */
    extractFilePaths(command) {
        const rawPaths = [];
        let match;
        // Reset regex state
        FILE_PATH_PATTERN.lastIndex = 0;
        while ((match = FILE_PATH_PATTERN.exec(command)) !== null) {
            const path = match[1];
            // Skip invalid paths
            if (INVALID_PATH_PATTERN.test(path)) {
                continue;
            }
            // Skip if it looks like a flag (starts with -)
            if (path.includes('/-')) {
                continue;
            }
            // Clean up path (remove trailing punctuation)
            const cleanPath = path.replace(/[,;:]+$/, '');
            if (cleanPath) {
                rawPaths.push(cleanPath);
            }
        }
        // Deduplicate paths that resolve to the same file
        return this.deduplicatePaths(rawPaths);
    }
    /**
     * Schedule a debounced update emission.
     */
    scheduleUpdate() {
        this._updateDeb.schedule(() => {
            this.emitUpdate();
        });
    }
    /**
     * Emit the current active tools list.
     */
    emitUpdate() {
        this.emit('toolsUpdate', this.activeTools);
    }
    /**
     * Clean up resources.
     */
    destroy() {
        this._destroyed = true;
        this._updateDeb.dispose();
        this.cleanup.dispose();
        this._activeTools.clear();
        this.removeAllListeners();
    }
}
//# sourceMappingURL=bash-tool-parser.js.map