// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * NERF Memory — Stage 1: Semantic Structured Compression
 *
 * Converts raw security engagement dialogues into atomic, self-contained
 * memory entries through extraction:
 *
 * - Security-specific entity extraction (IPs, CVEs, MITRE ATT&CK, tools)
 * - Information density gating (skip low-information exchanges)
 * - Heuristic compression (always works, no LLM required)
 * - LLM compression (Anthropic/OpenAI SDK — falls back to heuristic on error)
 *
 * Ported from Python memory/core/compressor.py.
 */

import { randomUUID } from 'node:crypto';

// ---------------------------------------------------------------------------
// Security-specific extraction patterns
// ---------------------------------------------------------------------------

const _IP_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
const _CVE_RE = /CVE-\d{4}-\d{4,}/g;
const _MITRE_RE = /T\d{4}(?:\.\d{3})?/g;
const _PORT_RE = /\b(?:port\s+)?(\d{1,5})(?:\/(?:tcp|udp))?\b/gi;
const _DOMAIN_RE =
  /\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b/g;
const _HASH_MD5_RE = /\b[a-fA-F0-9]{32}\b/g;
const _HASH_SHA256_RE = /\b[a-fA-F0-9]{64}\b/g;

// File extensions to filter from domain matches
const _FILE_EXTENSIONS = ['.py', '.js', '.md', '.yaml', '.json', '.txt'];

// ---------------------------------------------------------------------------
// Entity Extraction
// ---------------------------------------------------------------------------

/**
 * Extract security-relevant entities from text using regex patterns.
 * @param {string} text
 * @returns {{ ips: string[], cves: string[], mitre: string[], domains: string[], hashes_sha256: string[], hashes_md5: string[] }}
 */
function extractSecurityEntities(text) {
  // Reset lastIndex on all regexes (they have /g flag)
  const matchAll = (re, str) => [...str.matchAll(new RegExp(re.source, re.flags))].map((m) => m[0]);

  const ips = [...new Set(matchAll(_IP_RE, text))];
  const cves = [...new Set(matchAll(_CVE_RE, text))];
  const mitre = [...new Set(matchAll(_MITRE_RE, text))];
  const hashes_sha256 = [...new Set(matchAll(_HASH_SHA256_RE, text))];
  const hashes_md5 = [...new Set(matchAll(_HASH_MD5_RE, text))];

  // Filter hashes: SHA256 matches are 64 chars, MD5 are 32 chars.
  // If a 64-char hash also matches the 32-char pattern, remove from MD5.
  const sha256Set = new Set(hashes_sha256);
  const filteredMd5 = hashes_md5.filter(
    (h) => !hashes_sha256.some((s) => s.includes(h)),
  );

  const rawDomains = [...new Set(matchAll(_DOMAIN_RE, text))];
  const domains = rawDomains.filter(
    (d) => !_FILE_EXTENSIONS.some((ext) => d.endsWith(ext)),
  );

  return { ips, cves, mitre, domains, hashes_sha256, hashes_md5: filteredMd5 };
}

// ---------------------------------------------------------------------------
// DialogueTurn
// ---------------------------------------------------------------------------

/**
 * Single turn in a security engagement dialogue.
 */
class DialogueTurn {
  /**
   * @param {{ turnId: number, role: string, content: string, timestamp?: string, toolName?: string, toolOutput?: string }} opts
   */
  constructor(opts = {}) {
    this.turnId = opts.turnId ?? 0;
    this.role = opts.role ?? 'user';
    this.content = opts.content ?? '';
    this.timestamp = opts.timestamp ?? '';
    this.toolName = opts.toolName ?? '';
    this.toolOutput = opts.toolOutput ?? '';
  }
}

// ---------------------------------------------------------------------------
// CompressedEntry
// ---------------------------------------------------------------------------

/**
 * Atomic memory unit produced by Stage 1 compression.
 */
class CompressedEntry {
  constructor(opts = {}) {
    this.entryId = opts.entryId ?? randomUUID();
    this.losslessRestatement = opts.losslessRestatement ?? '';
    this.memoryType = opts.memoryType ?? 'note';
    this.confidence = opts.confidence ?? 'confirmed';

    // Security-specific extracted metadata
    this.targets = opts.targets ?? [];
    this.cveIds = opts.cveIds ?? [];
    this.mitreAttack = opts.mitreAttack ?? [];
    this.severity = opts.severity ?? '';
    this.toolsUsed = opts.toolsUsed ?? [];
    this.hashes = opts.hashes ?? [];

    // General metadata
    this.keywords = opts.keywords ?? [];
    this.persons = opts.persons ?? [];
    this.entities = opts.entities ?? [];
    this.timestamp = opts.timestamp ?? '';
    this.topic = opts.topic ?? '';

    // Source tracking
    this.sourceTurns = opts.sourceTurns ?? [];
    this.engagementId = opts.engagementId ?? '';
    this.sourceSkill = opts.sourceSkill ?? '';

    this.createdAt = opts.createdAt ?? new Date().toISOString();
  }
}

// ---------------------------------------------------------------------------
// DensityGate
// ---------------------------------------------------------------------------

/**
 * Information density gate — filters out low-information dialogue turns.
 *
 * Scores based on: technical content, content length, lexical diversity.
 */
class DensityGate {
  static LOW_INFO_PATTERNS = [
    /^(?:ok|okay|sure|yes|no|thanks|thank you|got it|understood)[.!?]?$/i,
    /^(?:let me|i'll|i will|going to)\s/i,
    /^(?:here (?:is|are)|sure,?\s+here)/i,
  ];

  /**
   * @param {number} minScore — minimum density score to include a turn (default 0.3)
   */
  constructor(minScore = 0.3) {
    this.minScore = minScore;
  }

  /**
   * Score information density of a dialogue turn (0.0 - 1.0).
   * @param {DialogueTurn} turn
   * @returns {number}
   */
  score(turn) {
    const text = (turn.content || '').trim();
    if (!text) return 0.0;

    let score = 0.0;

    // Length score
    const length = text.length;
    if (length > 200) {
      score += 0.3;
    } else if (length > 50) {
      score += 0.15;
    }

    // Technical content indicators
    if (_IP_RE.test(text)) score += 0.15;
    _IP_RE.lastIndex = 0;
    if (_CVE_RE.test(text)) score += 0.2;
    _CVE_RE.lastIndex = 0;
    if (_MITRE_RE.test(text)) score += 0.15;
    _MITRE_RE.lastIndex = 0;
    if (text.includes('```')) score += 0.2;
    if (_HASH_SHA256_RE.test(text) || _HASH_MD5_RE.test(text)) score += 0.1;
    _HASH_SHA256_RE.lastIndex = 0;
    _HASH_MD5_RE.lastIndex = 0;

    // Unique term ratio (lexical diversity)
    const words = text.toLowerCase().split(/\s+/);
    if (words.length > 0) {
      const uniqueRatio = new Set(words).size / words.length;
      score += uniqueRatio * 0.2;
    }

    // Penalize boilerplate
    for (const pat of DensityGate.LOW_INFO_PATTERNS) {
      if (pat.test(text)) {
        score *= 0.3;
        break;
      }
    }

    // Tool output is almost always high-info
    if (turn.role === 'tool' && turn.toolOutput) {
      score = Math.max(score, 0.5);
    }

    return Math.min(score, 1.0);
  }

  /**
   * @param {DialogueTurn} turn
   * @returns {boolean}
   */
  shouldInclude(turn) {
    return this.score(turn) >= this.minScore;
  }
}

// ---------------------------------------------------------------------------
// SemanticCompressor
// ---------------------------------------------------------------------------

// Stop words for keyword extraction
const _STOP_WORDS = new Set([
  'the', 'and', 'for', 'that', 'this', 'with', 'from', 'have', 'has',
  'been', 'were', 'was', 'are', 'will', 'would', 'could', 'should',
  'into', 'about', 'which',
]);

/**
 * Stage 1: Semantic Structured Compression.
 *
 * Processes dialogue windows through:
 * 1. Density gating — filter low-info turns
 * 2. Entity extraction — security-specific pattern matching
 * 3. LLM compression (when llmClient provided) or heuristic extraction
 * 4. Atomic entry creation — self-contained memory units
 */
class SemanticCompressor {
  /**
   * @param {{ llmClient?: any, windowSize?: number, overlapSize?: number, densityThreshold?: number, model?: string }} opts
   */
  constructor(opts = {}) {
    this.llmClient = opts.llmClient ?? null;
    this.windowSize = opts.windowSize ?? 20;
    this.overlapSize = opts.overlapSize ?? 2;
    this.densityGate = new DensityGate(opts.densityThreshold ?? 0.3);
    this.model = opts.model ?? 'claude-sonnet-4-6';
    this._previousContext = '';
  }

  /**
   * Compress dialogue turns into atomic memory entries.
   * @param {DialogueTurn[]} turns
   * @param {string} engagementId
   * @param {string} sourceSkill
   * @returns {Promise<CompressedEntry[]>}
   */
  async compress(turns, engagementId = '', sourceSkill = '') {
    // Step 1: Density gating
    const filtered = turns.filter((t) => this.densityGate.shouldInclude(t));
    if (filtered.length === 0) return [];

    // Step 2: Window processing
    const allEntries = [];
    const step = this.windowSize - this.overlapSize;
    for (let i = 0; i < filtered.length; i += step) {
      const window = filtered.slice(i, i + this.windowSize);
      const entries = await this._processWindow(window, engagementId, sourceSkill);
      allEntries.push(...entries);
    }

    return allEntries;
  }

  /**
   * Process a single window of dialogue turns.
   * @private
   */
  async _processWindow(window, engagementId, sourceSkill) {
    const combinedText = window.map((t) => t.content).join(' ');
    const entities = extractSecurityEntities(combinedText);

    let entries;
    if (this.llmClient !== null) {
      entries = await this._llmCompress(window, entities);
    } else {
      entries = this._heuristicCompress(window, entities);
    }

    // Annotate all entries with engagement context
    for (const entry of entries) {
      entry.engagementId = engagementId;
      entry.sourceSkill = sourceSkill;
      entry.targets = entities.ips.concat(entities.domains);
      entry.cveIds = entities.cves;
      entry.mitreAttack = entities.mitre;
      entry.hashes = entities.hashes_sha256.concat(entities.hashes_md5);
    }

    this._previousContext = this._buildContextSummary(entries);
    return entries;
  }

  /**
   * LLM-powered compression — sends dialogue to LLM for structured extraction.
   * Falls back to heuristic compression on error.
   * @private
   */
  async _llmCompress(window, entities) {
    const dialogueText = window.map((t) => `[${t.role}] ${t.content}`).join('\n');
    const extractionPrompt = this._buildExtractionPrompt(dialogueText, entities);

    const systemPrompt = [
      'You are a security engagement memory compressor. Extract atomic, self-contained memory entries from the dialogue.',
      'Each entry must be a complete statement that can be understood without the original dialogue.',
      '',
      'Return a JSON array of entries. Each entry has:',
      '- "restatement": string — lossless restatement of the finding/fact (1-3 sentences)',
      '- "type": string — one of: finding, ioc, ttp, note, recommendation',
      '- "confidence": string — one of: confirmed, inferred, uncertain',
      '- "severity": string — one of: critical, high, medium, low, info, or empty',
      '- "keywords": string[] — 3-8 content keywords for retrieval',
      '- "topic": string — brief topic label (2-5 words)',
      '',
      'Rules:',
      '- Extract ONLY security-relevant information. Skip greetings, confirmations, meta-discussion.',
      '- Each entry must stand alone — include target, context, and detail.',
      '- Prefer specific facts over vague summaries.',
      '- If the dialogue contains no security-relevant information, return an empty array [].',
      '',
      'Respond with ONLY the JSON array, no markdown fencing, no explanation.',
    ].join('\n');

    try {
      let responseText;

      if (this.llmClient.messages?.create) {
        // Anthropic SDK
        const response = await this.llmClient.messages.create({
          model: this.model,
          max_tokens: 2048,
          system: systemPrompt,
          messages: [{ role: 'user', content: extractionPrompt }],
        });
        responseText = response.content?.[0]?.text || '[]';
      } else if (this.llmClient.chat?.completions?.create) {
        // OpenAI SDK (Ollama, OpenAI, etc.)
        const response = await this.llmClient.chat.completions.create({
          model: this.model,
          max_tokens: 2048,
          messages: [
            { role: 'system', content: systemPrompt },
            { role: 'user', content: extractionPrompt },
          ],
        });
        responseText = response.choices?.[0]?.message?.content || '[]';
      } else {
        // Unknown client shape — fall back
        return this._heuristicCompress(window, entities);
      }

      // Parse LLM response
      const cleaned = responseText.replace(/^```(?:json)?\s*/m, '').replace(/\s*```\s*$/m, '').trim();
      const parsed = JSON.parse(cleaned);

      if (!Array.isArray(parsed) || parsed.length === 0) {
        return this._heuristicCompress(window, entities);
      }

      // Convert to CompressedEntry objects
      const entries = [];
      const turnIds = window.map(t => t.turnId);
      for (const item of parsed) {
        if (!item.restatement || typeof item.restatement !== 'string') continue;

        const validTypes = ['finding', 'ioc', 'ttp', 'note', 'recommendation'];
        const validConfidence = ['confirmed', 'inferred', 'uncertain'];

        entries.push(new CompressedEntry({
          losslessRestatement: item.restatement,
          memoryType: validTypes.includes(item.type) ? item.type : 'note',
          confidence: validConfidence.includes(item.confidence) ? item.confidence : 'confirmed',
          severity: item.severity || '',
          keywords: Array.isArray(item.keywords) ? item.keywords.slice(0, 10) : [],
          topic: item.topic || '',
          sourceTurns: turnIds,
          timestamp: window[0]?.timestamp || new Date().toISOString(),
        }));
      }

      return entries.length > 0 ? entries : this._heuristicCompress(window, entities);
    } catch (err) {
      // LLM error — fall back to heuristic silently
      const debug = process.env.NERF_DEBUG === '1'
        ? (msg) => process.stderr.write(`[compressor] ${msg}\n`)
        : () => {};
      debug(`LLM compression failed: ${err.message}, falling back to heuristic`);
      return this._heuristicCompress(window, entities);
    }
  }

  /**
   * Fallback heuristic compression when no LLM is available.
   * @private
   */
  _heuristicCompress(window, entities) {
    const entries = [];

    for (const turn of window) {
      const sentences = turn.content.split(/[.!?\n]+/);
      for (let sent of sentences) {
        sent = sent.trim();
        if (sent.length < 20) continue;

        const sentEntities = extractSecurityEntities(sent);
        const hasSecurityContent = Object.values(sentEntities).some(
          (arr) => arr.length > 0,
        );

        if (
          hasSecurityContent ||
          this.densityGate.score(new DialogueTurn({ turnId: 0, role: turn.role, content: sent })) > 0.5
        ) {
          // Determine memory type from content
          let memType = 'note';
          if (sentEntities.cves.length > 0) {
            memType = 'finding';
          } else if (sentEntities.ips.length > 0 || sentEntities.domains.length > 0) {
            memType = 'ioc';
          } else if (sentEntities.mitre.length > 0) {
            memType = 'ttp';
          }

          // Extract keywords (content words > 4 chars)
          const words = sent.toLowerCase().split(/\s+/);
          const keywords = words
            .map((w) => w.replace(/[.,;:!?()[\]{}"']/g, ''))
            .filter((w) => w.length > 4 && !_STOP_WORDS.has(w))
            .slice(0, 10);

          entries.push(
            new CompressedEntry({
              losslessRestatement: sent,
              memoryType: memType,
              keywords,
              sourceTurns: [turn.turnId],
              timestamp: turn.timestamp || new Date().toISOString(),
            }),
          );
        }
      }
    }

    return entries;
  }

  /**
   * Format a window of turns into human-readable text.
   * @private
   */
  _formatWindow(window) {
    const lines = [];
    for (const turn of window) {
      const ts = turn.timestamp ? ` [${turn.timestamp}]` : '';
      let prefix = turn.role.toUpperCase();
      if (turn.toolName) prefix = `TOOL(${turn.toolName})`;
      lines.push(`${prefix}${ts}: ${turn.content}`);
      if (turn.toolOutput) {
        lines.push(`  OUTPUT: ${turn.toolOutput.slice(0, 500)}`);
      }
    }
    return lines.join('\n');
  }

  /**
   * Build extraction prompt for LLM compression.
   * @private
   */
  _buildExtractionPrompt(dialogueText, entities) {
    let contextSection = '';
    if (this._previousContext) {
      contextSection = `## Previous Context (avoid duplication):\n${this._previousContext}\n\n---\n`;
    }

    let entityHints = '';
    if (Object.values(entities).some((arr) => arr.length > 0)) {
      const parts = [];
      if (entities.ips.length > 0) parts.push(`IPs: ${entities.ips.join(', ')}`);
      if (entities.cves.length > 0) parts.push(`CVEs: ${entities.cves.join(', ')}`);
      if (entities.mitre.length > 0) parts.push(`MITRE: ${entities.mitre.join(', ')}`);
      if (entities.domains.length > 0) parts.push(`Domains: ${entities.domains.slice(0, 10).join(', ')}`);
      entityHints = `\n## Detected Entities:\n${parts.join('\n')}\n`;
    }

    return `${contextSection}${entityHints}\n## Engagement Dialogue:\n${dialogueText}`;
  }

  /**
   * Build summary of recently compressed entries for context tracking.
   * @private
   */
  _buildContextSummary(entries) {
    if (entries.length === 0) return '';
    return entries.slice(-5).map((e) => `- ${e.losslessRestatement}`).join('\n');
  }
}

export {
  extractSecurityEntities,
  DialogueTurn,
  CompressedEntry,
  DensityGate,
  SemanticCompressor,
};
