// ══════════════════════════════════════════════════════════════
// nterm — Sniffer pipeline classes
// ══════════════════════════════════════════════════════════════
// Mirrors Python sniffer: accumulator → interruption detector →
// prompt detector → block extractor. Pure data classes — no DOM.

// ── Line Accumulator (mirrors sniffer/accumulator.py) ──────
class LineAccumulator {
  constructor() {
    this._buffer = '';
    this._stash = '';
    this._lineCount = 0;
    this._pendingCR = false;
    this._escState = null;  // null | 'esc' | 'csi' | 'osc' | 'charset'
  }

  feed(text) {
    if (!text) return [];
    const lines = [];

    for (const char of text) {
      // ── Escape sequence state machine ──────────────────
      if (this._escState === 'osc') {
        // OSC: eat until BEL (\x07) or ST (\x1b\\)
        if (char === '\x07') { this._escState = null; continue; }
        if (char === '\x1b') { this._escState = 'osc_st'; continue; }
        continue;
      }
      if (this._escState === 'osc_st') {
        // Expecting \\ to complete ST, but accept anything as terminator
        this._escState = null;
        continue;
      }
      if (this._escState === 'csi') {
        // CSI: eat until final byte (0x40-0x7E: @A-Z[\]^_`a-z{|}~)
        const code = char.charCodeAt(0);
        if (code >= 0x40 && code <= 0x7E) { this._escState = null; }
        continue;
      }
      if (this._escState === 'charset') {
        // Character set designation: eat one char after ( or )
        this._escState = null;
        continue;
      }
      if (this._escState === 'esc') {
        // Just saw ESC — classify the sequence
        if (char === '[')  { this._escState = 'csi'; continue; }
        if (char === ']')  { this._escState = 'osc'; continue; }
        if (char === '(' || char === ')') { this._escState = 'charset'; continue; }
        // Simple escape (ESC + one char) — eat and done
        this._escState = null;
        continue;
      }
      if (char === '\x1b') {
        this._escState = 'esc';
        continue;
      }
      // Also eat stray BEL that might appear outside OSC context
      if (char === '\x07') continue;

      // ── Normal character processing ────────────────────
      if (char === '\n') {
        if (this._pendingCR) {
          lines.push(this._cleanBuffer(this._stash));
          this._stash = '';
          this._pendingCR = false;
        } else {
          lines.push(this._cleanBuffer(this._buffer));
          this._buffer = '';
        }
        this._lineCount++;
      } else if (char === '\r') {
        this._stash = this._buffer;
        this._buffer = '';
        this._pendingCR = true;
      } else if (char === '\x08' || char === '\x7F') {
        if (this._pendingCR) {
          this._stash = '';
          this._pendingCR = false;
        }
        if (this._buffer.length > 0) {
          this._buffer = this._buffer.slice(0, -1);
        }
      } else {
        if (this._pendingCR) {
          this._stash = '';
          this._pendingCR = false;
        }
        this._buffer += char;
      }
    }
    return lines;
  }

  get pending() { return this._cleanBuffer(this._buffer); }
  get lineCount() { return this._lineCount; }

  // Strip orphaned OSC sequences where ESC was consumed upstream
  _cleanBuffer(str) {
    if (str.indexOf(']0;') === -1) return str;
    const idx = str.indexOf(']0;');
    const before = str.substring(0, idx);
    const after = str.substring(idx + 3); // skip "]0;"

    // The OSC title is "user@host: path" or "hostname" followed by real content.
    // BEL delimiter is already stripped, so title runs into real text.
    // Strategy: find where real content resumes by locating the LAST
    // occurrence of a prompt-like pattern (the first is inside the title).

    // Linux: last user@host occurrence
    const linuxHits = [...after.matchAll(/([\w\-\.]+@[\w\-\.]+)/g)];
    if (linuxHits.length >= 2) {
      const last = linuxHits[linuxHits.length - 1];
      return before + after.substring(last.index);
    }

    // Network: last hostname# or hostname>
    const netHits = [...after.matchAll(/([\w\-\.]+[#>])/g)];
    if (netHits.length >= 2) {
      const last = netHits[netHits.length - 1];
      return before + after.substring(last.index);
    }

    // Single occurrence — title IS the prompt text, just keep from that match
    if (linuxHits.length === 1) {
      return before + after.substring(linuxHits[0].index);
    }
    if (netHits.length === 1) {
      return before + after.substring(netHits[0].index);
    }

    return str;
  }

  reset() {
    this._buffer = '';
    this._stash = '';
    this._lineCount = 0;
    this._pendingCR = false;
    this._escState = null;
  }
}

// ── Interruption Detector (mirrors sniffer/interruption.py) ──
const _INTERRUPTION_PATTERNS = [
  // Pagination
  [/--\s*[Mm]ore\s*--/, 'pagination'],
  [/lines \d+-\d+\/\d+/, 'pagination'],
  [/lines \d+-\d+/, 'pagination'],
  // Confirmation
  [/\[yes\/no\]:\s*$/, 'confirm'],
  [/\[confirm\]\s*$/, 'confirm'],
  [/\(y\/n\)\s*\??\s*$/, 'confirm'],
  [/\[Y\/N\]:\s*$/, 'confirm'],
  [/Are you sure\b/, 'confirm'],
  [/Do you want to\b/, 'confirm'],
  [/Proceed\s*\?/, 'confirm'],
  [/Continue\s*\?/, 'confirm'],
  [/Save\?\s*\[/, 'confirm'],
  [/filename\s*\[.*\]\s*\?/, 'confirm'],
  // Continue
  [/[Pp]ress.*(?:ENTER|RETURN|any key)/, 'continue'],
  [/[Hh]it.*(?:ENTER|RETURN|any key)/, 'continue'],
  // Auth
  [/[Pp]assword:\s*$/, 'auth'],
  [/[Uu]sername:\s*$/, 'auth'],
];
const _INTERRUPTION_TYPES = new Set(['pagination', 'confirm', 'continue', 'auth']);

class InterruptionDetector {
  constructor() { this._last = 'normal'; }

  classify(line) {
    if (!line) { this._last = 'normal'; return 'normal'; }
    for (const [pattern, cls] of _INTERRUPTION_PATTERNS) {
      if (pattern.test(line)) { this._last = cls; return cls; }
    }
    this._last = 'normal';
    return 'normal';
  }

  get isInterrupted() { return _INTERRUPTION_TYPES.has(this._last); }
  get lastClassification() { return this._last; }
  reset() { this._last = 'normal'; }
}

// ── Prompt Detector (mirrors sniffer/prompt_detector.py) ────
const _PROMPT_PATTERNS = [
  { re: /^([\w\-\.]+)(\([^\)]+\))?[#>]\s*$/, type: 'network', score: 100 },
  { re: /^<[^>]+>\s+(\w+)@([\w\-\.]+)(?::[\S]*)?\$\s*$/, type: 'tagged_linux', score: 95 },
  { re: /^(\w+)@([\w\-\.]+)(?::[\S]*)?\$\s*$/, type: 'linux', score: 90 },
  { re: /^\[(\w+)@([\w\-\.]+)\s+[^\]]*\]\$\s*$/, type: 'bracketed', score: 90 },
  { re: /^.{1,60}[#>$%]\s*$/, type: 'general', score: 10 },
];

class PromptDetector {
  constructor(quietThreshold = 0.3) {
    this._state = 'learning';
    this._candidateLine = '';
    this._candidatePattern = null;
    this._candidateType = '';
    this._candidateScore = 0;
    this._candidateTime = 0;
    this._hostname = null;
    this._baseHostname = null;
    this._promptPattern = null;
    this._promptType = '';
    this._quietThreshold = quietThreshold;
    this._lastDataTime = 0;
    this._isInterrupted = false;
    this._events = [];
    this._matchCount = 0;
    this._linesSeen = 0;
    this._lastMatchedPending = '';
  }

  observe(line, isInterrupted) {
    this._linesSeen++;
    this._lastDataTime = performance.now() / 1000;
    this._isInterrupted = isInterrupted;
    this._lastMatchedPending = '';
    if (isInterrupted) return;

    if (this._state === 'learning') {
      this._observeLearning(line);
    } else if (this._state === 'candidate') {
      this._observeCandidate(line);
    }
  }

  checkPending(pendingText, isInterrupted) {
    if (isInterrupted || !pendingText) return;
    const now = performance.now() / 1000;
    const elapsed = this._lastDataTime ? (now - this._lastDataTime) : 0;

    if (this._state === 'learning') {
      const match = this._matchPromptPattern(pendingText);
      if (match && elapsed >= this._quietThreshold) {
        this._setCandidate(pendingText, match.type, match.score, match.re);
        this._confirmCandidate();
      }
    } else if (this._state === 'candidate') {
      if (elapsed >= this._quietThreshold) {
        this._confirmCandidate();
      }
    } else if (this._state === 'locked') {
      if (this._promptMatches(pendingText) && elapsed >= this._quietThreshold
          && pendingText !== this._lastMatchedPending) {
        this._lastMatchedPending = pendingText;
        this._matchCount++;
        this._emitEvent('matched', pendingText, `pending match #${this._matchCount}`);
      }
    }
  }

  isPrompt(line) {
    if (this._state !== 'locked' || !this._promptPattern) return false;
    return this._promptMatches(line);
  }

  get state() { return this._state; }
  get prompt() { return this._state === 'locked' ? this._candidateLine : null; }
  get hostname() { return this._hostname; }
  get baseHostname() { return this._baseHostname; }
  get promptType() { return this._promptType; }
  get matchCount() { return this._matchCount; }
  get events() { return this._events; }

  reset() {
    this._state = 'learning';
    this._candidateLine = '';
    this._candidatePattern = null;
    this._candidateType = '';
    this._candidateScore = 0;
    this._candidateTime = 0;
    this._hostname = null;
    this._baseHostname = null;
    this._promptPattern = null;
    this._promptType = '';
    this._lastDataTime = 0;
    this._isInterrupted = false;
    this._events = [];
    this._matchCount = 0;
    this._linesSeen = 0;
    this._lastMatchedPending = '';
  }

  // ── State machine internals ────────────────────────────
  _observeLearning(line) {
    const match = this._matchPromptPattern(line);
    if (!match) return;
    this._setCandidate(line, match.type, match.score, match.re);
  }

  _observeCandidate(line) {
    const match = this._matchPromptPattern(line);
    if (!match) return;

    if (this._candidatePattern && this._candidatePattern.test(line)) {
      this._emitEvent('confirmed', line, 'same pattern seen again');
      this._confirmCandidate();
      return;
    }

    if (match.score > this._candidateScore) {
      this._emitEvent('rejected', this._candidateLine,
        `replaced by higher-score: ${match.type}(${match.score})`);
      this._setCandidate(line, match.type, match.score, match.re);
    }
  }

  _setCandidate(line, matchType, score, pattern) {
    this._candidateLine = line;
    this._candidateType = matchType;
    this._candidateScore = score;
    this._candidateTime = performance.now() / 1000;
    this._candidatePattern = this._buildPromptRegex(line, matchType);
    this._state = 'candidate';
    this._emitEvent('candidate', line, `type=${matchType} score=${score}`);
  }

  _confirmCandidate() {
    this._promptPattern = this._candidatePattern;
    this._promptType = this._candidateType;
    this._hostname = this._extractHostname(this._candidateLine, this._candidateType);
    this._baseHostname = this._hostname;
    this._state = 'locked';
    this._matchCount = 1;
    this._emitEvent('confirmed', this._candidateLine,
      `hostname=${this._hostname} type=${this._promptType}`);
  }

  _promptMatches(line) {
    if (!this._promptPattern) return false;
    if (!this._promptPattern.test(line)) return false;

    if (this._promptType === 'network') {
      const m = /^([\w\-\.]+)(\([^\)]+\))?([#>])\s*$/.exec(line.trimEnd());
      if (m) {
        const host = m[1];
        const mode = m[2];
        const term = m[3];
        if (host === this._baseHostname) {
          // Update locked prompt text when terminator changes (> ↔ #)
          const oldTerm = this._candidateLine.trimEnd().slice(-1);
          if (term !== oldTerm) {
            this._candidateLine = line.trimEnd();
            this._emitEvent('privilege_change', line, `${oldTerm} → ${term}`);
          }
          if (mode && !this._candidateLine.includes(mode)) {
            this._emitEvent('variant', line, `mode=${mode}`);
          }
          return true;
        }
        return false;
      }
    }
    return true;
  }

  // ── Pattern matching ───────────────────────────────────
  _matchPromptPattern(line) {
    const stripped = line.trimEnd();
    if (!stripped || stripped.length > 80) return null;
    for (const p of _PROMPT_PATTERNS) {
      if (p.re.test(stripped)) return p;
    }
    return null;
  }

  _buildPromptRegex(line, matchType) {
    const stripped = line.trimEnd();
    if (matchType === 'network') {
      const m = /^([\w\-\.]+)(\([^\)]+\))?([#>])\s*$/.exec(stripped);
      if (m) {
        const host = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        // Use character class [#>] so privilege transitions (en/disable) don't break lock
        return new RegExp(`^${host}(\\([^\\)]+\\))?[#>]\\s*$`);
      }
    }
    if (matchType === 'linux') {
      const m = /^(\w+)@([\w\-\.]+)/.exec(stripped);
      if (m) {
        const user = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const host = m[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return new RegExp(`^${user}@${host}(?::[\\S]*)?\\$\\s*$`);
      }
    }
    if (matchType === 'tagged_linux') {
      const m = /^(<[^>]+>\s+)(\w+)@([\w\-\.]+)/.exec(stripped);
      if (m) {
        const tag = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const user = m[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const host = m[3].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return new RegExp(`^${tag}${user}@${host}(?::[\\S]*)?\\$\\s*$`);
      }
    }
    if (matchType === 'bracketed') {
      const m = /^\[(\w+)@([\w\-\.]+)/.exec(stripped);
      if (m) {
        const user = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const host = m[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return new RegExp(`^\\[${user}@${host}\\s+[^\\]]*\\]\\$\\s*$`);
      }
    }
    const escaped = stripped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    return new RegExp(escaped + '\\s*$');
  }

  _extractHostname(line, matchType) {
    const stripped = line.trimEnd();
    if (matchType === 'network') {
      const m = /^([\w\-\.]+)(\([^\)]+\))?[#>]\s*$/.exec(stripped);
      return m ? m[1] : null;
    }
    if (matchType === 'linux') {
      const m = /^(\w+)@([\w\-\.]+)/.exec(stripped);
      return m ? m[2] : null;
    }
    if (matchType === 'tagged_linux') {
      const m = /^<[^>]+>\s+(\w+)@([\w\-\.]+)/.exec(stripped);
      return m ? m[2] : null;
    }
    if (matchType === 'bracketed') {
      const m = /^\[(\w+)@([\w\-\.]+)/.exec(stripped);
      return m ? m[2] : null;
    }
    if (matchType === 'general') {
      const cleaned = stripped.replace(/[#>$%\s]+$/, '');
      const parts = cleaned.split(/\s+/);
      return parts.length ? parts[parts.length - 1] : null;
    }
    return null;
  }

  _emitEvent(eventType, line, detail = '') {
    this._events.push({
      timestamp: Date.now(),
      eventType,
      line,
      state: this._state,
      detail,
    });
    if (this._events.length > 200) {
      this._events = this._events.slice(-200);
    }
  }
}

// ── Block Extractor (mirrors sniffer/block_extractor.py) ────
class BlockExtractor {
  constructor() {
    this._state = 'banner';
    this._lastPrompt = '';
    this._command = '';
    this._commandLine = '';
    this._outputLines = [];
    this._rawLines = [];
    this._startLine = 0;
    this._endLine = 0;
    this._interrupted = false;
    this._commandTime = 0;
    this._bannerLines = [];
    this._blocks = [];
    this._events = [];
  }

  observe(line, lineNum, classification, isPrompt) {
    if (this._state === 'banner') return this._observeBanner(line, lineNum, isPrompt);
    if (this._state === 'idle') return this._observeIdle(line, lineNum, classification, isPrompt);
    if (this._state === 'collecting') return this._observeCollecting(line, lineNum, classification, isPrompt);
    return null;
  }

  notifyPrompt(promptText, lineNum) {
    this._lastPrompt = promptText;
    if (this._state === 'banner') {
      this._state = 'idle';
      this._emitEvent('banner_end', `${this._bannerLines.length} banner lines`);
      return null;
    }
    if (this._state === 'idle') return null;
    if (this._state === 'collecting') {
      this._endLine = lineNum;
      return this._finalizeBlock(promptText);
    }
    return null;
  }

  get state() { return this._state; }
  get currentCommand() { return this._state === 'collecting' ? this._command : null; }
  get currentLineCount() { return this._outputLines.length; }
  get blocks() { return this._blocks; }
  get events() { return this._events; }
  get banner() { return this._bannerLines; }

  reset() {
    this._state = 'banner';
    this._lastPrompt = '';
    this._command = '';
    this._commandLine = '';
    this._outputLines = [];
    this._rawLines = [];
    this._startLine = 0;
    this._endLine = 0;
    this._interrupted = false;
    this._commandTime = 0;
    this._bannerLines = [];
    this._blocks = [];
    this._events = [];
  }

  // ── State machine ──────────────────────────────────────
  _observeBanner(line, lineNum, isPrompt) {
    if (isPrompt) {
      this._lastPrompt = line;
      this._state = 'idle';
      this._emitEvent('banner_end', `${this._bannerLines.length} banner lines`);
      return null;
    }
    this._bannerLines.push(line);
    return null;
  }

  _observeIdle(line, lineNum, classification, isPrompt) {
    if (isPrompt) {
      this._lastPrompt = line;
      return null;
    }
    if (classification !== 'normal') return null;

    this._commandLine = line;
    this._command = this._extractCommand(line);
    this._startLine = lineNum;
    this._endLine = lineNum;
    this._outputLines = [];
    this._rawLines = [];
    this._interrupted = false;
    this._commandTime = performance.now();
    this._state = 'collecting';
    this._emitEvent('command', this._command);
    return null;
  }

  _observeCollecting(line, lineNum, classification, isPrompt) {
    this._endLine = lineNum;
    if (isPrompt) {
      this._lastPrompt = line;
      return this._finalizeBlock(line);
    }
    this._rawLines.push(line);
    if (classification === 'normal') {
      this._outputLines.push(line);
    } else {
      this._interrupted = true;
    }
    return null;
  }

  _finalizeBlock(prompt) {
    const command = this._command;
    if (!command.trim()) {
      this._state = 'idle';
      this._emitEvent('empty_discard', 'empty command');
      return null;
    }
    const outputText = this._outputLines.join('\n');
    const block = {
      blockId: crypto.randomUUID(),
      command,
      output: outputText,
      outputLines: [...this._outputLines],
      rawLines: [...this._rawLines],
      startLine: this._startLine,
      endLine: this._endLine,
      lineCount: this._outputLines.length,
      byteCount: outputText.length,
      timestamp: Date.now(),
      durationMs: Math.round(performance.now() - this._commandTime),
      interrupted: this._interrupted,
      prompt: prompt.trimEnd(),
    };
    this._blocks.push(block);
    if (this._blocks.length > 100) this._blocks = this._blocks.slice(-100);
    this._state = 'idle';
    this._emitEvent('block_complete',
      `cmd="${command}" lines=${block.lineCount} bytes=${block.byteCount} interrupted=${block.interrupted}`);
    return block;
  }

  _extractCommand(echoLine) {
    const stripped = echoLine.trimEnd();
    if (this._lastPrompt) {
      const prefix = this._lastPrompt.trimEnd();
      if (stripped.startsWith(prefix)) return stripped.slice(prefix.length).trim();
    }
    let m = stripped.match(/^[\w\-\.]+(?:\([^\)]+\))?[#>]\s*/);
    if (m) return stripped.slice(m[0].length).trim();
    m = stripped.match(/^\w+@[\w\-\.]+(?::[\S]*)?\$\s*/);
    if (m) return stripped.slice(m[0].length).trim();
    m = stripped.match(/^<[^>]+>\s+\w+@[\w\-\.]+(?::[\S]*)?\$\s*/);
    if (m) return stripped.slice(m[0].length).trim();
    m = stripped.match(/^\[\w+@[\w\-\.]+\s+[^\]]*\]\$\s*/);
    if (m) return stripped.slice(m[0].length).trim();
    return stripped;
  }

  _emitEvent(eventType, detail = '') {
    this._events.push({ timestamp: Date.now(), eventType, detail });
    if (this._events.length > 200) this._events = this._events.slice(-200);
  }
}