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

/**
 * KnowledgeStore — SQLite FTS5 persistence and query layer for the
 * NERF knowledge index.
 *
 * Stores markdown chunks (split by ## headers) from knowledge/ docs and
 * skills/ SKILL.md files with domain/mode/vuln-class metadata. Provides
 * BM25-ranked search with optional metadata filtering and mode-weighted
 * score boosting.
 *
 * Modeled on cli/lib/memory/engine.js SymbolicStore — same better-sqlite3
 * driver, WAL journal mode, FTS5 with porter+unicode61 tokenizer, and
 * token-quoting for query sanitization.
 */

import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

// ---------------------------------------------------------------------------
// Default config
// ---------------------------------------------------------------------------

const DEFAULT_DB_PATH = join(homedir(), '.nerf', 'knowledge', 'index.db');
const DEFAULT_LIMIT = 20;

// ---------------------------------------------------------------------------
// Domain–mode affinity map
// ---------------------------------------------------------------------------
// Each domain maps to per-mode float affinities (0.0–1.0).
// BM25 rank (negative) is multiplied by (1.0 + affinity) — higher affinity
// produces a more negative score, ranking the chunk higher for that mode.
// Domains not listed here receive no mode boost (affinity 0.0).
// ---------------------------------------------------------------------------

const MODE_AFFINITY_MAP = {
  // ---- Offensive-primary domains ----
  'web-security':           { red: 0.9, blue: 0.2, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.1, architect: 0.2 },
  'api-security':           { red: 0.8, blue: 0.3, purple: 0.7, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.3 },
  'binary-exploitation':    { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.0, incident: 0.1, architect: 0.0 },
  'linux-security':         { red: 0.7, blue: 0.3, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.2 },
  'offensive-security':     { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.2, incident: 0.0, architect: 0.0 },
  'red-team':               { red: 0.9, blue: 0.1, purple: 0.8, privacy: 0.0, recon: 0.2, incident: 0.0, architect: 0.0 },
  'red-teaming':            { red: 0.9, blue: 0.1, purple: 0.8, privacy: 0.0, recon: 0.2, incident: 0.0, architect: 0.0 },
  'post-exploitation':      { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.1, architect: 0.0 },
  'penetration-testing':    { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.3, incident: 0.0, architect: 0.0 },
  'bug-bounty':             { red: 0.9, blue: 0.1, purple: 0.6, privacy: 0.0, recon: 0.3, incident: 0.0, architect: 0.0 },
  'credential-attacks':     { red: 0.9, blue: 0.2, purple: 0.7, privacy: 0.1, recon: 0.1, incident: 0.2, architect: 0.0 },
  'evasion-techniques':     { red: 0.9, blue: 0.2, purple: 0.8, privacy: 0.0, recon: 0.0, incident: 0.1, architect: 0.0 },
  'exfiltration':           { red: 0.9, blue: 0.2, purple: 0.7, privacy: 0.2, recon: 0.0, incident: 0.3, architect: 0.0 },
  'social-engineering':     { red: 0.9, blue: 0.2, purple: 0.6, privacy: 0.1, recon: 0.3, incident: 0.1, architect: 0.0 },
  'vulnerability-research': { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.0, architect: 0.1 },
  'ctf':                    { red: 0.9, blue: 0.1, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.0, architect: 0.0 },
  'exploit-development':    { red: 0.9, blue: 0.1, purple: 0.7, privacy: 0.0, recon: 0.0, incident: 0.1, architect: 0.0 },
  'c2-frameworks':          { red: 0.9, blue: 0.2, purple: 0.8, privacy: 0.0, recon: 0.0, incident: 0.2, architect: 0.0 },
  'adversary-simulation':   { red: 0.8, blue: 0.3, purple: 0.9, privacy: 0.0, recon: 0.1, incident: 0.1, architect: 0.1 },
  'password-cracking':      { red: 0.9, blue: 0.1, purple: 0.6, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.0 },
  'reverse-engineering':    { red: 0.8, blue: 0.2, purple: 0.6, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.0 },
  'security-testing':       { red: 0.7, blue: 0.3, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.0, architect: 0.2 },
  'web-application-security': { red: 0.9, blue: 0.2, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.1, architect: 0.2 },

  // ---- Defensive-primary domains ----
  'defensive-security':     { red: 0.1, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.0, incident: 0.3, architect: 0.2 },
  'detection-engineering':  { red: 0.1, blue: 0.9, purple: 0.8, privacy: 0.0, recon: 0.0, incident: 0.2, architect: 0.1 },
  'digital-forensics':      { red: 0.1, blue: 0.9, purple: 0.5, privacy: 0.1, recon: 0.2, incident: 0.7, architect: 0.0 },
  'network-forensics':      { red: 0.1, blue: 0.9, purple: 0.5, privacy: 0.1, recon: 0.2, incident: 0.7, architect: 0.0 },
  'cloud-forensics':        { red: 0.1, blue: 0.9, purple: 0.5, privacy: 0.1, recon: 0.2, incident: 0.7, architect: 0.1 },
  'incident-response':      { red: 0.1, blue: 0.8, purple: 0.5, privacy: 0.1, recon: 0.1, incident: 0.9, architect: 0.1 },
  'incident-management':    { red: 0.0, blue: 0.7, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.9, architect: 0.2 },
  'soc-operations':         { red: 0.1, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.0, incident: 0.5, architect: 0.1 },
  'threat-hunting':         { red: 0.2, blue: 0.9, purple: 0.7, privacy: 0.0, recon: 0.3, incident: 0.3, architect: 0.0 },
  'endpoint-security':      { red: 0.2, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.0, incident: 0.3, architect: 0.2 },
  'logging-monitoring':     { red: 0.1, blue: 0.9, purple: 0.6, privacy: 0.1, recon: 0.0, incident: 0.4, architect: 0.3 },
  'log-analysis':           { red: 0.1, blue: 0.9, purple: 0.6, privacy: 0.1, recon: 0.0, incident: 0.5, architect: 0.1 },
  'hardening':              { red: 0.1, blue: 0.9, purple: 0.5, privacy: 0.1, recon: 0.0, incident: 0.1, architect: 0.4 },
  'insider-threat':         { red: 0.2, blue: 0.9, purple: 0.6, privacy: 0.2, recon: 0.2, incident: 0.4, architect: 0.1 },
  'blue-team':              { red: 0.1, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.0, incident: 0.3, architect: 0.1 },
  'ransomware-defense':     { red: 0.2, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.0, incident: 0.6, architect: 0.2 },
  'phishing-defense':       { red: 0.2, blue: 0.9, purple: 0.6, privacy: 0.0, recon: 0.1, incident: 0.3, architect: 0.1 },
  'email-security':         { red: 0.2, blue: 0.8, purple: 0.5, privacy: 0.1, recon: 0.1, incident: 0.3, architect: 0.2 },
  'vulnerability-management': { red: 0.3, blue: 0.8, purple: 0.6, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.3 },
  'security-operations-automation': { red: 0.1, blue: 0.8, purple: 0.5, privacy: 0.0, recon: 0.0, incident: 0.4, architect: 0.3 },
  'investigation-attribution': { red: 0.2, blue: 0.8, purple: 0.5, privacy: 0.0, recon: 0.5, incident: 0.6, architect: 0.0 },
  'security-awareness':     { red: 0.2, blue: 0.7, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.1, architect: 0.1 },
  'security-data-engineering': { red: 0.1, blue: 0.8, purple: 0.5, privacy: 0.1, recon: 0.0, incident: 0.3, architect: 0.3 },

  // ---- Dual (offensive + defensive) domains ----
  'active-directory':       { red: 0.7, blue: 0.5, purple: 0.7, privacy: 0.0, recon: 0.2, incident: 0.3, architect: 0.2 },
  'active-directory-security': { red: 0.7, blue: 0.5, purple: 0.7, privacy: 0.0, recon: 0.2, incident: 0.3, architect: 0.2 },
  'windows-security':       { red: 0.6, blue: 0.6, purple: 0.7, privacy: 0.0, recon: 0.1, incident: 0.3, architect: 0.2 },
  'cloud-security':         { red: 0.5, blue: 0.6, purple: 0.6, privacy: 0.1, recon: 0.2, incident: 0.3, architect: 0.5 },
  'cloud-native-security':  { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.1, recon: 0.1, incident: 0.2, architect: 0.6 },
  'container-security':     { red: 0.5, blue: 0.6, purple: 0.6, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.5 },
  'network-security':       { red: 0.5, blue: 0.6, purple: 0.6, privacy: 0.0, recon: 0.2, incident: 0.3, architect: 0.3 },
  'authentication':         { red: 0.6, blue: 0.5, purple: 0.6, privacy: 0.2, recon: 0.1, incident: 0.2, architect: 0.4 },
  'infrastructure-security': { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.6 },
  'threat-intelligence':    { red: 0.3, blue: 0.7, purple: 0.6, privacy: 0.0, recon: 0.6, incident: 0.4, architect: 0.1 },
  'threat-modeling':        { red: 0.4, blue: 0.5, purple: 0.6, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.8 },
  'purple-team':            { red: 0.6, blue: 0.6, purple: 0.9, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.1 },
  'malware-analysis':       { red: 0.5, blue: 0.7, purple: 0.6, privacy: 0.0, recon: 0.1, incident: 0.5, architect: 0.0 },
  'cryptography':           { red: 0.5, blue: 0.5, purple: 0.4, privacy: 0.3, recon: 0.0, incident: 0.1, architect: 0.3 },
  'ai-security':            { red: 0.5, blue: 0.5, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.1, architect: 0.3 },
  'ai-llm-security':        { red: 0.5, blue: 0.5, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.1, architect: 0.3 },
  'blockchain-security':    { red: 0.6, blue: 0.3, purple: 0.5, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.2 },
  'ics-security':           { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.4, architect: 0.3 },
  'ot-ics-security':        { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.4, architect: 0.3 },
  'iot-security':           { red: 0.5, blue: 0.5, purple: 0.5, privacy: 0.1, recon: 0.2, incident: 0.2, architect: 0.3 },
  'mobile-security':        { red: 0.6, blue: 0.4, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.1, architect: 0.2 },
  'wireless-security':      { red: 0.6, blue: 0.4, purple: 0.5, privacy: 0.1, recon: 0.3, incident: 0.1, architect: 0.2 },
  'browser-security':       { red: 0.6, blue: 0.4, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.1, architect: 0.2 },
  'database-security':      { red: 0.5, blue: 0.5, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.2, architect: 0.3 },
  'emerging-threats':        { red: 0.4, blue: 0.5, purple: 0.5, privacy: 0.0, recon: 0.2, incident: 0.3, architect: 0.1 },
  'identity-security':      { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.2, architect: 0.4 },
  'identity-access-management': { red: 0.4, blue: 0.6, purple: 0.5, privacy: 0.2, recon: 0.1, incident: 0.2, architect: 0.5 },
  'application-security':   { red: 0.5, blue: 0.5, purple: 0.5, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.5 },
  'data-security':          { red: 0.3, blue: 0.6, purple: 0.4, privacy: 0.5, recon: 0.0, incident: 0.3, architect: 0.3 },
  'security-training':      { red: 0.4, blue: 0.4, purple: 0.4, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.1 },
  'general-security':       { red: 0.3, blue: 0.3, purple: 0.3, privacy: 0.1, recon: 0.1, incident: 0.1, architect: 0.2 },

  // ---- OSINT / Recon domains ----
  'osint':                  { red: 0.5, blue: 0.3, purple: 0.4, privacy: 0.1, recon: 0.9, incident: 0.2, architect: 0.0 },
  'osint-recon':            { red: 0.5, blue: 0.3, purple: 0.4, privacy: 0.1, recon: 0.9, incident: 0.2, architect: 0.0 },
  'reconnaissance':         { red: 0.6, blue: 0.2, purple: 0.4, privacy: 0.0, recon: 0.9, incident: 0.1, architect: 0.0 },

  // ---- Privacy domains ----
  'privacy':                { red: 0.1, blue: 0.3, purple: 0.2, privacy: 0.9, recon: 0.0, incident: 0.1, architect: 0.3 },
  'privacy-engineering':    { red: 0.1, blue: 0.3, purple: 0.2, privacy: 0.9, recon: 0.0, incident: 0.1, architect: 0.4 },
  'data-protection':        { red: 0.1, blue: 0.3, purple: 0.2, privacy: 0.9, recon: 0.0, incident: 0.2, architect: 0.3 },
  'compliance':             { red: 0.1, blue: 0.4, purple: 0.2, privacy: 0.7, recon: 0.0, incident: 0.1, architect: 0.3 },
  'compliance-audit':       { red: 0.1, blue: 0.4, purple: 0.2, privacy: 0.6, recon: 0.0, incident: 0.1, architect: 0.3 },
  'governance-risk-compliance': { red: 0.1, blue: 0.4, purple: 0.2, privacy: 0.6, recon: 0.0, incident: 0.1, architect: 0.4 },

  // ---- Architect domains ----
  'security-architecture':  { red: 0.2, blue: 0.4, purple: 0.4, privacy: 0.2, recon: 0.0, incident: 0.1, architect: 0.9 },
  'zero-trust':             { red: 0.2, blue: 0.5, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.1, architect: 0.9 },
  'devsecops':              { red: 0.2, blue: 0.5, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.1, architect: 0.8 },
  'secure-development':     { red: 0.3, blue: 0.4, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.0, architect: 0.8 },
  'secure-coding':          { red: 0.3, blue: 0.4, purple: 0.4, privacy: 0.1, recon: 0.0, incident: 0.0, architect: 0.7 },
  'supply-chain-security':  { red: 0.3, blue: 0.6, purple: 0.5, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.7 },
  'security-automation':    { red: 0.2, blue: 0.6, purple: 0.4, privacy: 0.0, recon: 0.0, incident: 0.2, architect: 0.6 },
  'automation-scripting':   { red: 0.3, blue: 0.5, purple: 0.4, privacy: 0.0, recon: 0.1, incident: 0.2, architect: 0.4 },
  'security-leadership':    { red: 0.1, blue: 0.3, purple: 0.2, privacy: 0.2, recon: 0.0, incident: 0.2, architect: 0.5 },
  'security-management':    { red: 0.1, blue: 0.5, purple: 0.3, privacy: 0.2, recon: 0.0, incident: 0.2, architect: 0.5 },
  'nuclei-templating':      { red: 0.6, blue: 0.4, purple: 0.5, privacy: 0.0, recon: 0.3, incident: 0.0, architect: 0.1 },
};

// Valid NERF mode names — used for input validation
const VALID_MODES = new Set(['red', 'blue', 'purple', 'privacy', 'recon', 'incident', 'architect']);

// Domain name validation: alphanumeric + hyphens only
const SAFE_DOMAIN_RE = /^[a-z0-9-]+$/;

/**
 * Build a SQL CASE expression that multiplies BM25 rank by (1.0 + affinity)
 * based on the chunk's domain column and the requested NERF mode.
 *
 * BM25 ranks in SQLite FTS5 are negative (more negative = better match).
 * Multiplying by >1.0 makes matching chunks more negative = ranked higher.
 *
 * @param {string} mode — NERF mode name (red, blue, purple, etc.)
 * @param {string} bm25Fn — the bm25() SQL expression string
 * @returns {string} SQL expression string
 */
function buildAffinityRankExpr(mode, bm25Fn) {
  const normalizedMode = (mode || '').toLowerCase().trim();
  if (!VALID_MODES.has(normalizedMode)) return bm25Fn;

  const whenClauses = [];
  for (const [domain, affinities] of Object.entries(MODE_AFFINITY_MAP)) {
    const affinity = affinities[normalizedMode] || 0;
    if (affinity <= 0) continue;
    // Sanitize domain name — these are from our own constant map, but defense-in-depth
    if (!SAFE_DOMAIN_RE.test(domain)) continue;
    const multiplier = (1.0 + affinity).toFixed(1);
    whenClauses.push(`WHEN '${domain}' THEN ${bm25Fn} * ${multiplier}`);
  }

  if (whenClauses.length === 0) return bm25Fn;

  return `CASE c.domain ${whenClauses.join(' ')} ELSE ${bm25Fn} END`;
}

// ---------------------------------------------------------------------------
// KnowledgeStore
// ---------------------------------------------------------------------------

class KnowledgeStore {
  /**
   * @param {string} [dbPath] — path to SQLite DB file.
   *   Defaults to ~/.nerf/knowledge/index.db
   */
  constructor(dbPath) {
    this.dbPath = dbPath || DEFAULT_DB_PATH;
    mkdirSync(dirname(this.dbPath), { recursive: true });
    this.db = new Database(this.dbPath);
    this.db.pragma('journal_mode = WAL');
    this._initSchema();
    this._prepareStatements();
  }

  // -----------------------------------------------------------------------
  // Schema
  // -----------------------------------------------------------------------

  _initSchema() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS knowledge_chunks (
        chunk_id        TEXT PRIMARY KEY,
        source_file     TEXT NOT NULL,
        section_heading TEXT NOT NULL,
        content         TEXT NOT NULL,
        domain          TEXT DEFAULT '',
        subdomain       TEXT DEFAULT '',
        mode_tags       TEXT DEFAULT '[]',
        vuln_class      TEXT DEFAULT '',
        mitre_attack    TEXT DEFAULT '[]',
        file_type       TEXT NOT NULL,
        created_at      TEXT NOT NULL
      );

      CREATE INDEX IF NOT EXISTS idx_kc_domain    ON knowledge_chunks(domain);
      CREATE INDEX IF NOT EXISTS idx_kc_file_type ON knowledge_chunks(file_type);
      CREATE INDEX IF NOT EXISTS idx_kc_vuln_class ON knowledge_chunks(vuln_class);

      CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
        chunk_id UNINDEXED,
        content,
        section_heading,
        domain,
        vuln_class,
        tokenize='porter unicode61'
      );
    `);
  }

  // -----------------------------------------------------------------------
  // Prepared statements (cached for performance)
  // -----------------------------------------------------------------------

  _prepareStatements() {
    this._stmtInsertChunk = this.db.prepare(`
      INSERT OR REPLACE INTO knowledge_chunks
        (chunk_id, source_file, section_heading, content, domain, subdomain,
         mode_tags, vuln_class, mitre_attack, file_type, created_at)
      VALUES
        (@chunk_id, @source_file, @section_heading, @content, @domain, @subdomain,
         @mode_tags, @vuln_class, @mitre_attack, @file_type, @created_at)
    `);

    this._stmtDeleteFts = this.db.prepare(
      `DELETE FROM knowledge_fts WHERE chunk_id = ?`
    );

    this._stmtInsertFts = this.db.prepare(`
      INSERT INTO knowledge_fts (chunk_id, content, section_heading, domain, vuln_class)
      VALUES (?, ?, ?, ?, ?)
    `);

    this._upsertTxn = this.db.transaction((chunk) => {
      this._stmtInsertChunk.run(chunk);
      this._stmtDeleteFts.run(chunk.chunk_id);
      this._stmtInsertFts.run(
        chunk.chunk_id,
        chunk.content,
        chunk.section_heading,
        chunk.domain,
        chunk.vuln_class
      );
    });

    // Bulk insert — skips FTS delete for fresh DB builds
    this._stmtInsertOnly = this.db.prepare(`
      INSERT INTO knowledge_chunks
        (chunk_id, source_file, section_heading, content, domain, subdomain,
         mode_tags, vuln_class, mitre_attack, file_type, created_at)
      VALUES
        (@chunk_id, @source_file, @section_heading, @content, @domain, @subdomain,
         @mode_tags, @vuln_class, @mitre_attack, @file_type, @created_at)
    `);
  }

  // -----------------------------------------------------------------------
  // upsertChunk — idempotent insert/replace
  // -----------------------------------------------------------------------

  /**
   * Insert or replace a knowledge chunk in both the relational and FTS tables.
   *
   * @param {object} chunk — must include:
   *   chunk_id, source_file, section_heading, content, domain, subdomain,
   *   mode_tags (JSON string), vuln_class, mitre_attack (JSON string),
   *   file_type, created_at
   */
  upsertChunk(chunk) {
    // Normalise: ensure JSON-string fields are strings
    const row = {
      chunk_id: chunk.chunk_id,
      source_file: chunk.source_file,
      section_heading: chunk.section_heading,
      content: chunk.content,
      domain: chunk.domain ?? '',
      subdomain: chunk.subdomain ?? '',
      mode_tags: typeof chunk.mode_tags === 'string'
        ? chunk.mode_tags
        : JSON.stringify(chunk.mode_tags ?? []),
      vuln_class: chunk.vuln_class ?? '',
      mitre_attack: typeof chunk.mitre_attack === 'string'
        ? chunk.mitre_attack
        : JSON.stringify(chunk.mitre_attack ?? []),
      file_type: chunk.file_type,
      created_at: chunk.created_at ?? new Date().toISOString(),
    };

    this._upsertTxn(row);
  }

  /**
   * Fast insert for fresh builds — skips FTS delete, uses INSERT instead
   * of INSERT OR REPLACE. Caller must ensure the DB is empty.
   * Normalises fields the same as upsertChunk.
   */
  insertChunk(chunk) {
    const row = {
      chunk_id: chunk.chunk_id,
      source_file: chunk.source_file,
      section_heading: chunk.section_heading,
      content: chunk.content,
      domain: chunk.domain ?? '',
      subdomain: chunk.subdomain ?? '',
      mode_tags: typeof chunk.mode_tags === 'string'
        ? chunk.mode_tags
        : JSON.stringify(chunk.mode_tags ?? []),
      vuln_class: chunk.vuln_class ?? '',
      mitre_attack: typeof chunk.mitre_attack === 'string'
        ? chunk.mitre_attack
        : JSON.stringify(chunk.mitre_attack ?? []),
      file_type: chunk.file_type,
      created_at: chunk.created_at ?? new Date().toISOString(),
    };
    this._stmtInsertOnly.run(row);
    this._stmtInsertFts.run(row.chunk_id, row.content, row.section_heading, row.domain, row.vuln_class);
  }

  // -----------------------------------------------------------------------
  // search — BM25 ranked with optional metadata + mode boost
  // -----------------------------------------------------------------------

  /**
   * BM25 keyword search via FTS5 with optional metadata filtering and
   * mode-weighted score boosting.
   *
   * @param {string} query — natural language search query
   * @param {object} [opts]
   * @param {string} [opts.mode]       — boost chunks whose mode_tags contain this mode
   * @param {string} [opts.domain]     — filter to this domain
   * @param {string} [opts.vuln_class] — filter to this vuln class
   * @param {number} [opts.limit]      — max results (default 10)
   * @returns {object[]} ranked results with all metadata fields
   */
  search(query, opts = {}) {
    const { mode, domain, vuln_class, limit = DEFAULT_LIMIT } = opts;

    // Sanitize: quote each token to prevent FTS5 syntax errors
    const tokens = (query || '')
      .split(/\s+/)
      .map((t) => t.trim())
      .filter(Boolean)
      .map((t) => t.replace(/"/g, ''));

    // Filter out empty tokens after stripping quotes
    const cleanTokens = tokens.filter(Boolean);
    if (cleanTokens.length === 0) return [];

    const ftsQuery = cleanTokens.map((t) => `"${t}"`).join(' OR ');

    try {
      // Build dynamic WHERE clauses for relational filters
      const conditions = [];
      const params = [ftsQuery];

      if (domain) {
        conditions.push('c.domain = ?');
        params.push(domain);
      }
      if (vuln_class) {
        conditions.push('c.vuln_class = ?');
        params.push(vuln_class);
      }

      const whereClause = conditions.length > 0
        ? 'AND ' + conditions.join(' AND ')
        : '';

      // Mode-aware ranking via domain affinity scoring.
      // BM25 rank is negative (more negative = better match).
      // When a mode is requested, multiply rank by (1.0 + affinity) per
      // domain — offensive domains get high red affinity, defensive domains
      // get high blue affinity, etc. This produces meaningfully different
      // rankings for different modes on the same query.
      // Column weights for bm25(): content=1.0, section_heading=5.0, domain=3.0, vuln_class=2.0
      const bm25Fn = 'bm25(knowledge_fts, 1.0, 5.0, 3.0, 2.0)';
      const rankExpr = mode
        ? buildAffinityRankExpr(mode, bm25Fn)
        : bm25Fn;

      const sql = `
        SELECT c.*, ${rankExpr} AS adjusted_rank
        FROM knowledge_fts f
        JOIN knowledge_chunks c ON f.chunk_id = c.chunk_id
        WHERE knowledge_fts MATCH ?
        ${whereClause}
        ORDER BY adjusted_rank
        LIMIT ?
      `;

      params.push(limit);
      const stmt = this.db.prepare(sql);
      return stmt.all(...params);
    } catch {
      // FTS5 query parse failure — return empty results, never throw
      return [];
    }
  }

  // -----------------------------------------------------------------------
  // stats — aggregate counts
  // -----------------------------------------------------------------------

  /**
   * Return aggregate statistics about the knowledge index.
   * @returns {{ total: number, byFileType: object, byDomain: object }}
   */
  stats() {
    const total = this.db
      .prepare('SELECT COUNT(*) AS c FROM knowledge_chunks')
      .get().c;

    const byFileType = {};
    for (const row of this.db
      .prepare(
        'SELECT file_type, COUNT(*) AS c FROM knowledge_chunks GROUP BY file_type'
      )
      .all()) {
      byFileType[row.file_type] = row.c;
    }

    const byDomain = {};
    for (const row of this.db
      .prepare(
        "SELECT domain, COUNT(*) AS c FROM knowledge_chunks WHERE domain != '' GROUP BY domain"
      )
      .all()) {
      byDomain[row.domain] = row.c;
    }

    return { total, byFileType, byDomain };
  }

  // -----------------------------------------------------------------------
  // Housekeeping
  // -----------------------------------------------------------------------

  /**
   * Drop all data and rebuild the schema. Used by --rebuild.
   */
  clear() {
    this.db.exec(`
      DELETE FROM knowledge_fts;
      DELETE FROM knowledge_chunks;
    `);
  }

  /**
   * Close the database connection.
   */
  close() {
    this.db.close();
  }
}

export { KnowledgeStore, DEFAULT_DB_PATH, MODE_AFFINITY_MAP, buildAffinityRankExpr };
