/**
 * Lossless Context Management (LCM) for Nanoclaw
 *
 * Per-group SQLite DB stores every conversation turn (user + agent) and a
 * hierarchical summary DAG for context assembly on container launch.
 *
 * DB path: /home/<tenant>/nanoclaw/data/lcm/<folder>.db
 */

import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { randomBytes } from 'node:crypto';
import { embedText, cosineSim } from './embedder.js';

// Feature flag — checked at call time (not cached) so tests can stub the env var.
export function isLcmEnabled(): boolean {
  return process.env.LCM_ENABLED === 'true';
}

/** Check if LCM is enabled for a specific group folder (tenant-aware). */
export function isLcmEnabledForFolder(groupFolder: string): boolean {
  if (process.env.LCM_ENABLED === 'true') return true;
  const slashIdx = groupFolder.indexOf('/');
  if (slashIdx <= 0) return false;
  const tenantId = groupFolder.slice(0, slashIdx);
  const flagFile = path.join('/home', tenantId, 'nanoclaw', 'data', '.lcm-enabled');
  try { return fs.existsSync(flagFile); } catch { return false; }
}

// ---------------------------------------------------------------------------
// sqlite-vec extension support
// ---------------------------------------------------------------------------

/** Set of DB connections that have the sqlite-vec extension loaded. */
const vecEnabledDbs = new Set<Database.Database>();

function tryLoadVec(db: Database.Database): boolean {
  const vecPath = process.env.SQLITE_VEC_PATH;
  if (!vecPath) return false;
  try {
    db.loadExtension(vecPath);
    vecEnabledDbs.add(db);
    return true;
  } catch {
    return false;
  }
}

function isVecEnabled(db: Database.Database): boolean {
  return vecEnabledDbs.has(db);
}

/**
 * Create vec0 virtual table and metadata table if sqlite-vec is loaded.
 * Safe to call multiple times — uses CREATE IF NOT EXISTS.
 */
function ensureVecTables(db: Database.Database): void {
  if (!isVecEnabled(db)) return;
  try {
    // Fix old schema: if embedding_meta has 'id' column (pre-fix), drop and recreate
    // sqlite-vec 0.1.x rejects bound rowid params, so we let vec auto-assign rowid
    // and store the resulting vec rowid as the PK in embedding_meta.
    const tableInfo = db.prepare("SELECT name FROM pragma_table_info('embedding_meta')").all() as { name: string }[];
    if (tableInfo.length > 0 && tableInfo.some((c) => c.name === 'id')) {
      db.exec('DROP TABLE IF EXISTS embedding_meta');
      try { db.exec('DROP TABLE IF EXISTS vec_embeddings'); } catch { /* virtual table may already be gone */ }
    }
    db.exec(`
      CREATE TABLE IF NOT EXISTS embedding_meta (
        vec_rowid INTEGER PRIMARY KEY,
        item_type TEXT NOT NULL,
        item_id TEXT NOT NULL UNIQUE,
        created_at TEXT NOT NULL
      );
      CREATE VIRTUAL TABLE IF NOT EXISTS vec_embeddings USING vec0(embedding float[768]);
    `);
  } catch {
    // If vec table creation fails, fall back to BLOB-only mode
    vecEnabledDbs.delete(db);
  }
}

// ---------------------------------------------------------------------------
// Schema / DB management
// ---------------------------------------------------------------------------

const CONTENT_CAP = 50_000;
const TRUNCATION_MARKER = '\n[LCM: content truncated for storage]';
const FRESH_TAIL_TURNS = 32;

export interface LcmTurn {
  id: string;
  session_id: string;
  seq: number;
  role: 'user' | 'agent';
  content: string;
  input_tokens: number;
  timestamp: string;
}

export interface LcmSummaryNode {
  id: string;
  depth: number;
  content: string;
  source_turn_ids: string;
  parent_id: string | null;
  created_at: string;
}

function capContent(content: string): string {
  if (content.length <= CONTENT_CAP) return content;
  return content.slice(0, CONTENT_CAP) + TRUNCATION_MARKER;
}

const TENANT_HOME_BASE = process.env.TENANT_HOME_BASE || '/home';
const DATA_DIR_BASE = process.env.DATA_DIR || path.join(process.cwd(), 'data');

export function lcmDbPath(groupFolder: string): string {
  const parts = groupFolder.split('/');
  if (parts.length >= 2) {
    const tenant = parts[0];
    const folder = parts.slice(1).join('_') || 'default';
    return path.join(TENANT_HOME_BASE, tenant, 'nanoclaw', 'data', 'lcm', `${folder}.db`);
  }
  return path.join(DATA_DIR_BASE, 'lcm', `${groupFolder}.db`);
}

function createSchema(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS turns (
      id TEXT PRIMARY KEY,
      session_id TEXT NOT NULL,
      seq INTEGER NOT NULL,
      role TEXT NOT NULL CHECK(role IN ('user', 'agent')),
      content TEXT NOT NULL,
      input_tokens INTEGER DEFAULT 0,
      timestamp TEXT NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_turns_seq ON turns(seq);
    CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);

    CREATE TABLE IF NOT EXISTS summary_nodes (
      id TEXT PRIMARY KEY,
      depth INTEGER NOT NULL,
      content TEXT NOT NULL,
      source_turn_ids TEXT NOT NULL,
      parent_id TEXT,
      created_at TEXT NOT NULL,
      FOREIGN KEY (parent_id) REFERENCES summary_nodes(id)
    );
    CREATE INDEX IF NOT EXISTS idx_summary_depth ON summary_nodes(depth);

    CREATE TABLE IF NOT EXISTS context_items (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      assembled_at TEXT NOT NULL,
      item_type TEXT NOT NULL CHECK(item_type IN ('turn', 'summary')),
      item_id TEXT NOT NULL,
      position INTEGER NOT NULL
    );
  `);
}

function runMigrations(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS lcm_meta (
      key TEXT PRIMARY KEY,
      value TEXT NOT NULL
    );
  `);

  const row = db.prepare("SELECT value FROM lcm_meta WHERE key = 'schema_version'").get() as { value: string } | undefined;
  const version = row ? parseInt(row.value, 10) : 0;

  if (version < 1) {
    createSchema(db);
    db.prepare("INSERT OR REPLACE INTO lcm_meta (key, value) VALUES ('schema_version', '1')").run();
  }

  if (version < 2) {
    // BLOB-based embeddings table — fallback when sqlite-vec is unavailable
    db.exec(`
      CREATE TABLE IF NOT EXISTS embeddings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        item_type TEXT NOT NULL,
        item_id TEXT NOT NULL UNIQUE,
        embedding BLOB NOT NULL,
        created_at TEXT NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_embeddings_item ON embeddings(item_type, item_id);
    `);
    db.prepare("INSERT OR REPLACE INTO lcm_meta (key, value) VALUES ('schema_version', '2')").run();
  }
  // Note: sqlite-vec tables (embedding_meta + vec_embeddings) are created by ensureVecTables()
  // which runs after migrations, whenever the extension is available.
}

function openDb(groupFolder: string): Database.Database | null {
  if (!isLcmEnabledForFolder(groupFolder)) return null;
  try {
    const dbPath = lcmDbPath(groupFolder);
    fs.mkdirSync(path.dirname(dbPath), { recursive: true });
    const db = new Database(dbPath);
    db.pragma('journal_mode = WAL');
    db.pragma('busy_timeout = 5000');
    tryLoadVec(db);
    runMigrations(db);
    ensureVecTables(db);
    return db;
  } catch {
    return null;
  }
}

const dbCache = new Map<string, Database.Database>();

function getDb(groupFolder: string): Database.Database | null {
  if (dbCache.has(groupFolder)) return dbCache.get(groupFolder)!;
  if (!isLcmEnabledForFolder(groupFolder)) return null;
  const db = openDb(groupFolder);
  if (db) dbCache.set(groupFolder, db);
  return db;
}

function newId(): string {
  return randomBytes(8).toString('hex');
}

function nextSeq(db: Database.Database): number {
  const row = db.prepare('SELECT MAX(seq) as max_seq FROM turns').get() as { max_seq: number | null };
  return (row.max_seq ?? -1) + 1;
}

// ---------------------------------------------------------------------------
// Turn storage
// ---------------------------------------------------------------------------

export function storeTurn(params: {
  groupFolder: string;
  sessionId: string;
  role: 'user' | 'agent';
  content: string;
  inputTokens?: number;
}): void {
  if (!isLcmEnabledForFolder(params.groupFolder)) return;
  try {
    const db = getDb(params.groupFolder);
    if (!db) return;
    const id = newId();
    const seq = nextSeq(db);
    db.prepare(
      'INSERT INTO turns (id, session_id, seq, role, content, input_tokens, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)',
    ).run(id, params.sessionId, seq, params.role, capContent(params.content), params.inputTokens ?? 0, new Date().toISOString());
    storeEmbeddingAsync(db, 'turn', id, params.content).catch(() => {});
  } catch {
    // LCM writes are best-effort
  }
}

export function storeTurnPair(params: {
  groupFolder: string;
  sessionId: string;
  userContent: string;
  agentContent: string;
  inputTokens?: number;
}): void {
  if (!isLcmEnabledForFolder(params.groupFolder)) return;
  try {
    const db = getDb(params.groupFolder);
    if (!db) return;

    const insertTurn = db.prepare(
      'INSERT INTO turns (id, session_id, seq, role, content, input_tokens, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)',
    );

    const now = new Date().toISOString();
    const userSeq = nextSeq(db);
    const userId = newId();
    const agentId = newId();

    db.transaction(() => {
      insertTurn.run(userId, params.sessionId, userSeq, 'user', capContent(params.userContent), 0, now);
      insertTurn.run(agentId, params.sessionId, userSeq + 1, 'agent', capContent(params.agentContent), params.inputTokens ?? 0, now);
    })();

    storeEmbeddingAsync(db, 'turn', userId, params.userContent).catch(() => {});
    storeEmbeddingAsync(db, 'turn', agentId, params.agentContent).catch(() => {});
  } catch {
    // LCM writes are best-effort
  }
}

// ---------------------------------------------------------------------------
// Context assembly
// ---------------------------------------------------------------------------

const CHARS_PER_TOKEN = 4;
const INJECTION_HARD_CAP_CHARS = 100_000 * CHARS_PER_TOKEN;

export function assembleLcmContext(groupFolder: string): string {
  if (!isLcmEnabledForFolder(groupFolder)) return '';
  try {
    const db = getDb(groupFolder);
    if (!db) return '';

    const totalTurns = (db.prepare('SELECT COUNT(*) as n FROM turns').get() as { n: number }).n;
    if (totalTurns === 0) return '';

    const freshTurns = db.prepare(
      'SELECT role, content FROM turns ORDER BY seq DESC LIMIT ?',
    ).all(FRESH_TAIL_TURNS) as Array<{ role: string; content: string }>;
    freshTurns.reverse();

    const summaries = db.prepare(
      'SELECT content FROM summary_nodes ORDER BY depth ASC, created_at ASC',
    ).all() as Array<{ content: string }>;

    const freshParts: string[] = [];
    if (freshTurns.length > 0) {
      freshParts.push('## Recent Conversation\n');
      for (const t of freshTurns) {
        const label = t.role === 'user' ? 'User' : 'Assistant';
        freshParts.push(`**${label}:** ${t.content}`);
        freshParts.push('');
      }
    }
    const freshSection = freshParts.join('\n');

    const remainingBudget = INJECTION_HARD_CAP_CHARS - freshSection.length;
    const includedSummaries: string[] = [];
    let summaryChars = 0;
    for (let i = summaries.length - 1; i >= 0; i--) {
      const chunk = summaries[i].content + '\n\n';
      if (summaryChars + chunk.length > remainingBudget) break;
      includedSummaries.unshift(summaries[i].content);
      summaryChars += chunk.length;
    }
    const droppedCount = summaries.length - includedSummaries.length;

    const parts: string[] = [];
    if (includedSummaries.length > 0) {
      parts.push('## Conversation History (Summarised)\n');
      if (droppedCount > 0) {
        parts.push(`*[${droppedCount} older summary block(s) omitted — use \`tc memory search\` to retrieve]*\n`);
      }
      for (const s of includedSummaries) {
        parts.push(s);
        parts.push('');
      }
    }
    if (freshSection) parts.push(freshSection);
    if (parts.length === 0) return '';

    const header = 'This is your long-term conversation memory (LCM) — turns stored from previous sessions. Use tc memory search to find content older than what is shown here.';
    return `<lcm_context>\n<!-- ${header} -->\n${parts.join('\n').trim()}\n</lcm_context>`;
  } catch {
    return '';
  }
}

// ---------------------------------------------------------------------------
// Summary node storage
// ---------------------------------------------------------------------------

export function storeSummaryNode(params: {
  groupFolder: string;
  depth: number;
  content: string;
  sourceTurnIds: string[];
  parentId?: string;
}): string | null {
  if (!isLcmEnabledForFolder(params.groupFolder)) return null;
  try {
    const db = getDb(params.groupFolder);
    if (!db) return null;
    const id = newId();
    db.prepare(
      'INSERT INTO summary_nodes (id, depth, content, source_turn_ids, parent_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
    ).run(id, params.depth, capContent(params.content), JSON.stringify(params.sourceTurnIds), params.parentId ?? null, new Date().toISOString());
    storeEmbeddingAsync(db, 'summary', id, params.content).catch(() => {});
    return id;
  } catch {
    return null;
  }
}

export function getAllTurns(groupFolder: string): LcmTurn[] {
  if (!isLcmEnabledForFolder(groupFolder)) return [];
  try {
    const db = getDb(groupFolder);
    if (!db) return [];
    return db.prepare('SELECT * FROM turns ORDER BY seq ASC').all() as LcmTurn[];
  } catch {
    return [];
  }
}

// ---------------------------------------------------------------------------
// Embedding storage (vec0 path + BLOB fallback)
// ---------------------------------------------------------------------------

async function storeEmbeddingAsync(
  db: Database.Database,
  itemType: 'turn' | 'summary',
  itemId: string,
  content: string,
): Promise<void> {
  try {
    const vector = await embedText(content, false);
    const now = new Date().toISOString();

    if (isVecEnabled(db)) {
      // vec0 path: sqlite-vec 0.1.x rejects bound rowid params, so insert vec first
      // (auto-assign rowid), then store that rowid in embedding_meta.
      const alreadyIndexed = db.prepare('SELECT 1 FROM embedding_meta WHERE item_id = ?').get(itemId);
      if (!alreadyIndexed) {
        const vecJson = JSON.stringify(Array.from(vector));
        const vecResult = db.prepare('INSERT INTO vec_embeddings(embedding) VALUES (?)').run(vecJson);
        const vecRowid = Number(vecResult.lastInsertRowid);
        db.prepare(
          'INSERT OR IGNORE INTO embedding_meta (vec_rowid, item_type, item_id, created_at) VALUES (?, ?, ?, ?)',
        ).run(vecRowid, itemType, itemId, now);
      }
    } else {
      // BLOB fallback
      const buf = Buffer.from(vector.buffer);
      db.prepare(
        'INSERT OR IGNORE INTO embeddings (item_type, item_id, embedding, created_at) VALUES (?, ?, ?, ?)',
      ).run(itemType, itemId, buf, now);
    }
  } catch {
    // Best-effort: never propagate
  }
}

// ---------------------------------------------------------------------------
// Search: keyword / semantic / hybrid
// ---------------------------------------------------------------------------

export function searchLcm(groupFolder: string, pattern: string): Array<{ type: string; id: string; excerpt: string }> {
  if (!isLcmEnabledForFolder(groupFolder)) return [];
  try {
    const db = getDb(groupFolder);
    if (!db) return [];

    const results: Array<{ type: string; id: string; excerpt: string }> = [];
    const lowerPattern = pattern.toLowerCase();

    const turns = db.prepare('SELECT id, role, content FROM turns').all() as Array<{ id: string; role: string; content: string }>;
    for (const t of turns) {
      if (t.content.toLowerCase().includes(lowerPattern)) {
        const idx = t.content.toLowerCase().indexOf(lowerPattern);
        const start = Math.max(0, idx - 80);
        const end = Math.min(t.content.length, idx + pattern.length + 80);
        results.push({ type: `turn:${t.role}`, id: t.id, excerpt: `...${t.content.slice(start, end)}...` });
      }
    }

    const summaries = db.prepare('SELECT id, depth, content FROM summary_nodes').all() as Array<{ id: string; depth: number; content: string }>;
    for (const s of summaries) {
      if (s.content.toLowerCase().includes(lowerPattern)) {
        const idx = s.content.toLowerCase().indexOf(lowerPattern);
        const start = Math.max(0, idx - 80);
        const end = Math.min(s.content.length, idx + pattern.length + 80);
        results.push({ type: `summary:depth${s.depth}`, id: s.id, excerpt: `...${s.content.slice(start, end)}...` });
      }
    }

    return results;
  } catch {
    return [];
  }
}

export interface SemanticSearchResult {
  type: string;
  id: string;
  score: number;
  excerpt: string;
}

export async function semanticSearchLcm(
  groupFolder: string,
  query: string,
  limit = 5,
): Promise<SemanticSearchResult[]> {
  if (!isLcmEnabledForFolder(groupFolder)) return [];
  try {
    const db = getDb(groupFolder);
    if (!db) return [];

    const queryVec = await embedText(query, true);

    if (isVecEnabled(db)) {
      // sqlite-vec KNN path
      const count = (db.prepare('SELECT COUNT(*) as n FROM embedding_meta').get() as { n: number }).n;
      if (count === 0) return [];

      const queryJson = JSON.stringify(Array.from(queryVec));
      const vecRows = db.prepare(
        'SELECT rowid, distance FROM vec_embeddings WHERE embedding MATCH ? ORDER BY distance LIMIT ?',
      ).all(queryJson, limit) as Array<{ rowid: number; distance: number }>;

      const results: SemanticSearchResult[] = [];
      for (const row of vecRows) {
        const meta = db.prepare('SELECT item_type, item_id FROM embedding_meta WHERE vec_rowid = ?').get(row.rowid) as
          | { item_type: string; item_id: string } | undefined;
        if (!meta) continue;

        // Convert L2 distance to similarity: for unit vectors, cosine = 1 - L2²/2
        const score = Math.max(0, Math.round((1 - row.distance * row.distance / 2) * 1000) / 1000);
        const excerpt = getExcerptForItem(db, meta.item_type, meta.item_id);
        if (excerpt) {
          results.push({ type: meta.item_type === 'turn' ? excerpt.type : `summary:depth${excerpt.depth ?? 0}`, id: meta.item_id, score, excerpt: excerpt.text });
        }
      }
      return results;

    } else {
      // In-memory cosine fallback (BLOB path)
      const count = (db.prepare('SELECT COUNT(*) as n FROM embeddings').get() as { n: number }).n;
      if (count === 0) return [];

      const rows = db.prepare('SELECT item_type, item_id, embedding FROM embeddings').all() as Array<{
        item_type: string; item_id: string; embedding: Buffer;
      }>;

      const scored = rows.map((row) => {
        const vec = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
        return { item_type: row.item_type, item_id: row.item_id, score: cosineSim(queryVec, vec) };
      });
      scored.sort((a, b) => b.score - a.score);

      const results: SemanticSearchResult[] = [];
      for (const hit of scored.slice(0, limit)) {
        const excerpt = getExcerptForItem(db, hit.item_type, hit.item_id);
        if (excerpt) {
          results.push({
            type: hit.item_type === 'turn' ? excerpt.type : `summary:depth${excerpt.depth ?? 0}`,
            id: hit.item_id,
            score: Math.round(hit.score * 1000) / 1000,
            excerpt: excerpt.text,
          });
        }
      }
      return results;
    }
  } catch {
    return [];
  }
}

/** Fetch a short excerpt + type info for a stored item. */
function getExcerptForItem(
  db: Database.Database,
  itemType: string,
  itemId: string,
): { type: string; depth?: number; text: string } | null {
  if (itemType === 'turn') {
    const t = db.prepare('SELECT role, content FROM turns WHERE id = ?').get(itemId) as { role: string; content: string } | undefined;
    if (!t) return null;
    return { type: `turn:${t.role}`, text: t.content.slice(0, 300) + (t.content.length > 300 ? '...' : '') };
  } else {
    const s = db.prepare('SELECT depth, content FROM summary_nodes WHERE id = ?').get(itemId) as { depth: number; content: string } | undefined;
    if (!s) return null;
    return { type: `summary:depth${s.depth}`, depth: s.depth, text: s.content.slice(0, 300) + (s.content.length > 300 ? '...' : '') };
  }
}

export interface HybridSearchResult {
  type: string;
  id: string;
  rrfScore: number;
  excerpt: string;
  sources: string[]; // which systems found it: 'text' | 'semantic'
}

/**
 * Hybrid search combining keyword and semantic results via Reciprocal Rank Fusion (RRF).
 * This is the recommended default search mode.
 */
export async function hybridSearchLcm(
  groupFolder: string,
  query: string,
  limit = 5,
): Promise<HybridSearchResult[]> {
  if (!isLcmEnabledForFolder(groupFolder)) return [];

  const [textResults, semanticResults] = await Promise.all([
    Promise.resolve(searchLcm(groupFolder, query)),
    semanticSearchLcm(groupFolder, query, Math.min(limit * 3, 20)),
  ]);

  const k = 60; // RRF smoothing constant
  const merged = new Map<string, { score: number; type: string; excerpt: string; sources: string[] }>();

  textResults.forEach((r, i) => {
    merged.set(r.id, { score: 1 / (k + i + 1), type: r.type, excerpt: r.excerpt, sources: ['text'] });
  });

  semanticResults.forEach((r, i) => {
    const rrfScore = 1 / (k + i + 1);
    const existing = merged.get(r.id);
    if (existing) {
      existing.score += rrfScore;
      existing.sources.push('semantic');
    } else {
      merged.set(r.id, { score: rrfScore, type: r.type, excerpt: r.excerpt, sources: ['semantic'] });
    }
  });

  return Array.from(merged.entries())
    .sort((a, b) => b[1].score - a[1].score)
    .slice(0, limit)
    .map(([id, data]) => ({
      id,
      type: data.type,
      rrfScore: Math.round(data.score * 10000) / 10000,
      excerpt: data.excerpt,
      sources: data.sources,
    }));
}

// ---------------------------------------------------------------------------
// Expand / describe
// ---------------------------------------------------------------------------

export function expandSummaryNode(groupFolder: string, summaryId: string): { summary: LcmSummaryNode; turns: LcmTurn[] } | null {
  if (!isLcmEnabledForFolder(groupFolder)) return null;
  try {
    const db = getDb(groupFolder);
    if (!db) return null;
    const node = db.prepare('SELECT * FROM summary_nodes WHERE id = ?').get(summaryId) as LcmSummaryNode | undefined;
    if (!node) return null;
    const turnIds: string[] = JSON.parse(node.source_turn_ids);
    const placeholders = turnIds.map(() => '?').join(',');
    const turns = turnIds.length > 0
      ? (db.prepare(`SELECT * FROM turns WHERE id IN (${placeholders}) ORDER BY seq ASC`).all(...turnIds) as LcmTurn[])
      : [];
    return { summary: node, turns };
  } catch {
    return null;
  }
}

export function describeLcm(groupFolder: string, id?: string): string {
  if (!isLcmEnabledForFolder(groupFolder)) return 'LCM is not enabled.';
  try {
    const db = getDb(groupFolder);
    if (!db) return 'No LCM database found for this group.';

    if (id) {
      const node = db.prepare('SELECT * FROM summary_nodes WHERE id = ?').get(id) as LcmSummaryNode | undefined;
      if (!node) return `No summary node found with id: ${id}`;
      const sourceTurnIds: string[] = JSON.parse(node.source_turn_ids);
      return [
        `Summary node: ${node.id}`,
        `Depth: ${node.depth}`,
        `Source turns: ${sourceTurnIds.length}`,
        `Parent: ${node.parent_id ?? 'none'}`,
        `Created: ${node.created_at}`,
        `Content preview: ${node.content.slice(0, 200)}...`,
      ].join('\n');
    }

    const turnCount = (db.prepare('SELECT COUNT(*) as n FROM turns').get() as { n: number }).n;
    const summaryCount = (db.prepare('SELECT COUNT(*) as n FROM summary_nodes').get() as { n: number }).n;
    const embCount = isVecEnabled(db)
      ? (db.prepare('SELECT COUNT(*) as n FROM embedding_meta').get() as { n: number }).n
      : (db.prepare('SELECT COUNT(*) as n FROM embeddings').get() as { n: number }).n;
    const oldest = db.prepare('SELECT timestamp FROM turns ORDER BY seq ASC LIMIT 1').get() as { timestamp: string } | undefined;
    const newest = db.prepare('SELECT timestamp FROM turns ORDER BY seq DESC LIMIT 1').get() as { timestamp: string } | undefined;

    return [
      `LCM context for group: ${groupFolder}`,
      `Total turns: ${turnCount}`,
      `Summary nodes: ${summaryCount}`,
      `Embeddings stored: ${embCount} (${isVecEnabled(db) ? 'sqlite-vec' : 'BLOB fallback'})`,
      `History from: ${oldest?.timestamp ?? 'n/a'}`,
      `History to: ${newest?.timestamp ?? 'n/a'}`,
    ].join('\n');
  } catch {
    return 'Error reading LCM database.';
  }
}

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------

export function _initTestLcmDb(): Database.Database {
  const db = new Database(':memory:');
  db.pragma('journal_mode = WAL');
  // Try to load vec in test environment too
  tryLoadVec(db);
  runMigrations(db);
  ensureVecTables(db);
  return db;
}

export function _injectTestDb(groupFolder: string, db: Database.Database): void {
  dbCache.set(groupFolder, db);
}

export function _clearDbCache(): void {
  dbCache.clear();
}

// ---------------------------------------------------------------------------
// Compaction
// ---------------------------------------------------------------------------

const COMPACTION_FRESH_TAIL = 32;
const COMPACTION_SOFT_THRESHOLD = 200_000 * 0.75;
const LEAF_SUMMARY_MAX_TOKENS = 1200;

export function buildLeafSummaryPrompt(text: string, aggressive = false): string {
  const inputTokens = Math.ceil(text.length / CHARS_PER_TOKEN);
  const targetTokens = aggressive
    ? Math.max(96, Math.min(640, Math.floor(inputTokens * 0.2)))
    : Math.max(192, Math.min(LEAF_SUMMARY_MAX_TOKENS, Math.floor(inputTokens * 0.35)));

  const policy = aggressive
    ? [
        'Aggressive summary policy:',
        '- Keep only durable facts and current task state.',
        '- Remove examples, repetition, and low-value narrative details.',
        '- Preserve explicit TODOs, blockers, decisions, and constraints.',
      ].join('\n')
    : [
        'Normal summary policy:',
        '- Preserve key decisions, rationale, constraints, and active tasks.',
        '- Keep essential technical details needed to continue work safely.',
        '- Remove obvious repetition and conversational filler.',
      ].join('\n');

  return [
    'You summarize a SEGMENT of a conversation for future model turns.',
    'Treat this as incremental memory compaction input, not a full-conversation summary.',
    policy,
    [
      'Output requirements:',
      '- Plain text only.',
      '- No preamble, headings, or markdown formatting.',
      '- Keep it concise while preserving required details.',
      '- Track file operations (created, modified, deleted, renamed) with file paths and current status.',
      '- If no file operations appear, include exactly: "Files: none".',
      '- End with exactly: "Expand for details about: <list of what was dropped>".',
      `- Target length: about ${targetTokens} tokens or less.`,
    ].join('\n'),
    `<conversation_segment>\n${text}\n</conversation_segment>`,
  ].join('\n\n');
}

function formatTurnsForCompaction(turns: LcmTurn[]): string {
  return turns.map((t) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.content}`).join('\n\n');
}

export function shouldCompact(groupFolder: string): boolean {
  if (!isLcmEnabledForFolder(groupFolder)) return false;
  try {
    const db = getDb(groupFolder);
    if (!db) return false;
    const totalTurns = (db.prepare('SELECT COUNT(*) as n FROM turns').get() as { n: number }).n;
    if (totalTurns <= COMPACTION_FRESH_TAIL) return false;
    const oldTurns = db
      .prepare('SELECT content FROM turns ORDER BY seq ASC LIMIT ?')
      .all(Math.max(0, totalTurns - COMPACTION_FRESH_TAIL)) as Array<{ content: string }>;
    const estimatedTokens = oldTurns.reduce((sum, t) => sum + Math.ceil(t.content.length / CHARS_PER_TOKEN), 0);
    return estimatedTokens > COMPACTION_SOFT_THRESHOLD;
  } catch {
    return false;
  }
}

export function getTurnsForCompaction(groupFolder: string): LcmTurn[] {
  if (!isLcmEnabledForFolder(groupFolder)) return [];
  try {
    const db = getDb(groupFolder);
    if (!db) return [];
    const totalTurns = (db.prepare('SELECT COUNT(*) as n FROM turns').get() as { n: number }).n;
    if (totalTurns <= COMPACTION_FRESH_TAIL) return [];
    const summaryRows = db
      .prepare('SELECT source_turn_ids FROM summary_nodes WHERE depth = 0')
      .all() as Array<{ source_turn_ids: string }>;
    const summarisedIds = new Set<string>(
      summaryRows.flatMap((r) => { try { return JSON.parse(r.source_turn_ids) as string[]; } catch { return []; } }),
    );
    const candidates = db
      .prepare('SELECT * FROM turns ORDER BY seq ASC LIMIT ?')
      .all(Math.max(0, totalTurns - COMPACTION_FRESH_TAIL)) as LcmTurn[];
    return candidates.filter((t) => !summarisedIds.has(t.id));
  } catch {
    return [];
  }
}

export function deterministicTruncate(text: string): string {
  const maxChars = 512 * CHARS_PER_TOKEN;
  if (text.length <= maxChars) return text;
  return text.slice(0, maxChars) + '\n[LCM: deterministic truncation applied]';
}

export async function runCompaction(
  groupFolder: string,
  summarize: (prompt: string) => Promise<string>,
): Promise<{ compacted: boolean; summaryId: string | null; error?: string }> {
  if (!isLcmEnabledForFolder(groupFolder)) return { compacted: false, summaryId: null };
  const turns = getTurnsForCompaction(groupFolder);
  if (turns.length === 0) return { compacted: false, summaryId: null };
  const text = formatTurnsForCompaction(turns);
  const turnIds = turns.map((t) => t.id);
  let summary: string | null = null;

  try {
    const result = await summarize(buildLeafSummaryPrompt(text, false));
    if (result.trim() && result.length < text.length) summary = result.trim();
  } catch { /* fall through */ }

  if (!summary) {
    try {
      const result = await summarize(buildLeafSummaryPrompt(text, true));
      if (result.trim() && result.length < text.length) summary = result.trim();
    } catch { /* fall through */ }
  }

  if (!summary) summary = deterministicTruncate(text);

  const summaryId = storeSummaryNode({ groupFolder, depth: 0, content: summary, sourceTurnIds: turnIds });
  return { compacted: true, summaryId };
}

// ---------------------------------------------------------------------------
// Summarize function factory
// ---------------------------------------------------------------------------

const LCM_SUMMARIZER_SYSTEM =
  'You are a context-compaction summarization engine. Follow user instructions exactly and return plain text summary content only.';

export function makeSummarizeFunction(
  apiKey: string,
  oauthToken = '',
  model = 'claude-haiku-4-5-20251001',
): (prompt: string) => Promise<string> {
  return async (prompt: string): Promise<string> => {
    const authHeaders: Record<string, string> = apiKey
      ? { 'x-api-key': apiKey }
      : { 'Authorization': `Bearer ${oauthToken}` };
    const resp = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...authHeaders, 'anthropic-version': '2023-06-01' },
      body: JSON.stringify({ model, max_tokens: 1200, system: LCM_SUMMARIZER_SYSTEM, messages: [{ role: 'user', content: prompt }] }),
    });
    if (!resp.ok) throw new Error(`Anthropic API error: ${resp.status}`);
    const data = (await resp.json()) as { content: Array<{ type: string; text?: string }> };
    return data.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('').trim();
  };
}
