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

/**
 * NERF Cross-Session Memory — Dual-Layer Indexing Engine
 *
 * Provides persistent memory across security engagements with two orthogonal
 * index layers for high-recall, high-precision retrieval:
 *
 * 1. Lexical Layer   — BM25 keyword index (SQLite FTS5, porter stemming)
 * 2. Symbolic Layer  — Structured metadata constraints (SQLite relational)
 *
 * Retrieval fuses both layers with reciprocal rank fusion (RRF).
 *
 * The semantic layer (LanceDB/embeddings) from the Python implementation is
 * intentionally excluded per D050 — FTS5 replaces vector search for the
 * Node.js stack.
 *
 * Architecture inspired by SimpleMem (tri-layer indexing) and ProjectDiscovery Neo
 * (persistent memory compounding across engagements).
 */

import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { sanitizeContent } from './sanitizer.js';

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

const DEFAULT_TOP_K = 10;
const RRF_K = 60;

// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------

/** @enum {string} Categories of security memory entries. */
const MemoryType = Object.freeze({
  FINDING: 'finding',
  IOC: 'ioc',
  TTP: 'ttp',
  CREDENTIAL: 'credential',
  HOST: 'host',
  NETWORK: 'network',
  CONFIG: 'config',
  NOTE: 'note',
  DECISION: 'decision',
  ARTIFACT: 'artifact',
});

/** @enum {string} Confidence levels matching NERF output standards. */
const Confidence = Object.freeze({
  CONFIRMED: 'confirmed',
  INFERRED: 'inferred',
  EXTERNAL: 'external',
  UNCERTAIN: 'uncertain',
});

// ---------------------------------------------------------------------------
// MemoryEntry
// ---------------------------------------------------------------------------

class MemoryEntry {
  /**
   * Atomic memory unit indexed across two orthogonal layers.
   *
   * Lexical:   keywords + content → BM25 sparse index
   * Symbolic:  All typed metadata fields → SQL constraints
   */
  constructor(opts = {}) {
    this.entryId = opts.entryId ?? randomUUID();
    this.content = opts.content ?? '';
    this.memoryType = opts.memoryType ?? MemoryType.NOTE;
    this.confidence = opts.confidence ?? Confidence.CONFIRMED;

    // Symbolic metadata
    this.engagementId = opts.engagementId ?? '';
    this.sourceSkill = opts.sourceSkill ?? '';
    this.timestamp = opts.timestamp ?? '';
    this.targets = opts.targets ?? [];
    this.mitreAttack = opts.mitreAttack ?? [];
    this.cveIds = opts.cveIds ?? [];
    this.severity = opts.severity ?? '';
    this.tags = opts.tags ?? [];
    this.relatedEntries = opts.relatedEntries ?? [];

    // Lexical metadata
    this.keywords = opts.keywords ?? [];

    // Lifecycle
    this.createdAt = opts.createdAt ?? new Date().toISOString();
    this.updatedAt = opts.updatedAt ?? '';
    this.accessedAt = opts.accessedAt ?? '';
    this.accessCount = opts.accessCount ?? 0;
    this.decayScore = opts.decayScore ?? 1.0;
    this.isArchived = opts.isArchived ?? false;
  }

  /** Serialize to a flat dict for SQLite storage. Array fields are JSON-encoded. */
  toDict() {
    return {
      entry_id: this.entryId,
      content: this.content,
      memory_type: this.memoryType,
      confidence: this.confidence,
      engagement_id: this.engagementId,
      source_skill: this.sourceSkill,
      timestamp: this.timestamp,
      targets: JSON.stringify(this.targets),
      mitre_attack: JSON.stringify(this.mitreAttack),
      cve_ids: JSON.stringify(this.cveIds),
      severity: this.severity,
      tags: JSON.stringify(this.tags),
      related_entries: JSON.stringify(this.relatedEntries),
      keywords: JSON.stringify(this.keywords),
      created_at: this.createdAt,
      updated_at: this.updatedAt,
      accessed_at: this.accessedAt,
      access_count: this.accessCount,
      decay_score: this.decayScore,
      is_archived: this.isArchived ? 1 : 0,
    };
  }

  /** Deserialize from a SQLite row dict. */
  static fromDict(d) {
    return new MemoryEntry({
      entryId: d.entry_id,
      content: d.content,
      memoryType: d.memory_type,
      confidence: d.confidence,
      engagementId: d.engagement_id ?? '',
      sourceSkill: d.source_skill ?? '',
      timestamp: d.timestamp ?? '',
      targets: _parseJsonArray(d.targets),
      mitreAttack: _parseJsonArray(d.mitre_attack),
      cveIds: _parseJsonArray(d.cve_ids),
      severity: d.severity ?? '',
      tags: _parseJsonArray(d.tags),
      relatedEntries: _parseJsonArray(d.related_entries),
      keywords: _parseJsonArray(d.keywords),
      createdAt: d.created_at ?? '',
      updatedAt: d.updated_at ?? '',
      accessedAt: d.accessed_at ?? '',
      accessCount: d.access_count ?? 0,
      decayScore: d.decay_score ?? 1.0,
      isArchived: Boolean(d.is_archived),
    });
  }
}

/** Safely parse a JSON array field, returning [] on failure. */
function _parseJsonArray(val) {
  if (Array.isArray(val)) return val;
  if (!val) return [];
  try {
    const parsed = JSON.parse(val);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

// ---------------------------------------------------------------------------
// Symbolic Layer — SQLite relational store with FTS5
// ---------------------------------------------------------------------------

class SymbolicStore {
  /**
   * SQLite-backed structured metadata store with FTS5 for lexical search.
   * @param {string} dbPath — path to the SQLite database file
   */
  constructor(dbPath) {
    mkdirSync(dirname(dbPath), { recursive: true });
    this.db = new Database(dbPath);
    this.db.pragma('journal_mode = WAL');
    this._initSchema();
  }

  _initSchema() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS entries (
        entry_id TEXT PRIMARY KEY,
        content TEXT NOT NULL,
        memory_type TEXT NOT NULL,
        confidence TEXT NOT NULL DEFAULT 'confirmed',
        engagement_id TEXT DEFAULT '',
        source_skill TEXT DEFAULT '',
        timestamp TEXT DEFAULT '',
        targets TEXT DEFAULT '[]',
        mitre_attack TEXT DEFAULT '[]',
        cve_ids TEXT DEFAULT '[]',
        severity TEXT DEFAULT '',
        tags TEXT DEFAULT '[]',
        related_entries TEXT DEFAULT '[]',
        keywords TEXT DEFAULT '[]',
        created_at TEXT NOT NULL,
        updated_at TEXT DEFAULT '',
        accessed_at TEXT DEFAULT '',
        access_count INTEGER DEFAULT 0,
        decay_score REAL DEFAULT 1.0,
        is_archived INTEGER DEFAULT 0
      );

      CREATE INDEX IF NOT EXISTS idx_engagement ON entries(engagement_id);
      CREATE INDEX IF NOT EXISTS idx_memory_type ON entries(memory_type);
      CREATE INDEX IF NOT EXISTS idx_severity ON entries(severity);
      CREATE INDEX IF NOT EXISTS idx_source_skill ON entries(source_skill);
      CREATE INDEX IF NOT EXISTS idx_decay ON entries(decay_score);

      CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
        entry_id UNINDEXED,
        content,
        keywords,
        tokenize='porter unicode61'
      );

      CREATE TABLE IF NOT EXISTS engagements (
        engagement_id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        client TEXT DEFAULT '',
        engagement_type TEXT DEFAULT '',
        status TEXT DEFAULT 'active',
        created_at TEXT NOT NULL,
        updated_at TEXT DEFAULT '',
        metadata TEXT DEFAULT '{}'
      );
    `);
  }

  /**
   * Store a memory entry in both the relational table and FTS index.
   * @param {MemoryEntry} entry
   * @returns {string} entry_id
   */
  store(entry) {
    const d = entry.toDict();
    const cols = Object.keys(d);
    const placeholders = cols.map(() => '?').join(', ');
    const colStr = cols.join(', ');

    const insertEntry = this.db.prepare(
      `INSERT OR REPLACE INTO entries (${colStr}) VALUES (${placeholders})`
    );
    const deleteFts = this.db.prepare(
      `DELETE FROM entries_fts WHERE entry_id = ?`
    );
    const insertFts = this.db.prepare(
      `INSERT INTO entries_fts (entry_id, content, keywords) VALUES (?, ?, ?)`
    );

    const txn = this.db.transaction(() => {
      insertEntry.run(...Object.values(d));
      deleteFts.run(entry.entryId);
      insertFts.run(entry.entryId, entry.content, entry.keywords.join(' '));
    });
    txn();

    return entry.entryId;
  }

  /**
   * Query by structured metadata constraints.
   * @param {object} filters
   * @returns {object[]}
   */
  searchSymbolic(filters = {}) {
    const {
      engagementId = '',
      memoryType = '',
      severity = '',
      mitreTechnique = '',
      cveId = '',
      target = '',
      tag = '',
      limit = DEFAULT_TOP_K,
    } = filters;

    const conditions = ['is_archived = 0'];
    const params = [];

    if (engagementId) {
      conditions.push('engagement_id = ?');
      params.push(engagementId);
    }
    if (memoryType) {
      conditions.push('memory_type = ?');
      params.push(memoryType);
    }
    if (severity) {
      conditions.push('severity = ?');
      params.push(severity);
    }
    // Escape LIKE wildcards in user-supplied values to prevent wildcard injection.
    // % and _ are SQLite LIKE special chars; \ is the escape character.
    // CWE-74: Injection prevention.
    const escapeLike = (v) => v.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');

    if (mitreTechnique) {
      conditions.push('mitre_attack LIKE ? ESCAPE \'\\\'');
      params.push(`%${escapeLike(mitreTechnique)}%`);
    }
    if (cveId) {
      conditions.push('cve_ids LIKE ? ESCAPE \'\\\'');
      params.push(`%${escapeLike(cveId)}%`);
    }
    if (target) {
      conditions.push('targets LIKE ? ESCAPE \'\\\'');
      params.push(`%${escapeLike(target)}%`);
    }
    if (tag) {
      conditions.push('tags LIKE ? ESCAPE \'\\\'');
      params.push(`%${escapeLike(tag)}%`);
    }

    const where = conditions.join(' AND ');
    const stmt = this.db.prepare(
      `SELECT * FROM entries WHERE ${where} ORDER BY decay_score DESC LIMIT ?`
    );
    return stmt.all(...params, limit);
  }

  /**
   * BM25 keyword search via FTS5.
   * @param {string} query
   * @param {number} limit
   * @returns {object[]}
   */
  searchLexical(query, limit = DEFAULT_TOP_K) {
    // Sanitize query for FTS5: quote each token to avoid syntax errors
    const tokens = query
      .split(/\s+/)
      .map((t) => t.trim())
      .filter(Boolean);
    if (tokens.length === 0) return [];

    // Strip FTS5 control characters and operators before quoting each token.
    // Without this, tokens like "NEAR(x,5)" or "* credential" manipulate query
    // behavior (CWE-74). Double-quote wrapping makes each token a phrase search.
    const ftsQuery = tokens
      .map((t) => t.replace(/[*^"(){}[\]:~]/g, ' ').trim())
      .filter(Boolean)
      .map((t) => `"${t}"`)
      .join(' ');

    try {
      const stmt = this.db.prepare(`
        SELECT e.*, rank FROM entries_fts f
        JOIN entries e ON f.entry_id = e.entry_id
        WHERE entries_fts MATCH ? AND e.is_archived = 0
        ORDER BY rank LIMIT ?
      `);
      return stmt.all(ftsQuery, limit);
    } catch {
      // FTS5 query parse failure — return empty results (matching Python behavior)
      return [];
    }
  }

  /**
   * Get a single entry by ID. Updates access metadata on read.
   * @param {string} entryId
   * @returns {object|null}
   */
  get(entryId) {
    const stmt = this.db.prepare('SELECT * FROM entries WHERE entry_id = ?');
    const row = stmt.get(entryId);
    if (!row) return null;

    const now = new Date().toISOString();
    this.db.prepare(
      'UPDATE entries SET accessed_at = ?, access_count = access_count + 1 WHERE entry_id = ?'
    ).run(now, entryId);

    return row;
  }

  /**
   * Apply time-based decay to all active entries.
   * @param {number} factor — decay multiplier (default 0.95)
   */
  decayAll(factor = 0.95) {
    this.db.prepare(
      'UPDATE entries SET decay_score = decay_score * ? WHERE is_archived = 0'
    ).run(factor);
  }

  /**
   * Boost entry score when accessed or referenced.
   * @param {string} entryId
   * @param {number} amount — boost increment (default 0.1)
   */
  boost(entryId, amount = 0.1) {
    this.db.prepare(
      'UPDATE entries SET decay_score = MIN(decay_score + ?, 1.0) WHERE entry_id = ?'
    ).run(amount, entryId);
  }

  /**
   * Archive entries with decay below threshold.
   * @param {number} threshold
   */
  archiveStale(threshold = 0.1) {
    this.db.prepare(
      'UPDATE entries SET is_archived = 1 WHERE decay_score < ? AND is_archived = 0'
    ).run(threshold);
  }

  /** Return memory statistics: { total, active, archived, engagements }. */
  stats() {
    const row = this.db.prepare(`
      SELECT
        COUNT(*) as total,
        COALESCE(SUM(CASE WHEN is_archived = 0 THEN 1 ELSE 0 END), 0) as active,
        COALESCE(SUM(CASE WHEN is_archived = 1 THEN 1 ELSE 0 END), 0) as archived,
        COUNT(DISTINCT engagement_id) as engagements
      FROM entries
    `).get();
    return row ?? { total: 0, active: 0, archived: 0, engagements: 0 };
  }

  close() {
    this.db.close();
  }
}

// ---------------------------------------------------------------------------
// Fusion Engine — Reciprocal Rank Fusion
// ---------------------------------------------------------------------------

/**
 * Fuse multiple ranked lists using Reciprocal Rank Fusion.
 *
 * RRF(d) = Σ 1 / (k + rank_i(d)) for each result list i
 *
 * @param {string[][]} resultLists — list of ranked entry_id arrays (best first)
 * @param {number} k — RRF constant (default 60)
 * @returns {Array<[string, number]>} — fused ranked list of [entry_id, rrf_score]
 */
function reciprocalRankFusion(resultLists, k = RRF_K) {
  const scores = new Map();
  for (const rankedList of resultLists) {
    for (let rank = 0; rank < rankedList.length; rank++) {
      const entryId = rankedList[rank];
      scores.set(entryId, (scores.get(entryId) ?? 0) + 1.0 / (k + rank + 1));
    }
  }
  return [...scores.entries()].sort((a, b) => b[1] - a[1]);
}

// ---------------------------------------------------------------------------
// Memory Engine — Unified interface
// ---------------------------------------------------------------------------

class NerfMemory {
  /**
   * Unified memory engine combining lexical and symbolic layers.
   *
   * Usage:
   *   const memory = new NerfMemory('/path/to/memory');
   *   memory.store(new MemoryEntry({ content: 'Found SQLi on /api/login', ... }));
   *   const results = memory.search('SQL injection vulnerabilities');
   *   memory.close();
   *
   * @param {string} memoryDir — directory for SQLite database
   */
  constructor(memoryDir) {
    this.memoryDir = memoryDir;
    mkdirSync(memoryDir, { recursive: true });
    this.symbolic = new SymbolicStore(join(memoryDir, 'memory.db'));
  }

  /**
   * Store entry in both symbolic and lexical layers.
   * @param {MemoryEntry} entry
   * @returns {string} entry_id
   */
  store(entry) {
    // Sanitize content at storage time regardless of caller.
    // Previously the sanitizer was only invoked if callers explicitly called it,
    // leaving direct paths (orchestrator, API) unprotected. CWE-116 prevention.
    // Mutate only the content property — preserve the MemoryEntry class instance
    // so toDict() and other methods remain callable.
    if (entry && typeof entry.content === 'string') {
      const { sanitized } = sanitizeContent(entry.content);
      entry.content = sanitized;
    }
    const entryId = this.symbolic.store(entry);
    return entryId;
  }

  /**
   * Dual-layer fused search via RRF.
   *
   * 1. Lexical — "what words match?" (BM25 keyword matching)
   * 2. Symbolic — "what metadata matches?" (structured SQL constraints)
   *
   * @param {string} query
   * @param {object} filters — { engagementId, memoryType, severity, mitreTechnique }
   * @param {number} limit
   * @returns {MemoryEntry[]}
   */
  search(query, filters = {}, limit = DEFAULT_TOP_K) {
    const resultLists = [];

    // Layer 1: Lexical search (BM25) — always run when query is provided
    const lexicalResults = this.symbolic.searchLexical(query, limit * 2);
    if (lexicalResults.length > 0) {
      resultLists.push(lexicalResults.map((r) => r.entry_id));
    }

    // Layer 2: Symbolic search (structured) — only when filters are provided
    // Without filters, symbolic returns ALL entries which drowns out lexical ranking
    const hasFilters = !!(filters.engagementId || filters.memoryType || filters.severity || filters.mitreTechnique);
    if (hasFilters) {
      const symbolicResults = this.symbolic.searchSymbolic({
        engagementId: filters.engagementId ?? '',
        memoryType: filters.memoryType ?? '',
        severity: filters.severity ?? '',
        mitreTechnique: filters.mitreTechnique ?? '',
        limit: limit * 2,
      });
      if (symbolicResults.length > 0) {
        resultLists.push(symbolicResults.map((r) => r.entry_id));
      }
    }

    // Fuse with RRF
    if (resultLists.length === 0) return [];

    const fused = reciprocalRankFusion(resultLists);

    // Fetch full entries for top results
    const entries = [];
    for (const [entryId] of fused.slice(0, limit)) {
      const row = this.symbolic.get(entryId);
      if (row) {
        const entry = MemoryEntry.fromDict(row);
        this.symbolic.boost(entryId, 0.05);
        entries.push(entry);
      }
    }

    return entries;
  }

  /**
   * Get all active memories for an engagement, sorted by relevance.
   * @param {string} engagementId
   * @returns {MemoryEntry[]}
   */
  getEngagementContext(engagementId) {
    const results = this.symbolic.searchSymbolic({
      engagementId,
      limit: 100,
    });
    return results.map((r) => MemoryEntry.fromDict(r));
  }

  /**
   * Run memory maintenance: decay, archive stale, return stats.
   * @returns {object}
   */
  consolidate() {
    this.symbolic.decayAll(0.98);
    this.symbolic.archiveStale(0.05);
    const stats = this.symbolic.stats();
    return stats;
  }

  /** Return memory statistics: { total, active, archived, engagements }. */
  stats() {
    return this.symbolic.stats();
  }

  close() {
    this.symbolic.close();
  }
}

// ---------------------------------------------------------------------------
// Memory Consolidator
// ---------------------------------------------------------------------------

class MemoryConsolidator {
  /**
   * Memory consolidation worker — decay, merge, prune.
   *
   * 1. Decay — reduce importance of old entries over time
   * 2. Merge — combine near-duplicate entries with high textual similarity
   * 3. Prune — archive entries below importance threshold
   *
   * @param {NerfMemory} memory
   */
  constructor(memory) {
    this.memory = memory;
  }

  /**
   * Combined similarity: Jaccard trigrams + TF-IDF cosine (no external deps).
   * Returns the max of both methods for robust near-duplicate detection.
   * @param {string} a
   * @param {string} b
   * @returns {number} similarity score 0–1
   */
  textSimilarity(a, b) {
    const jaccard = MemoryConsolidator.jaccardTrigrams(a, b);
    const cosine = MemoryConsolidator.tfidfCosine(a, b);
    return Math.max(jaccard, cosine);
  }

  /**
   * Jaccard similarity on word trigrams.
   * @param {string} a
   * @param {string} b
   * @returns {number}
   */
  static jaccardTrigrams(a, b) {
    const trigrams = (text) => {
      const words = text.toLowerCase().split(/\s+/).filter(Boolean);
      if (words.length < 3) return new Set(words);
      const tgs = new Set();
      for (let i = 0; i <= words.length - 3; i++) {
        tgs.add(words.slice(i, i + 3).join(' '));
      }
      return tgs;
    };

    const tgA = trigrams(a);
    const tgB = trigrams(b);
    if (tgA.size === 0 || tgB.size === 0) return 0.0;

    let intersection = 0;
    for (const t of tgA) {
      if (tgB.has(t)) intersection++;
    }
    const union = new Set([...tgA, ...tgB]).size;
    return union === 0 ? 0.0 : intersection / union;
  }

  /**
   * TF-IDF cosine similarity using pure JS (no numpy/sklearn).
   * @param {string} a
   * @param {string} b
   * @returns {number}
   */
  static tfidfCosine(a, b) {
    const wordsA = a.toLowerCase().split(/\s+/).filter(Boolean);
    const wordsB = b.toLowerCase().split(/\s+/).filter(Boolean);
    if (wordsA.length === 0 || wordsB.length === 0) return 0.0;

    const tfA = new Map();
    for (const w of wordsA) tfA.set(w, (tfA.get(w) ?? 0) + 1);
    const tfB = new Map();
    for (const w of wordsB) tfB.set(w, (tfB.get(w) ?? 0) + 1);

    const vocab = new Set([...tfA.keys(), ...tfB.keys()]);

    // IDF: treat the two docs as the corpus
    const docFreq = new Map();
    for (const w of vocab) {
      docFreq.set(w, (tfA.has(w) ? 1 : 0) + (tfB.has(w) ? 1 : 0));
    }

    // TF-IDF vectors
    const tfidfVec = (tf) => {
      const total = [...tf.values()].reduce((s, v) => s + v, 0);
      const vec = new Map();
      for (const w of vocab) {
        vec.set(w, ((tf.get(w) ?? 0) / total) * Math.log(2.0 / docFreq.get(w) + 1));
      }
      return vec;
    };

    const vecA = tfidfVec(tfA);
    const vecB = tfidfVec(tfB);

    // Cosine similarity
    let dot = 0;
    let magA = 0;
    let magB = 0;
    for (const w of vocab) {
      const va = vecA.get(w) ?? 0;
      const vb = vecB.get(w) ?? 0;
      dot += va * vb;
      magA += va * va;
      magB += vb * vb;
    }
    magA = Math.sqrt(magA);
    magB = Math.sqrt(magB);
    if (magA === 0 || magB === 0) return 0.0;
    return dot / (magA * magB);
  }

  /**
   * Merge entries with textual similarity above threshold.
   * Keeps the higher-confidence entry and appends context from the other.
   * @param {number} threshold
   * @returns {object} { checked, merged, pairs }
   */
  mergeSimilar(threshold = 0.85) {
    let checked = 0;
    let mergeCount = 0;
    const pairs = [];
    const store = this.memory.symbolic;

    // Get all active entries grouped by type
    for (const memoryType of ['finding', 'ioc', 'ttp', 'note']) {
      const results = store.searchSymbolic({ memoryType, limit: 500 });
      const entries = results.map((r) => MemoryEntry.fromDict(r));

      for (let i = 0; i < entries.length; i++) {
        const entryA = entries[i];
        for (let j = i + 1; j < entries.length; j++) {
          const entryB = entries[j];
          checked++;
          const sim = this.textSimilarity(entryA.content, entryB.content);
          if (sim >= threshold) {
            // Keep the one with higher decay score
            const keeper =
              entryA.decayScore >= entryB.decayScore ? entryA : entryB;
            const discard = keeper === entryA ? entryB : entryA;

            // Combine unique tags
            keeper.tags = [...new Set([...keeper.tags, ...discard.tags])];

            // Boost decay score of keeper
            keeper.decayScore = Math.min(1.0, keeper.decayScore + 0.1);

            // Re-store keeper
            store.store(keeper);

            // Archive the merged entry
            discard.isArchived = true;
            discard.decayScore = 0.0;
            store.store(discard);

            mergeCount++;
            pairs.push({
              kept: keeper.entryId,
              merged: discard.entryId,
              similarity: Math.round(sim * 1000) / 1000,
            });
          }
        }
      }
    }

    return { checked, merged: mergeCount, pairs };
  }

  /**
   * Run full consolidation cycle: decay → merge → prune.
   * @returns {object}
   */
  fullConsolidation() {
    const results = {};

    // 1. Decay + archive
    const decayStats = this.memory.consolidate();
    results.decay = decayStats;

    // 2. Merge
    const mergeStats = this.mergeSimilar();
    results.merge = mergeStats;

    // 3. Summary
    results.summary = {
      decayed: decayStats.total ?? 0,
      archived: decayStats.archived ?? 0,
      merged: mergeStats.merged,
      pairsChecked: mergeStats.checked,
    };

    return results;
  }
}

export {
  MemoryType,
  Confidence,
  MemoryEntry,
  SymbolicStore,
  NerfMemory,
  MemoryConsolidator,
  reciprocalRankFusion,
  DEFAULT_TOP_K,
  RRF_K,
};
