import { expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { Database } from "bun:sqlite";
import { readOperationalMetricEvents } from "../../packages/butler-agent/src/operations/metrics/operational-metrics.ts";
import {
  createCachedRecallMemoryRunner,
  loadRecallCorpus,
  recallFromCorpus,
  recallMemory,
  recallRankingPolicyFromPlan,
  verifyRecallEvidence,
  type RecallCorpus,
} from "../../packages/butler-agent/src/agent/cognition/memory/recall/engine.ts";

const now = Date.parse("2026-04-26T00:00:00.000Z");

function baseCorpus(): RecallCorpus {
  return {
    hotCacheHints: ["떡볶이, 식단, 로제 선택"],
    nodes: [
      { id: "n-tteokbokki", type: "entity", name: "떡볶이", degree: 2 },
      { id: "n-rose", type: "preference", name: "로제 떡볶이", degree: 1 },
      { id: "n-diet", type: "goal", name: "저탄수 식단", degree: 1 },
      { id: "n-runtime", type: "decision", name: "런타임", degree: 2 },
      { id: "n-hub", type: "project", name: "butler", degree: 40 },
    ],
    edges: [
      { sourceId: "n-tteokbokki", targetId: "n-rose", relType: "likes", weight: 1 },
      { sourceId: "n-tteokbokki", targetId: "n-diet", relType: "related_to", weight: 1 },
      { sourceId: "n-runtime", targetId: "n-hub", relType: "belongs_to", weight: 1 },
      { sourceId: "n-hub", targetId: "n-rose", relType: "related_to", weight: 1 },
    ],
    candidates: [
      {
        id: "ep-rose",
        summary: "지난번에는 로제 떡볶이 선택을 골랐다.",
        text: "사용자는 지난번 로제 떡볶이 선택을 골랐고 만족했다.",
        source: "vector",
        originalSource: "task-memory",
        provenance: ["transcript:food-1"],
        relatedNodes: ["n-rose"],
        timestamp: Math.floor(now / 1000),
        frequency: 2,
      },
      {
        id: "ep-diet",
        summary: "떡볶이 선택에는 저탄수 식단 목표가 함께 고려됐다.",
        text: "사용자는 떡볶이 야식 선택 때 최근 저탄수 식단 목표도 고려하기로 했다.",
        source: "graph",
        originalSource: "graph",
        provenance: ["transcript:diet-1"],
        relatedNodes: ["n-diet"],
        timestamp: Math.floor(now / 1000),
        frequency: 1,
      },
      {
        id: "ep-hub",
        summary: "Butler 프로젝트에는 많은 하위 주제가 있다.",
        text: "Butler 프로젝트에는 transport, memory, search, install 등 많은 주제가 있다.",
        source: "graph",
        originalSource: "graph",
        provenance: ["transcript:hub"],
        relatedNodes: ["n-hub"],
        timestamp: Math.floor(now / 1000),
        frequency: 8,
      },
    ],
  };
}

test("associative recall activates indirect graph memories and keeps provenance", () => {
  const result = recallFromCorpus({
    cue: "아 떡볶이 먹고 싶다",
    corpus: baseCorpus(),
    now,
    limit: 3,
    minScore: 0.01,
  });

  expect(result.abstained).toBe(false);
  expect(result.items.map((item) => item.provenance[0])).toContain("transcript:food-1");
  expect(result.items.map((item) => item.provenance[0])).toContain("transcript:diet-1");
  expect(result.items[0]?.score_breakdown.graph_activation).toBeGreaterThan(0);
  expect(result.items.some((item) => item.source === "hybrid" || item.source === "graph")).toBe(true);
});

test("associative recall penalizes hub nodes and superseded memories", () => {
  const corpus = baseCorpus();
  corpus.candidates.push({
    id: "ep-old-runtime",
    summary: "Node 전환을 검토했다.",
    text: "예전에는 Node 런타임 이전을 검토했다.",
    source: "vector",
    originalSource: "task-memory",
    provenance: ["transcript:old-runtime"],
    relatedNodes: ["n-runtime"],
    supersededBy: "ep-managed-bun",
    frequency: 5,
  }, {
    id: "ep-managed-bun",
    summary: "최종 결정은 Butler-managed Bun 유지다.",
    text: "최종 런타임 결정은 Bun을 Butler-managed runtime으로 유지하는 것이다.",
    source: "vector",
    originalSource: "task-memory",
    provenance: ["transcript:managed-bun"],
    relatedNodes: ["n-runtime"],
    explicitSalience: 0.8,
  });

  const result = recallFromCorpus({
    cue: "최종 런타임 결정 Bun",
    corpus,
    now,
    minScore: 0.01,
  });

  expect(result.items[0]?.provenance[0]).toBe("transcript:managed-bun");
  const old = result.items.find((item) => item.provenance[0] === "transcript:old-runtime");
  expect(old).toBeUndefined();
});

test("associative recall abstains on unsupported memories", () => {
  const result = recallFromCorpus({
    cue: "지난번에 산 운동화 색깔이 뭐였죠?",
    corpus: baseCorpus(),
    now,
  });

  expect(result.abstained).toBe(true);
  expect(result.items).toHaveLength(0);
});

test("associative recall does not surface recency-only candidates", () => {
  const result = recallFromCorpus({
    cue: "완전히 새로운 주제입니다",
    now,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "recent-no-evidence",
        summary: "최근 대화지만 현재 질의와 관계가 없다.",
        text: "최근 대화지만 현재 질의와 관계가 없다.",
        source: "vector",
        originalSource: "task-memory",
        provenance: ["transcript:recent"],
        timestamp: Math.floor(now / 1000),
        frequency: 8,
      }],
    },
  });

  expect(result.abstained).toBe(true);
});

test("associative recall keeps dominant low-confidence evidence below the normal floor", () => {
  const result = recallFromCorpus({
    cue: "continue the weakly anchored memory",
    now,
    minScore: 0.5,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "dominant-low-score",
        summary: "Weak but specific continuity evidence",
        text: "The previous discussion has one clearly separated continuation candidate.",
        source: "hot-cache",
        originalSource: "hot-cache",
        provenance: ["memory:dominant"],
        contextualScore: 0.2,
      }, {
        id: "distant-low-score",
        summary: "A weaker neighboring candidate",
        text: "This candidate is related but much less specific.",
        source: "hot-cache",
        originalSource: "hot-cache",
        provenance: ["memory:distant"],
        contextualScore: 0.05,
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.provenance[0]).toBe("memory:dominant");
  expect(result.diagnostics).toContain("score_gate=dominant-low-confidence");
});

test("associative recall abstains when low-confidence fallback evidence is ambiguous", () => {
  const result = recallFromCorpus({
    cue: "continue the weakly anchored memory",
    now,
    minScore: 0.5,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "first-low-score",
        summary: "First weak continuation candidate",
        text: "The previous discussion might refer to this first weak candidate.",
        source: "hot-cache",
        originalSource: "hot-cache",
        provenance: ["memory:first"],
        contextualScore: 0.2,
      }, {
        id: "second-low-score",
        summary: "Second weak continuation candidate",
        text: "The previous discussion might also refer to this second weak candidate.",
        source: "hot-cache",
        originalSource: "hot-cache",
        provenance: ["memory:second"],
        contextualScore: 0.15,
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.items).toHaveLength(0);
  expect(result.diagnostics).toContain("score_gate=ambiguous-low-confidence");
});

test("fallback recall treats graph activation as corroboration rather than standalone evidence", () => {
  const result = recallFromCorpus({
    cue: "image OCR noise cleanup",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [
        { id: "ocr", type: "topic", name: "image OCR noise cleanup", degree: 1 },
      ],
      edges: [],
      candidates: [{
        id: "graph-only-decoy",
        summary: "Readability extraction decision",
        text: "The web reader note discussed article extraction and fallback raw mode.",
        source: "graph",
        originalSource: "graph",
        provenance: ["graph:web-reader"],
        relatedNodes: ["ocr"],
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.items).toHaveLength(0);
});

test("planned graph recall can still request graph-only relation evidence", () => {
  const result = recallFromCorpus({
    cue: "image OCR noise cleanup",
    now,
    minScore: 0.01,
    rankingPolicy: recallRankingPolicyFromPlan({
      strategies: ["read_graph_memory"],
      evidence_required: ["graph_relation_hit"],
    }),
    corpus: {
      hotCacheHints: [],
      nodes: [
        { id: "ocr", type: "topic", name: "image OCR noise cleanup", degree: 1 },
      ],
      edges: [],
      candidates: [{
        id: "graph-only-memory",
        summary: "Vision processing relation",
        text: "This relation exists only through the graph channel.",
        source: "graph",
        originalSource: "graph",
        provenance: ["graph:vision"],
        relatedNodes: ["ocr"],
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.provenance[0]).toBe("graph:vision");
  expect(result.diagnostics).toContain("evidence=verified");
});

test("associative recall reports contextual evidence only when bounded context connects to a candidate", () => {
  const result = recallFromCorpus({
    cue: "계속해줘",
    now,
    minScore: 0.01,
    context: {
      recentContext: "We decided that the composer ask-first approval form uses vertical card items.",
      activeTaskSummary: "Implement composer approval form continuity.",
      projectId: "butler",
    },
    corpus: {
      hotCacheHints: [],
      nodes: [
        { id: "composer", type: "surface", name: "composer approval form", degree: 1 },
      ],
      edges: [],
      candidates: [
        {
          id: "composer-form",
          summary: "Composer ask-first approval form renders vertical card items.",
          text: "The approval form replaces the composer input and lays options out as a card item list.",
          source: "task-memory",
          originalSource: "task-memory",
          provenance: ["task:butler:composer-form"],
          relatedNodes: ["composer"],
        },
        {
          id: "unrelated",
          summary: "Search planning has web source buckets.",
          text: "Web search planning decomposes external source queries.",
          source: "task-memory",
          originalSource: "task-memory",
          provenance: ["task:butler:search"],
        },
      ],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.provenance[0]).toBe("task:butler:composer-form");
  expect(result.items[0]?.score_breakdown.contextual_match).toBeGreaterThan(0);
  expect(result.diagnostics).toContain("contextual_candidates=1");
});

test("associative recall reports lexical evidence separately from semantic similarity", () => {
  const result = recallFromCorpus({
    cue: "composer approval form",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "composer-form",
        summary: "Composer approval form",
        text: "The ask-first approval form replaces the composer input.",
        source: "task-memory",
        originalSource: "task-memory",
        provenance: ["memory:composer-form"],
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.source).toBe("task-memory");
  expect(result.items[0]?.score_breakdown.semantic_similarity).toBe(0);
  expect(result.items[0]?.score_breakdown.lexical_match).toBeGreaterThan(0);
  expect(result.items[0]?.score_breakdown.contextual_match).toBe(0);
  expect(result.items[0]?.score_breakdown.evidence_confidence).toBe(
    result.items[0]?.score_breakdown.lexical_match,
  );
});

test("associative recall uses non-ASCII character shingles without particle dictionaries", () => {
  const result = recallFromCorpus({
    cue: "런타임은 어떻게 정했지?",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "runtime-decision",
        summary: "최종 런타임 결정",
        text: "최종 런타임 결정은 Butler-managed Bun 유지다.",
        source: "task-memory",
        originalSource: "task-memory",
        provenance: ["memory:runtime"],
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.provenance[0]).toBe("memory:runtime");
  expect(result.items[0]?.score_breakdown.lexical_match).toBeGreaterThan(0);
});

test("associative recall uses semantic similarity only for real vector candidates", () => {
  const result = recallFromCorpus({
    cue: "semantic episode",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "vector-episode",
        summary: "A vector-backed semantic episode",
        text: "This candidate came from a vector backend.",
        source: "vector",
        provenance: ["vector:episode-1"],
        vectorSimilarity: 0.82,
      }],
    },
  });

  expect(result.items[0]?.source).toBe("vector");
  expect(result.items[0]?.score_breakdown.semantic_similarity).toBe(0.82);
  expect(result.items[0]?.score_breakdown.lexical_match).toBeGreaterThan(0);
});

test("associative recall can use semantic-only vector evidence without lexical overlap", () => {
  const result = recallFromCorpus({
    cue: "사과",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "semantic-fruit",
        summary: "과일 선택",
        text: "과일을 고르는 대화였다.",
        source: "vector",
        provenance: ["vector:fruit"],
        vectorSimilarity: 0.9,
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.source).toBe("vector");
  expect(result.items[0]?.score_breakdown.semantic_similarity).toBe(0.9);
  expect(result.items[0]?.score_breakdown.lexical_match).toBe(0);
});

test("recall evidence verifier rejects exact-quote answers from summary recall", () => {
  const result = recallFromCorpus({
    cue: "정확한 문장",
    now,
    minScore: 0.01,
    evidencePolicy: {
      evidenceRequired: ["exact_quote"],
    },
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "summary-only",
        summary: "사용자는 정확한 문장을 물어본 적이 있다.",
        text: "This is only a memory summary, not an exact transcript projection hit.",
        source: "task-memory",
        originalSource: "task-memory",
        provenance: ["memory:summary"],
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.diagnostics).toContain("evidence=exact_quote_requires_query_memory");
});

test("planned recall ranks by requested evidence instead of fixed weighted sums", () => {
  const result = recallFromCorpus({
    cue: "composer approval form",
    now,
    minScore: 0.01,
    rankingPolicy: recallRankingPolicyFromPlan({
      strategies: ["search_vector_episode"],
      evidence_required: ["vector_episode_hit"],
    }),
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "lexical-project",
        summary: "Composer approval form",
        text: "Composer approval form uses lexical project memory anchors.",
        source: "project-memory",
        originalSource: "project-memory",
        provenance: ["project:composer"],
        timestamp: Math.floor(now / 1000),
        frequency: 8,
      }, {
        id: "semantic-vector",
        summary: "Semantic UI decision episode",
        text: "The semantic episode says the ask-first UI replaced the input field.",
        source: "vector",
        originalSource: "task-memory",
        provenance: ["vector:episode-approval"],
        vectorSimilarity: 0.24,
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.diagnostics).toContain("ranking_policy=planned");
  expect(result.items[0]?.provenance[0]).toBe("vector:episode-approval");
  expect(result.items[0]?.score_breakdown.semantic_similarity).toBe(0.24);
  expect(result.items[0]?.score_breakdown.lexical_match).toBe(0);
});

test("retrieval plans enforce non-exact evidence requirements", () => {
  const result = recallFromCorpus({
    cue: "runtime decision",
    now,
    minScore: 0.01,
    limit: 2,
    rankingPolicy: recallRankingPolicyFromPlan({
      strategies: ["search_vector_episode", "search_lexical_memory", "read_graph_memory", "read_task_state"],
      evidence_required: [
        "vector_episode_hit",
        "project_memory_hit",
        "graph_relation_hit",
        "task_continuity",
      ],
    }),
    corpus: {
      hotCacheHints: [],
      nodes: [{ id: "runtime", type: "decision", name: "runtime decision", degree: 1 }],
      edges: [],
      candidates: [{
        id: "vector-hit",
        summary: "Semantic runtime episode",
        text: "The semantic episode covers the runtime decision.",
        source: "vector",
        provenance: ["vector:runtime"],
        vectorSimilarity: 0.64,
      }, {
        id: "project-hit",
        summary: "Runtime decision project memory",
        text: "Runtime decision is recorded in project memory.",
        source: "project-memory",
        originalSource: "project-memory",
        provenance: ["project:runtime"],
      }, {
        id: "graph-hit",
        summary: "Runtime decision graph relation",
        text: "Runtime decision is connected to the managed runtime node.",
        source: "graph",
        originalSource: "graph",
        provenance: ["graph:runtime"],
        relatedNodes: ["runtime"],
      }, {
        id: "task-hit",
        summary: "Runtime task continuity",
        text: "Runtime decision remains the active task continuity record.",
        source: "task-memory",
        originalSource: "task-memory",
        provenance: ["task:runtime"],
      }, {
        id: "plain-lexical",
        summary: "Runtime decision lexical note",
        text: "Runtime decision appears here, but this note is not one of the requested evidence channels.",
        source: "hot-cache",
        originalSource: "hot-cache",
        provenance: ["hot:runtime"],
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.diagnostics).toContain("evidence=verified");
  expect(result.diagnostics).not.toContain("evidence_missing=vector_episode_hit");
  expect(result.items.map((item) => item.provenance[0])).toEqual([
    "vector:runtime",
    "project:runtime",
    "graph:runtime",
    "task:runtime",
  ]);
});

test("evidence-only retrieval plans rank required evidence before slicing", () => {
  const result = recallFromCorpus({
    cue: "runtime decision",
    now,
    minScore: 0.01,
    limit: 1,
    rankingPolicy: recallRankingPolicyFromPlan({
      strategies: [],
      evidence_required: ["project_memory_hit"],
    }),
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "semantic-distractor",
        summary: "Runtime decision semantic distractor",
        text: "This vector episode is semantically strong but is not project memory.",
        source: "vector",
        provenance: ["vector:runtime-distractor"],
        vectorSimilarity: 0.95,
      }, {
        id: "project-hit",
        summary: "Runtime decision project memory",
        text: "Runtime decision is recorded in project memory.",
        source: "project-memory",
        originalSource: "project-memory",
        provenance: ["project:runtime"],
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.diagnostics).toContain("ranking_policy=planned");
  expect(result.items).toHaveLength(1);
  expect(result.items[0]?.provenance[0]).toBe("project:runtime");
});

test("recall evidence verifier rejects missing non-exact evidence channels", () => {
  const result = recallFromCorpus({
    cue: "runtime decision",
    now,
    minScore: 0.01,
    evidencePolicy: {
      evidenceRequired: ["vector_episode_hit"],
    },
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "fake-vector",
        summary: "Runtime decision",
        text: "Runtime decision matches lexically but has no vector similarity.",
        source: "vector",
        provenance: ["memory:not-a-vector-hit"],
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.diagnostics).toContain("evidence_missing=vector_episode_hit");
});

test("task continuity evidence requires task provenance or contextual linkage", () => {
  const result = recallFromCorpus({
    cue: "runtime decision",
    now,
    minScore: 0.01,
    evidencePolicy: {
      evidenceRequired: ["task_continuity"],
    },
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "static-task-note",
        summary: "Runtime decision task note",
        text: "Runtime decision appears in a static task-memory note.",
        source: "task-memory",
        originalSource: "task-memory",
        provenance: ["memory:task-note"],
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.diagnostics).toContain("evidence_missing=task_continuity");
});

test("recall evidence verifier rejects weak or ambiguous specific-memory evidence", () => {
  const baseItem = {
    summary: "후보",
    source: "task-memory" as const,
    originalSource: "task-memory" as const,
    provenance: ["memory:one"],
    related_nodes: [],
    score_breakdown: {
      semantic_similarity: 0,
      lexical_match: 0.1,
      contextual_match: 0,
      graph_activation: 0,
      recency_score: 0,
      frequency_score: 0,
      explicit_salience: 0,
      evidence_confidence: 0.1,
      decision_preference_boost: 0,
      hub_penalty: 0,
      conflict_penalty: 0,
      stale_superseded_penalty: 0,
      total: 0.4,
    },
  };
  const weak = verifyRecallEvidence({
    items: [{ ...baseItem, confidence: 0.4 }],
    policy: { minEvidenceConfidence: 0.2 },
  });
  const ambiguous = verifyRecallEvidence({
    items: [
      { ...baseItem, confidence: 0.7 },
      { ...baseItem, confidence: 0.69, provenance: ["memory:two"] },
    ],
    policy: { requireSpecificMemory: true, tieMargin: 0.02 },
  });

  expect(weak.verified).toBe(false);
  expect(weak.nextAction).toBe("try_alternate_retrieval");
  expect(ambiguous.verified).toBe(false);
  expect(ambiguous.diagnostics).toContain("evidence=ambiguous_tie");
});

test("associative recall applies contradiction penalties", () => {
  const result = recallFromCorpus({
    cue: "최종 보고 선호 자세하게",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [
        { id: "reporting", type: "preference", name: "보고", degree: 1 },
      ],
      edges: [],
      candidates: [{
        id: "old-reporting",
        summary: "보고는 자세하게 한다.",
        text: "예전에는 최종 보고 선호를 자세하게 하는 편이 좋다고 추정했다.",
        source: "vector",
        originalSource: "task-memory",
        provenance: ["transcript:old-reporting"],
        relatedNodes: ["reporting"],
        contradicts: ["new-reporting"],
      }, {
        id: "new-reporting",
        summary: "최종 선호는 핵심만 간결하게 보고하는 것이다.",
        text: "사용자는 최종적으로 핵심만 간결하게 보고하기를 선호한다고 말했다.",
        source: "explicit",
        originalSource: "rules",
        provenance: ["rules:reporting"],
        relatedNodes: ["reporting"],
        explicitSalience: 1,
      }],
    },
  });

  expect(result.items.some((item) => item.provenance[0] === "rules:reporting")).toBe(true);
  const old = result.items.find((item) => item.provenance[0] === "transcript:old-reporting");
  expect(old?.score_breakdown.conflict_penalty ?? 0).toBeGreaterThan(0);
});

test("explicit recall requires a cue anchor before rules become evidence", () => {
  const result = recallFromCorpus({
    cue: "이미지 OCR 노이즈 제거 결론",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "reporting-rule",
        summary: "보고는 핵심만 간결하게 한다.",
        text: "사용자는 최종적으로 핵심만 간결하게 보고하기를 선호한다.",
        source: "explicit",
        originalSource: "rules",
        provenance: ["rules:reporting"],
        explicitSalience: 1,
      }],
    },
  });

  expect(result.abstained).toBe(true);
  expect(result.items).toHaveLength(0);
});

test("explicit recall still returns rules when the cue anchors the rule", () => {
  const result = recallFromCorpus({
    cue: "간결하게 보고하기",
    now,
    minScore: 0.01,
    corpus: {
      hotCacheHints: [],
      nodes: [],
      edges: [],
      candidates: [{
        id: "reporting-rule",
        summary: "보고는 핵심만 간결하게 한다.",
        text: "사용자는 최종적으로 핵심만 간결하게 보고하기를 선호한다.",
        source: "explicit",
        originalSource: "rules",
        provenance: ["rules:reporting"],
        explicitSalience: 1,
      }],
    },
  });

  expect(result.abstained).toBe(false);
  expect(result.items[0]?.provenance[0]).toBe("rules:reporting");
  expect(result.items[0]?.score_breakdown.explicit_salience).toBeGreaterThan(0);
});

test("file-backed recall memory returns explicit and graph-backed results", () => {
  const tempDir = join(tmpdir(), `butler-recall-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "rules"), { recursive: true });
    mkdirSync(join(tempDir, "cognition", "memory", "tasks"), { recursive: true });
    mkdirSync(join(tempDir, "cognition", "memory", "db"), { recursive: true });
    writeFileSync(join(tempDir, "cognition", "memory", "rules", "report.md"), "Always keep final reports concise.\n", "utf8");
    writeFileSync(join(tempDir, "cognition", "memory", "tasks", "search.md"), "검색 공급자 결정: DuckDuckGo 기본, Brave fallback.\n", "utf8");

    const db = new Database(join(tempDir, "cognition", "memory", "db", "graph.sqlite"));
    db.exec(`
      CREATE TABLE entities (id TEXT, type TEXT, name TEXT);
      CREATE TABLE edges (id INTEGER, source_id TEXT, target_id TEXT, rel_type TEXT, weight REAL);
      CREATE TABLE entity_mentions (id INTEGER, entity_id TEXT, session_id TEXT, timestamp INTEGER, snippet TEXT);
      INSERT INTO entities VALUES ('duck', 'decision', '검색 공급자 DuckDuckGo');
      INSERT INTO entities VALUES ('fallback', 'decision', 'Brave fallback');
      INSERT INTO edges VALUES (1, 'duck', 'fallback', 'supports', 1.0);
      INSERT INTO entity_mentions VALUES (1, 'duck', 'butler_main_c0', 1777210000, 'DuckDuckGo를 기본 검색 공급자로 결정했다.');
    `);
    db.close();

    const result = recallMemory({
      butlerData: tempDir,
      cue: "검색 공급자 DuckDuckGo",
      now,
      minScore: 0.05,
    });

    expect(result.abstained).toBe(false);
    expect(result.items.some((item) => item.provenance.includes(join(tempDir, "cognition", "memory", "tasks", "search.md")))).toBe(true);
    expect(result.items.some((item) => item.provenance.includes("graph:butler_main_c0"))).toBe(true);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("file-backed hot cache recall indexes markdown entries as separate candidates", () => {
  const tempDir = join(tmpdir(), `butler-recall-hot-blocks-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "hot"), { recursive: true });
    writeFileSync(join(tempDir, "cognition", "memory", "hot", "cache.md"), [
      "## [00:00] first",
      "**Task**: A weather briefing was completed.",
      "",
      "## [00:01] second",
      "**Task**: Butler web reader should use Readability with fallback raw mode.",
      "**Learning**: Article, product, and list pages need separate extraction modes.",
    ].join("\n"), "utf8");

    const corpus = loadRecallCorpus({ butlerData: tempDir });
    const hotCandidates = corpus.candidates.filter((candidate) => candidate.source === "hot-cache");
    const result = recallMemory({
      butlerData: tempDir,
      cue: "Readability fallback raw article product list",
      now,
      minScore: 0.01,
    });

    expect(hotCandidates).toHaveLength(2);
    expect(result.abstained).toBe(false);
    expect(result.items[0]?.summary).toContain("Readability");
    expect(result.items[0]?.summary).not.toContain("weather briefing");
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("file-backed graph recall filters candidates by active project", () => {
  const tempDir = join(tmpdir(), `butler-recall-graph-project-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "db"), { recursive: true });
    const db = new Database(join(tempDir, "cognition", "memory", "db", "graph.sqlite"));
    db.exec(`
      CREATE TABLE entities (id TEXT, type TEXT, name TEXT, project TEXT);
      CREATE TABLE edges (id INTEGER, source_id TEXT, target_id TEXT, rel_type TEXT, weight REAL);
      CREATE TABLE entity_mentions (
        id INTEGER,
        entity_id TEXT,
        session_id TEXT,
        timestamp INTEGER,
        snippet TEXT,
        project TEXT
      );
      INSERT INTO entities VALUES ('runtime-butler', 'decision', 'Runtime decision', 'butler');
      INSERT INTO entities VALUES ('runtime-other', 'decision', 'Runtime decision', 'other');
      INSERT INTO entity_mentions VALUES (1, 'runtime-butler', 'butler_session', 1777210000, 'Runtime decision belongs to Butler.', 'butler');
      INSERT INTO entity_mentions VALUES (2, 'runtime-other', 'other_session', 1777210001, 'Runtime decision belongs to another project.', 'other');
    `);
    db.close();

    const result = recallMemory({
      butlerData: tempDir,
      cue: "runtime decision",
      projectId: "butler",
      now,
      minScore: 0.01,
    });

    expect(result.abstained).toBe(false);
    expect(result.items.some((item) => item.provenance.includes("graph:butler_session"))).toBe(true);
    expect(result.items.some((item) => item.provenance.includes("graph:other_session"))).toBe(false);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("project-scoped legacy graph recall degrades without leaking unscoped graph rows", () => {
  const tempDir = join(tmpdir(), `butler-recall-graph-legacy-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "db"), { recursive: true });
    const db = new Database(join(tempDir, "cognition", "memory", "db", "graph.sqlite"));
    db.exec(`
      CREATE TABLE entities (id TEXT, type TEXT, name TEXT);
      CREATE TABLE edges (id INTEGER, source_id TEXT, target_id TEXT, rel_type TEXT, weight REAL);
      CREATE TABLE entity_mentions (id INTEGER, entity_id TEXT, session_id TEXT, timestamp INTEGER, snippet TEXT);
      INSERT INTO entities VALUES ('runtime', 'decision', 'Runtime decision');
      INSERT INTO entity_mentions VALUES (1, 'runtime', 'legacy_session', 1777210000, 'Legacy graph row has no project scope.');
    `);
    db.close();

    const result = recallMemory({
      butlerData: tempDir,
      cue: "runtime decision",
      projectId: "butler",
      now,
      minScore: 0.01,
    });

    expect(result.abstained).toBe(true);
    expect(result.items.some((item) => item.provenance.includes("graph:legacy_session"))).toBe(false);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("cached recall runner avoids reloading the corpus on every turn", () => {
  const tempDir = join(tmpdir(), `butler-recall-cache-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "tasks"), { recursive: true });
    const path = join(tempDir, "cognition", "memory", "tasks", "decision.md");
    writeFileSync(path, "검색 공급자는 DuckDuckGo로 결정했다.\n", "utf8");
    const runner = createCachedRecallMemoryRunner({
      butlerData: tempDir,
      ttlMs: 60_000,
    });

    expect(runner({
      butlerData: tempDir,
      cue: "검색 공급자 뭐였죠?",
    }).abstained).toBe(false);

    writeFileSync(path, "날씨 도구는 다른 결정이다.\n", "utf8");

    expect(runner({
      butlerData: tempDir,
      cue: "날씨 도구는 뭐였죠?",
    }).abstained).toBe(true);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("file-backed recall emits raw-text-free operational metrics", () => {
  const tempDir = join(tmpdir(), `butler-recall-metrics-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "rules"), { recursive: true });
    writeFileSync(join(tempDir, "cognition", "memory", "rules", "secret.md"), "SECRET_MEMORY_TEXT says concise reports matter.\n", "utf8");

    const result = recallMemory({
      butlerData: tempDir,
      cue: "concise reports",
      now,
    });
    const events = readOperationalMetricEvents({
      butlerData: tempDir,
    });
    const event = events.find((candidate) => candidate.category === "memory" && candidate.name === "recall");

    expect(result.abstained).toBe(false);
    expect(event).toMatchObject({
      category: "memory",
      name: "recall",
      status: "ok",
      rawTextStored: false,
      unit: "items",
    });
    expect(event?.dimensions).toMatchObject({
      items_count: result.items.length,
      seeds_count: result.seeds.length,
      candidates_count: 1,
      shadow_baseline_items_count: 0,
      shadow_recall_gain_count: result.items.length,
      abstained: false,
      project_scoped: false,
    });
    const serialized = JSON.stringify(events);
    expect(serialized).not.toContain("SECRET_RECALL_CUE_TEXT");
    expect(serialized).not.toContain("SECRET_MEMORY_TEXT");
    expect(serialized).not.toContain("secret.md");
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("recall metrics respect the shared operational metrics opt-out", () => {
  const tempDir = join(tmpdir(), `butler-recall-metrics-disabled-${Date.now()}-${Math.random()}`);
  const original = process.env.BUTLER_METRICS_ENABLED;
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "rules"), { recursive: true });
    writeFileSync(join(tempDir, "cognition", "memory", "rules", "report.md"), "Always keep reports concise.\n", "utf8");
    process.env.BUTLER_METRICS_ENABLED = "false";

    recallMemory({
      butlerData: tempDir,
      cue: "보고 방식 기억해?",
      now,
    });

    expect(readOperationalMetricEvents({ butlerData: tempDir })).toHaveLength(0);
  } finally {
    if (original === undefined) delete process.env.BUTLER_METRICS_ENABLED;
    else process.env.BUTLER_METRICS_ENABLED = original;
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("recall emits a safe error metric before rethrowing invalid requests", () => {
  const tempDir = join(tmpdir(), `butler-recall-metrics-error-${Date.now()}-${Math.random()}`);
  try {
    expect(() => recallMemory({
      butlerData: tempDir,
      cue: "   ",
      now,
    })).toThrow("recall cue requires text");

    const events = readOperationalMetricEvents({ butlerData: tempDir });
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({
      category: "memory",
      name: "recall",
      status: "error",
      rawTextStored: false,
    });
    expect(JSON.stringify(events)).not.toContain("recall cue requires text");
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("cached recall runner emits safe metrics without reloading private corpus text", () => {
  const tempDir = join(tmpdir(), `butler-recall-cache-metrics-${Date.now()}-${Math.random()}`);
  try {
    mkdirSync(join(tempDir, "cognition", "memory", "tasks"), { recursive: true });
    writeFileSync(join(tempDir, "cognition", "memory", "tasks", "decision.md"), "검색 공급자는 DuckDuckGo로 결정했다.\n", "utf8");
    const runner = createCachedRecallMemoryRunner({
      butlerData: tempDir,
      ttlMs: 60_000,
    });

    runner({
      butlerData: tempDir,
      cue: "검색 공급자 뭐였죠?",
    });

    const events = readOperationalMetricEvents({ butlerData: tempDir });
    expect(events.some((event) => event.category === "memory" && event.name === "recall")).toBe(true);
    expect(JSON.stringify(events)).not.toContain("검색 공급자");
    expect(JSON.stringify(events)).not.toContain("decision.md");
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});
