import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
  type Embedder,
  embeddingStorePath,
  normalizeVector,
} from "../extensions/llm-wiki/lib/embeddings.js";
import { rebuildMetadata, rebuildMetadataLight } from "../extensions/llm-wiki/lib/metadata.js";
import {
  DEFAULT_RECALL_LINKS_THRESHOLD,
  DEFAULT_SEMANTIC_WEIGHT,
  SEMANTIC_SCALE,
  type SemanticContext,
  __clearQueryEmbeddingCache,
  formatRecallContext,
  fuseScores,
  registerWikiRecall,
  searchWiki,
  searchWikiHybrid,
  searchWikiLayered,
  shouldUseLinksFirst,
  vaultPageCount,
} from "../extensions/llm-wiki/lib/recall.js";
import {
  ensureVaultStructure,
  getPersonalWikiPaths,
  getVaultPaths,
} from "../extensions/llm-wiki/lib/utils.js";

describe("wiki recall", () => {
  let wikiDir: string;
  let tmpDir: string;
  let prevWikiHome: string | undefined;

  beforeEach(() => {
    tmpDir = join(
      import.meta.dirname,
      "..",
      "tmp",
      `recall-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    );
    mkdirSync(tmpDir, { recursive: true });
    // Sandbox the personal vault: point WIKI_HOME at a clean temp dir so tests
    // never read the developer's real ~/.llm-wiki (which would make the
    // "no personal wiki" cases flaky depending on the machine).
    prevWikiHome = process.env.WIKI_HOME;
    process.env.WIKI_HOME = join(tmpDir, "home");
    mkdirSync(process.env.WIKI_HOME, { recursive: true });
    wikiDir = (() => {
      const dir = join(tmpDir, `wiki-${Math.random().toString(36).slice(2)}`);
      mkdirSync(dir, { recursive: true });
      const llmWiki = join(dir, ".llm-wiki");
      const dirs = [
        "raw/articles",
        "raw/papers",
        "raw/notes",
        "raw/assets",
        "wiki/entities",
        "wiki/concepts",
        "wiki/sources",
        "wiki/syntheses",
        "wiki/changes",
        "meta",
        "outputs",
        ".discoveries",
      ];
      for (const d of dirs) mkdirSync(join(llmWiki, d), { recursive: true });
      return dir;
    })();
    ensureVaultStructure(getVaultPaths(wikiDir));
    writeFileSync(
      join(getVaultPaths(wikiDir).dotWiki, "config.json"),
      JSON.stringify({ name: "Recall test" }),
    );
  });

  afterEach(() => {
    // biome-ignore lint/performance/noDelete: delete truly unsets the env var (assigning undefined coerces to "undefined")
    if (prevWikiHome === undefined) delete process.env.WIKI_HOME;
    else process.env.WIKI_HOME = prevWikiHome;
    try {
      rmSync(tmpDir, { recursive: true, force: true });
    } catch {}
  });

  function createRegistryPage(
    id: string,
    type: string,
    title: string,
    content: string,
    extra: Record<string, unknown> = {},
  ) {
    const folder = id.includes("/") ? id.split("/")[0] : "concepts";
    const name = id.includes("/") ? id.split("/").pop()! : id;
    const pagePath = join(wikiDir, ".llm-wiki", "wiki", folder, `${name}.md`);
    writeFileSync(
      pagePath,
      `---\ntype: ${type}\ntitle: "${title}"\ncreated: 2026-05-11\nupdated: 2026-05-11\nsources: []\n${Object.entries(
        extra,
      )
        .map(([k, v]) => `${k}: ${v}`)
        .join("\n")}\n---\n\n${content}`,
      "utf-8",
    );
    return `${folder}/${name}`;
  }

  it("skips a now-malformed page retained in the known-good registry", () => {
    const id = createRegistryPage("good", "concept", "Good", "needle body", {
      summary: "needle body",
    });
    const paths = getVaultPaths(wikiDir);
    expect(rebuildMetadata(paths).ok).toBe(true);
    writeFileSync(join(paths.wiki, `${id}.md`), "broken\n");
    expect(searchWiki(paths, "needle", 5)).toEqual([]);
  });

  it("surfaces version diagnostics in Pi recall including no-match responses", async () => {
    const paths = getVaultPaths(wikiDir);
    writeFileSync(
      join(paths.dotWiki, "config.json"),
      JSON.stringify({ knowledge_format: "okf-0.2" }),
    );
    expect(rebuildMetadata(paths).ok).toBe(true);
    writeFileSync(join(paths.wiki, "index.md"), '---\nokf_version: "0.3"\n---\n');

    let captured:
      | {
          execute: (...args: unknown[]) => Promise<{
            content: Array<{ text: string }>;
            details: { diagnostics: Array<{ code: string }> };
          }>;
        }
      | undefined;
    const pi = {
      registerTool: (tool: typeof captured) => {
        captured = tool;
      },
    } as unknown as ExtensionAPI;
    registerWikiRecall(pi);
    if (!captured) throw new Error("wiki_recall was not registered");
    const response = await captured.execute("id", { query: "absent" }, undefined, undefined, {
      cwd: paths.root,
      hasUI: false,
    });
    expect(
      response.details.diagnostics.map((diagnostic: { code: string }) => diagnostic.code),
    ).toContain("okf_version_mismatch");
    expect(response.content[0].text).toContain("okf_version_mismatch");
  });

  it("should return empty results when wiki has no pages", () => {
    const paths = getVaultPaths(wikiDir);
    const results = searchWiki(paths, "machine learning");
    expect(results).toEqual([]);
  });

  it("should find pages matching by title", () => {
    createRegistryPage(
      "reinforcement-learning",
      "concept",
      "Reinforcement Learning",
      "RL is a type of machine learning.",
    );
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "reinforcement learning");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results[0].title).toContain("Reinforcement Learning");
    expect(results[0].id).toContain("reinforcement-learning");
    expect(results[0].type).toBe("concept");
  });

  it("should find pages matching by page ID", () => {
    createRegistryPage(
      "transformer-architecture",
      "concept",
      "Transformer",
      "The Transformer model architecture.",
    );
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "transformer-architecture");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results[0].id).toContain("transformer-architecture");
  });

  it("should find pages matching by type", () => {
    createRegistryPage("gpt-4", "entity", "GPT-4", "OpenAI language model.", { category: "tool" });
    createRegistryPage("rag", "concept", "RAG", "Retrieval augmented generation.");
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "entity");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results.some((r) => r.type === "entity")).toBe(true);
  });

  it("should find pages matching multiline aliases and recall triggers", () => {
    const pagePath = join(wikiDir, ".llm-wiki", "wiki", "analyses", "continue-learning-pi.md");
    writeFileSync(
      pagePath,
      [
        "---",
        "type: analysis",
        "title: Pi 学习入口",
        "aliases:",
        "  - 继续学习pi",
        "  - 学习pi",
        "recall_triggers:",
        "  - pi下一节",
        "created: 2026-05-27",
        "updated: 2026-05-27",
        "---",
        "",
        "# Recall 提示",
        "",
        "命中后读取 Pi 学习进度。",
      ].join("\n"),
      "utf-8",
    );

    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "继续学习pi");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results[0].id).toBe("analyses/continue-learning-pi");
  });

  it("should find pages matching body text when metadata is sparse", () => {
    createRegistryPage(
      "sparse-page",
      "concept",
      "Unrelated Title",
      "This body explains codex relay provider credential precedence.",
    );
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "credential precedence");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results[0].id).toBe("concepts/sparse-page");
  });

  it("should find mixed Chinese and Latin short queries without spaces", () => {
    createRegistryPage(
      "pi-learning-progress",
      "synthesis",
      "Pi 学习进度",
      "下一步学习 Pi 交互模式。",
    );
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "学习pi");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results.some((r) => r.id === "concepts/pi-learning-progress")).toBe(true);
  });

  it("should return pages with content preview", () => {
    createRegistryPage(
      "attention-is-all-you-need",
      "source",
      "Attention Is All You Need",
      "This paper introduced the Transformer architecture.",
    );
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "attention");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results[0].preview).toBeTruthy();
    expect(results[0].preview).toContain("Transformer");
  });

  it("should respect maxResults parameter", () => {
    for (let i = 1; i <= 5; i++) {
      createRegistryPage(
        `concept-${i}`,
        "concept",
        `Concept ${i}`,
        `This is concept ${i} about stuff.`,
      );
    }
    const paths = getVaultPaths(wikiDir);
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "concept", 3);
    expect(results.length).toBeLessThanOrEqual(3);
  });

  it("should find requirement pages by title", () => {
    const reqDir = join(wikiDir, ".llm-wiki", "wiki", "requirements");
    mkdirSync(reqDir, { recursive: true });
    writeFileSync(
      join(reqDir, "sso-login.md"),
      [
        "---",
        "type: requirement",
        'title: "SSO Login"',
        "status: active",
        "priority: p0",
        "created: 2026-05-16",
        "updated: 2026-05-16",
        "---",
        "",
        "# SSO Login",
        "",
        "Users can sign in via OAuth providers.",
      ].join("\n"),
      "utf-8",
    );
    const paths = getVaultPaths(wikiDir);
    const dotWiki = join(paths.dotWiki);
    mkdirSync(dotWiki, { recursive: true });
    writeFileSync(
      join(dotWiki, "config.json"),
      JSON.stringify({ topic: "Test", mode: "personal" }),
      "utf-8",
    );
    rebuildMetadataLight(paths);
    const results = searchWiki(paths, "SSO Login");
    expect(results.length).toBeGreaterThanOrEqual(1);
    expect(results.some((r) => r.id.startsWith("requirements/"))).toBe(true);
    expect(results.some((r) => r.title.includes("SSO Login"))).toBe(true);
  });

  it("should format recall results as system context", () => {
    const results = [
      {
        id: "concepts/rag",
        title: "RAG",
        type: "concept",
        preview: "Retrieval augmented generation is a technique.",
        path: "/tmp/wiki/concepts/rag.md",
        score: 5,
      },
      {
        id: "entities/openai",
        title: "OpenAI",
        type: "entity",
        preview: "OpenAI is an AI research organization.",
        path: "/tmp/wiki/entities/openai.md",
        score: 3,
      },
    ];
    const context = formatRecallContext(results);
    expect(context).toContain("Relevant Wiki Knowledge");
    expect(context).toContain("[[concepts/rag]]");
    expect(context).toContain("[[entities/openai]]");
    expect(context).toContain("RAG");
    expect(context).toContain("OpenAI");
    expect(context).toContain("2 page(s)");
    expect(context).toContain("wiki_ensure_page or wiki_retro");
  });

  it("should return empty string for empty results", () => {
    expect(formatRecallContext([])).toBe("");
  });

  it("should return empty results when no vault exists", () => {
    const emptyDir = join(tmpDir, "no-vault");
    mkdirSync(emptyDir, { recursive: true });
    const paths = getVaultPaths(emptyDir);
    // No config.json means no vault — search should handle gracefully
    const results = searchWiki(paths, "anything");
    expect(results).toEqual([]);
  });

  describe("layered recall (personal + project)", () => {
    it("should fall back to primary vault when no personal wiki exists", () => {
      createRegistryPage(
        "layered-test",
        "concept",
        "Layered Test",
        "Testing layered recall fallback.",
      );
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);
      // Personal wiki (~/.llm-wiki/) won't exist in test sandbox
      // So searchWikiLayered should return primary vault results only
      const results = searchWikiLayered(paths, "layered test");
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].title).toContain("Layered Test");
      // Should not have vaultLabel since personal wiki didn't contribute
      expect(results.every((r) => !r.vaultLabel)).toBe(true);
    });

    it("should tag personal vault results when personal wiki exists", () => {
      // Create primary vault page
      createRegistryPage(
        "project-concept",
        "concept",
        "Project Concept",
        "A project-specific concept.",
      );
      // Also create a personal wiki entry
      const personalPaths = getPersonalWikiPaths();
      const personalSourcesDir = join(personalPaths.wiki, "sources");
      mkdirSync(personalSourcesDir, { recursive: true });
      const personalMeta = join(personalPaths.meta);
      mkdirSync(personalMeta, { recursive: true });
      const personalDotWiki = personalPaths.dotWiki;
      mkdirSync(personalDotWiki, { recursive: true });
      writeFileSync(
        join(personalDotWiki, "config.json"),
        JSON.stringify({ topic: "Personal", mode: "personal" }),
      );
      writeFileSync(
        join(personalSourcesDir, "personal-insight.md"),
        [
          "---",
          "type: source",
          'title: "Personal Insight"',
          "slug: personal-insight",
          "status: insight",
          "created: 2026-05-21",
          "updated: 2026-05-21",
          "---",
          "",
          "# Personal Insight",
          "",
          "A personal wiki insight.",
        ].join("\n"),
      );
      rebuildMetadataLight(personalPaths);

      // Now rebuild primary vault metadata
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Search should find results from both vaults
      const results = searchWikiLayered(paths, "concept", 5);
      // Personal results should be tagged
      const personalResults = results.filter((r) => r.vaultLabel);
      if (personalResults.length > 0) {
        expect(personalResults[0].vaultLabel).toBe("📓 personal");
      }

      // Clean up personal wiki test artifacts
      try {
        rmSync(personalPaths.dotWiki, { recursive: true, force: true });
      } catch {}
    });
  });

  // ── Two-stage (links-first) recall gated by vault size (issue #68) ──
  describe("two-stage recall (links-first gated by vault size)", () => {
    const sampleResults = () => [
      {
        id: "concepts/rag",
        title: "RAG",
        type: "concept",
        preview:
          "Retrieval augmented generation is a technique for grounding LLMs in external knowledge sources.",
        path: "/tmp/wiki/concepts/rag.md",
        score: 12.5,
      },
      {
        id: "entities/openai",
        title: "OpenAI",
        type: "entity",
        preview: "OpenAI is an AI research organization.",
        path: "/tmp/wiki/entities/openai.md",
        score: 3,
        vaultLabel: "\uD83D\uDCD3 personal",
      },
    ];

    it("vaultPageCount counts only the primary registry when includePersonal=false", () => {
      createRegistryPage("a", "concept", "A", "Body a.");
      createRegistryPage("b", "concept", "B", "Body b.");
      createRegistryPage("c", "concept", "C", "Body c.");
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);
      expect(vaultPageCount(paths, false)).toBe(3);
    });

    it("shouldUseLinksFirst gates strictly-greater-than the threshold (both directions)", () => {
      // Default threshold.
      expect(shouldUseLinksFirst(DEFAULT_RECALL_LINKS_THRESHOLD)).toBe(false);
      expect(shouldUseLinksFirst(DEFAULT_RECALL_LINKS_THRESHOLD + 1)).toBe(true);
      // Custom threshold via config.
      expect(shouldUseLinksFirst(2, { recallLinksThreshold: 2 })).toBe(false);
      expect(shouldUseLinksFirst(3, { recallLinksThreshold: 2 })).toBe(true);
      // Threshold 0 → links-first for any non-empty vault, still off for empty.
      expect(shouldUseLinksFirst(0, { recallLinksThreshold: 0 })).toBe(false);
      expect(shouldUseLinksFirst(1, { recallLinksThreshold: 0 })).toBe(true);
    });

    it("small vault (default opts) renders previews inline, no scores (regression)", () => {
      const out = formatRecallContext(sampleResults());
      // Prior-release guard (NOT a self-comparison): for ordinary non-skill pages
      // the default path must render byte-for-byte as it did pre-fix — no resolvable
      // `↳ read <path>` line, the original footer copy. This is the #68 promise that
      // small vaults below the threshold see zero recall-shape regression.
      const expected = [
        "## Relevant Wiki Knowledge",
        "",
        "_2 page(s) matched your query (personal + project)._",
        "",
        "- **[[concepts/rag]]** — *concept* — RAG",
        "  Retrieval augmented generation is a technique for grounding LLMs in external knowledge sources.",
        "",
        "- **[[entities/openai]]** — *entity* — OpenAI 📓 personal",
        "  OpenAI is an AI research organization.",
        "",
        "Use `read` to view full pages. Add new findings via wiki_ensure_page or wiki_retro.",
        "",
      ].join("\n");
      expect(out).toBe(expected);
      // Belt-and-suspenders: the links-first-only affordances must not leak here.
      expect(out).not.toContain("↳");
      expect(out).not.toContain("on the exact path shown under each link");
      expect(out).not.toContain("links-first");
      expect(out).not.toContain("score ");
    });

    it("large vault (linksOnly) renders ranked links: id/title/type/score/snippet, no full preview", () => {
      const out = formatRecallContext(sampleResults(), { linksOnly: true });
      expect(out).toContain("## Relevant Wiki Knowledge (links-first)");
      // id, title, type, score for each link.
      expect(out).toContain("1. **[[concepts/rag]]** — *concept* — score 12.5");
      expect(out).toContain("2. **[[entities/openai]]** — *entity* — score 3.0");
      expect(out).toContain("RAG");
      expect(out).toContain("OpenAI");
      // Personal vault label preserved.
      expect(out).toContain("\uD83D\uDCD3 personal");
      // Snippet is truncated to one short line — the full preview must NOT appear.
      expect(out).not.toContain("external knowledge sources");
      expect(out).toContain("…");
      // Two-stage contract is spelled out.
      expect(out).toContain("Call `read`");
      expect(out).not.toContain("\n  Retrieval augmented");
    });

    it("links-first snippet flattens newlines onto a single line and truncates", () => {
      const out = formatRecallContext(
        [
          {
            id: "concepts/multi",
            title: "Multi",
            type: "concept",
            preview:
              "line one\nline two\nline three with lots of extra words to overflow the snippet budget here for sure",
            path: "/tmp/x.md",
            score: 7,
          },
        ],
        { linksOnly: true },
      );
      const linkLine = out.split("\n").find((l) => l.startsWith("1."));
      expect(linkLine).toBeDefined();
      // The multi-line preview becomes one physical line (newlines → spaces).
      expect(linkLine).toContain("line one line two line three");
      // ...and it is truncated to the snippet budget with an ellipsis.
      expect(linkLine).toContain("…");
      expect(linkLine).not.toContain("for sure");
    });

    it("config parsing clamps recallLinksThreshold to a non-negative integer", async () => {
      const { loadTaskConfig } = await import("../extensions/llm-wiki/lib/task-config.js");
      const projectDir = join(tmpDir, "cfg-threshold");
      mkdirSync(join(projectDir, ".pi"), { recursive: true });
      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallLinksThreshold: 12.9 } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallLinksThreshold).toBe(12);

      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallLinksThreshold: -5 } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallLinksThreshold).toBe(0);

      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallLinksThreshold: "nope" } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallLinksThreshold).toBeUndefined();
    });

    it("config parsing clamps recallSkillInlineMax to a non-negative integer", async () => {
      const { loadTaskConfig } = await import("../extensions/llm-wiki/lib/task-config.js");
      const projectDir = join(tmpDir, "cfg-inline-max");
      mkdirSync(join(projectDir, ".pi"), { recursive: true });
      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallSkillInlineMax: 800.9 } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallSkillInlineMax).toBe(800);

      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallSkillInlineMax: -10 } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallSkillInlineMax).toBe(0);

      writeFileSync(
        join(projectDir, ".pi", "settings.json"),
        JSON.stringify({ "llm-wiki": { recallSkillInlineMax: "nope" } }),
        "utf-8",
      );
      expect(loadTaskConfig(projectDir).recallSkillInlineMax).toBeUndefined();
    });

    it("end-to-end gating: same results below vs above a custom threshold", () => {
      createRegistryPage("alpha", "concept", "Alpha", "Body about caching alpha.");
      createRegistryPage("beta", "concept", "Beta", "Body about caching beta.");
      createRegistryPage("gamma", "concept", "Gamma", "Body about caching gamma.");
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);
      const count = vaultPageCount(paths, false);
      expect(count).toBe(3);

      // threshold 5 ≥ 3 → previews inline.
      expect(shouldUseLinksFirst(count, { recallLinksThreshold: 5 })).toBe(false);
      // threshold 2 < 3 → links-first.
      expect(shouldUseLinksFirst(count, { recallLinksThreshold: 2 })).toBe(true);

      const results = searchWiki(paths, "caching", 5);
      const inline = formatRecallContext(results, { linksOnly: false });
      const links = formatRecallContext(results, { linksOnly: true });
      expect(inline).toContain("## Relevant Wiki Knowledge\n");
      expect(inline).not.toContain("links-first");
      expect(links).toContain("links-first");
      expect(links).toContain("score ");
    });
  });

  // ── Recall-adherence rendering: resolvable read-path, salience nudge, and
  //    the skills/cases inline carve-out (commit ee16fad) ──
  describe("recall-adherence rendering (read-path + nudge + skills inline)", () => {
    const NUDGE = "read and APPLY it BEFORE experimenting";

    // Write a real skill fixture on disk so inlineSkillBody can read it; the
    // returned absolute path is what formatRecallContext renders + inlines.
    const writeSkillFixture = (name: string, content: string): string => {
      const dir = join(wikiDir, ".llm-wiki", "wiki", "skills");
      mkdirSync(dir, { recursive: true });
      const path = join(dir, `${name}.md`);
      writeFileSync(path, content, "utf-8");
      return path;
    };

    it("surfaces a resolvable `read <path>` under each link in links-first, but NOT in the default small-vault path", () => {
      const results = [
        {
          id: "concepts/rag",
          title: "RAG",
          type: "concept",
          preview: "p",
          path: "/abs/wiki/concepts/rag.md",
          score: 5,
        },
      ];
      // Links-first: there is no inline content, so every link gets a resolvable path.
      expect(formatRecallContext(results, { linksOnly: true })).toContain(
        "↳ `read /abs/wiki/concepts/rag.md`",
      );
      // Default (small-vault) mode: ordinary pages keep the pre-fix render — no read-path line.
      expect(formatRecallContext(results)).not.toContain("↳");
    });

    it("omits the read-path line in links-first when a result has no path", () => {
      const results = [
        { id: "concepts/x", title: "X", type: "concept", preview: "p", path: "", score: 5 },
      ];
      expect(formatRecallContext(results, { linksOnly: true })).not.toContain("↳ `read");
    });

    it("injects the salience nudge in both modes only when a skill/case matches", () => {
      const plain = [
        {
          id: "concepts/rag",
          title: "RAG",
          type: "concept",
          preview: "p",
          path: "/x.md",
          score: 5,
        },
      ];
      expect(formatRecallContext(plain)).not.toContain(NUDGE);
      expect(formatRecallContext(plain, { linksOnly: true })).not.toContain(NUDGE);

      // Caught by the type check (skill/case) AND by the id-prefix fallback for
      // pages whose frontmatter type was lost (recall as a generic "page").
      const skillish = [
        {
          id: "skills/debug",
          title: "Debug",
          type: "skill",
          preview: "p",
          path: "/x.md",
          score: 9,
        },
        {
          id: "cases/outage",
          title: "Outage",
          type: "case",
          preview: "p",
          path: "/x.md",
          score: 9,
        },
        { id: "skills/lost", title: "Lost", type: "page", preview: "p", path: "/x.md", score: 9 },
        { id: "cases/lost", title: "Lost", type: "page", preview: "p", path: "/x.md", score: 9 },
      ];
      for (const r of skillish) {
        expect(formatRecallContext([r])).toContain(NUDGE);
        expect(formatRecallContext([r], { linksOnly: true })).toContain(NUDGE);
      }
    });

    it("inlines a skill body (frontmatter stripped) and suppresses the one-line preview", () => {
      const path = writeSkillFixture(
        "kebab-cli",
        [
          "---",
          "type: skill",
          'title: "Kebab CLI"',
          "---",
          "",
          "# Kebab CLI",
          "",
          "Always pass --json.",
        ].join("\n"),
      );
      const results = [
        {
          id: "skills/kebab-cli",
          title: "Kebab CLI",
          type: "skill",
          preview: "ONE-LINE-PREVIEW",
          path,
          score: 9,
        },
      ];
      const out = formatRecallContext(results);
      expect(out).toContain("Always pass --json."); // body inlined
      expect(out).not.toContain("type: skill"); // frontmatter stripped
      expect(out).not.toContain("ONE-LINE-PREVIEW"); // preview suppressed when inlined
      // Even in default mode an inlined skill carries its resolvable path (the
      // body may be truncated) — this is the ONLY default-path read-path line.
      expect(out).toContain(`↳ \`read ${path}\``);
    });

    it("skillInlineMax 0 disables inlining (skill falls back to preview/link, zero page-body I/O)", () => {
      const path = writeSkillFixture(
        "disabled",
        ["---", "type: skill", "---", "", "Body that should NOT be inlined."].join("\n"),
      );
      const results = [
        {
          id: "skills/disabled",
          title: "Disabled",
          type: "skill",
          preview: "PREVIEW-FALLBACK",
          path,
          score: 9,
        },
      ];
      // Default mode, inlining off → no body, falls back to preview, no read-path line.
      const def = formatRecallContext(results, { skillInlineMax: 0 });
      expect(def).not.toContain("Body that should NOT be inlined.");
      expect(def).toContain("PREVIEW-FALLBACK");
      expect(def).not.toContain("↳");
      // Links-first, inlining off → ranked link only, no inlined body.
      const links = formatRecallContext(results, { linksOnly: true, skillInlineMax: 0 });
      expect(links).not.toContain("Body that should NOT be inlined.");
      expect(links).toContain("[[skills/disabled]]");
    });

    it("falls back to the preview when the skill file can't be read", () => {
      const results = [
        {
          id: "skills/missing",
          title: "Missing",
          type: "skill",
          preview: "FALLBACK-PREVIEW",
          path: "/does/not/exist.md",
          score: 9,
        },
      ];
      const out = formatRecallContext(results);
      expect(out).toContain("FALLBACK-PREVIEW"); // preview, not an inlined body
      expect(out).toContain(NUDGE); // still a skill → still nudged
    });

    it("truncates an over-long skill body with a marker", () => {
      const huge = "A".repeat(5000);
      const path = writeSkillFixture("huge", ["---", "type: skill", "---", "", huge].join("\n"));
      const results = [
        { id: "skills/huge", title: "Huge", type: "skill", preview: "p", path, score: 9 },
      ];
      const out = formatRecallContext(results);
      expect(out).toContain("…(truncated — `read` the path above for the full page)");
      expect(out).not.toContain(huge); // full body not dumped
    });

    it("wraps a fenced skill body in a longer outer fence so it can't terminate early", () => {
      // Skill body contains its OWN ```bash fence — the wrapper must out-length
      // it (regression for the code-fence corruption bug).
      const path = writeSkillFixture(
        "fenced",
        [
          "---",
          "type: skill",
          "---",
          "",
          "Run this:",
          "",
          "```bash",
          "kebab build --json",
          "```",
          "",
          "Done.",
        ].join("\n"),
      );
      const results = [
        { id: "skills/fenced", title: "Fenced", type: "skill", preview: "p", path, score: 9 },
      ];
      const out = formatRecallContext(results, { linksOnly: true });
      // Whole body — including the inner fence's contents and trailing prose — is present.
      expect(out).toContain("kebab build --json");
      expect(out).toContain("Done.");
      // Two wrapper fences, each strictly longer than the body's own 3-backtick
      // fence (which survives as content), prove no early termination.
      const fenceLens = out
        .split("\n")
        .filter((l) => /^\s*`{3,}\s*$/.test(l))
        .map((l) => l.trim().length);
      expect(fenceLens).toContain(3); // body's own ``` preserved as content
      expect(fenceLens.filter((n) => n >= 4).length).toBe(2); // two longer wrappers
    });

    it("strips CRLF frontmatter so no raw YAML leaks into the prompt", () => {
      const crlf = [
        "---",
        "type: skill",
        'title: "CRLF"',
        "---",
        "",
        "# CRLF Skill",
        "",
        "Body after CRLF frontmatter.",
      ].join("\r\n");
      const path = writeSkillFixture("crlf", crlf);
      const results = [
        { id: "skills/crlf", title: "CRLF", type: "skill", preview: "p", path, score: 9 },
      ];
      const out = formatRecallContext(results);
      expect(out).toContain("Body after CRLF frontmatter.");
      expect(out).not.toContain("type: skill"); // frontmatter gone despite CRLF
      expect(out).not.toContain("---"); // no raw YAML delimiters leaked
    });
  });

  describe("chunk-level indexing", () => {
    it("should match a query against a specific section, not the whole page", () => {
      // Page with multiple sections - only one about the query topic
      createRegistryPage(
        "server-setup",
        "concept",
        "Server Setup",
        [
          "This page covers server setup basics.",
          "",
          "## Postgres",
          "",
          "PostgreSQL is configured with SSL and connection pooling via PgBouncer.",
          "Connection string uses the DATABASE_URL env var.",
          "",
          "## Redis",
          "",
          "Redis is used for caching session data and rate limiting.",
          "",
          "## Nginx",
          "",
          "Nginx reverse proxies API requests and serves static assets.",
        ].join("\n"),
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Query for Postgres - should match the Postgres section specifically
      const results = searchWiki(paths, "PostgreSQL connection pooling");
      expect(results.length).toBeGreaterThanOrEqual(1);

      // The preview should show the Postgres section content, not the intro
      const result = results[0];
      expect(result.id).toBe("concepts/server-setup");
      expect(result.preview).toContain("Postgres");
      expect(result.preview).toContain("PgBouncer");
      // Should not show the intro which mentions "server setup basics"
      expect(result.preview).not.toContain("basics");
    });

    it("should match a query against the intro section", () => {
      createRegistryPage(
        "introduction",
        "concept",
        "Getting Started",
        "Welcome to the framework. This is where you begin learning about routing and middleware.",
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      const results = searchWiki(paths, "Getting Started routing");
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].preview).toContain("routing");
    });

    it("should match a deeply nested section heading", () => {
      createRegistryPage(
        "deep-page",
        "synthesis",
        "Deep Analysis",
        [
          "## Background",
          "",
          "General background.",
          "",
          "### Deep Dive",
          "",
          "The specific mechanism involves token-level processing.",
          "",
          "## Results",
          "",
          "All tests pass.",
        ].join("\n"),
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      const results = searchWiki(paths, "token-level processing");
      expect(results.length).toBeGreaterThanOrEqual(1);
      const result = results[0];
      expect(result.preview).toContain("token-level");
      expect(result.preview).toContain("Deep Dive");
    });

    it("should still find pages via metadata even when body has no match", () => {
      createRegistryPage(
        "api-reference",
        "concept",
        "API Reference",
        "Detailed API documentation with examples.",
        { aliases: "api-docs rest-api" },
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Match on alias, not body
      const results = searchWiki(paths, "rest-api");
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].id).toBe("concepts/api-reference");
    });
  });

  describe("pseudo-relevance feedback (semantic expansion)", () => {
    it("should expand query terms from top results to boost related pages", () => {
      // Two pages: one directly matching "login", one about "authentication"
      // that mentions "JWT" and "OAuth" (terms not in the original query)
      createRegistryPage(
        "login-page",
        "concept",
        "Login Page",
        [
          "## Login Form",
          "",
          "The login form accepts email and password.",
          "",
          "## Password Reset",
          "",
          "Users can reset their password via email link.",
        ].join("\n"),
      );
      createRegistryPage(
        "auth-module",
        "concept",
        "Authentication System",
        [
          "## JWT Tokens",
          "",
          "JWT tokens are used for session management and API authentication.",
          "",
          "## OAuth Integration",
          "",
          "OAuth 2.0 supports Google and GitHub login providers.",
        ].join("\n"),
      );
      createRegistryPage(
        "unrelated",
        "concept",
        "Unrelated",
        "This page is about CSS styling and color themes.",
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Query for "login" - should find the Login Page (direct match)
      // and the Authentication System page (boosted because top result
      // "Login Page" shares semantic context with "Authentication System"
      // through terms like JWT, OAuth, session, etc.)
      const results = searchWiki(paths, "login", 5);
      expect(results.length).toBeGreaterThanOrEqual(2);

      // Login Page should be first (direct title match)
      expect(results[0].title).toContain("Login Page");

      // Authentication System should be found (boosted by PRF)
      const authResult = results.find((r) => r.id === "concepts/auth-module");
      expect(authResult).toBeDefined();

      // Unrelated should either be absent or ranked below auth
      const unrelatedIndex = results.findIndex((r) => r.id === "concepts/unrelated");
      const authIndex = results.findIndex((r) => r.id === "concepts/auth-module");
      if (unrelatedIndex >= 0 && authIndex >= 0) {
        expect(authIndex).toBeLessThan(unrelatedIndex);
      }
    });

    it("should work with CJK content (no crash)", () => {
      createRegistryPage(
        "pi-learning",
        "concept",
        "Pi 学习",
        [
          "## 交互模式",
          "",
          "Pi 的交互模式包括命令行和对话两种方式。",
          "",
          "## 扩展开发",
          "",
          "通过 TypeScript 扩展可以添加自定义工具和事件处理。",
        ].join("\n"),
      );

      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Query in Chinese - should not crash and should find relevant page
      const results = searchWiki(paths, "Pi 交互模式", 5);
      expect(results.length).toBeGreaterThanOrEqual(1);
    });

    it("should handle empty wiki gracefully (no expansion crash)", () => {
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Empty wiki - no top results to expand from
      const results = searchWiki(paths, "anything");
      expect(results).toEqual([]);
    });
  });

  // ── Hybrid lexical + semantic ranking (issue #67) ──────
  describe("hybrid lexical + semantic ranking", () => {
    beforeEach(() => __clearQueryEmbeddingCache());

    /** Write a hand-built embeddings.json sidecar keyed by page id. */
    function writeSidecar(
      paths: ReturnType<typeof getVaultPaths>,
      vectors: Record<string, number[]>,
      model = "mock-embed",
    ): void {
      const entries: Record<string, unknown> = {};
      for (const [id, raw] of Object.entries(vectors)) {
        const vector = normalizeVector(raw);
        entries[id] = { hash: "h", model, dim: vector.length, vector, updated: "t" };
      }
      writeFileSync(
        embeddingStorePath(paths),
        JSON.stringify({ version: "1.0", entries }),
        "utf-8",
      );
    }

    /** Mock embedder returning a fixed raw vector, recording call count. */
    function makeFixedEmbedder(raw: number[], model = "mock-embed") {
      const calls: string[][] = [];
      const embedder: Embedder = {
        model,
        embed: async (texts) => {
          calls.push(texts);
          return texts.map(() => [...raw]);
        },
      };
      return { embedder, calls };
    }

    it("fuseScores adds a bounded, weighted, non-negative semantic boost", () => {
      // Identity on the lexical score when cosine ≤ 0 (pure-lexical preserved).
      expect(fuseScores(5, 0, 0.5)).toBe(5);
      expect(fuseScores(5, -0.9, 0.5)).toBe(5);
      // lexical + weight * SCALE * cosine
      expect(fuseScores(5, 1, 0.5)).toBeCloseTo(5 + 0.5 * SEMANTIC_SCALE * 1, 10);
      expect(fuseScores(0, 1, 1)).toBeCloseTo(SEMANTIC_SCALE, 10);
      expect(fuseScores(3, 0.5, DEFAULT_SEMANTIC_WEIGHT)).toBeCloseTo(
        3 + DEFAULT_SEMANTIC_WEIGHT * SEMANTIC_SCALE * 0.5,
        10,
      );
    });

    it("surfaces a page pure lexical misses (paraphrase recall)", () => {
      // Neither page contains the query tokens "fixing cars" → lexical = 0.
      createRegistryPage(
        "automobile-care",
        "concept",
        "Automobile Maintenance",
        "Keeping your vehicle running smoothly over the years.",
      );
      createRegistryPage(
        "css-themes",
        "concept",
        "Color Palettes",
        "Styling guidance for light and dark visual themes.",
      );
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Pure lexical: query matches nothing.
      expect(searchWiki(paths, "fixing cars")).toEqual([]);

      // Semantic: query vector close to the automobile page, orthogonal to CSS.
      writeSidecar(paths, {
        "concepts/automobile-care": [1, 0, 0, 0],
        "concepts/css-themes": [0, 0, 1, 0],
      });
      const semantic: SemanticContext = {
        queryVector: normalizeVector([0.95, 0.05, 0, 0]),
        weight: 0.5,
      };
      const results = searchWiki(paths, "fixing cars", 5, 0, semantic);

      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].id).toBe("concepts/automobile-care");
      // The near-orthogonal CSS page stays below the semantic candidacy gate.
      expect(results.some((r) => r.id === "concepts/css-themes")).toBe(false);
    });

    it("changes ranking order vs pure lexical when semantics dominate", () => {
      // "Login Page" matches the query lexically; "Session Management" does not.
      createRegistryPage(
        "login-page",
        "concept",
        "Login Page",
        "The login form accepts an email and a password.",
      );
      createRegistryPage(
        "session-mgmt",
        "concept",
        "Token Lifecycle",
        "How bearer credentials are minted, rotated, and revoked.",
      );
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // Pure lexical: login-page is the (weak, body-only) match for "form";
      // session-mgmt has no lexical overlap at all.
      const lexical = searchWiki(paths, "form", 5);
      expect(lexical[0].id).toBe("concepts/login-page");
      expect(lexical.some((r) => r.id === "concepts/session-mgmt")).toBe(false);

      // Semantic: query vector identical to session-mgmt, orthogonal to login.
      writeSidecar(paths, {
        "concepts/login-page": [0, 1, 0, 0],
        "concepts/session-mgmt": [1, 0, 0, 0],
      });
      const semantic: SemanticContext = { queryVector: normalizeVector([1, 0, 0, 0]), weight: 0.5 };
      const hybrid = searchWiki(paths, "form", 5, 0, semantic);

      // session-mgmt now outranks the lexically-matched login-page.
      expect(hybrid[0].id).toBe("concepts/session-mgmt");
      expect(hybrid.some((r) => r.id === "concepts/login-page")).toBe(true);
    });

    it("no embeddings sidecar → identical to pure lexical (regression)", () => {
      for (const [id, title] of [
        ["alpha", "Alpha Concept"],
        ["beta", "Beta Concept"],
      ] as const) {
        createRegistryPage(id, "concept", title, `Body about ${title} and shared concept text.`);
      }
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      const pure = searchWiki(paths, "concept", 5);
      // Passing a semantic context but with NO sidecar present must be a no-op.
      const semantic: SemanticContext = { queryVector: [1, 0, 0, 0], weight: 0.5 };
      const withCtxNoSidecar = searchWiki(paths, "concept", 5, 0, semantic);

      expect(withCtxNoSidecar.map((r) => [r.id, r.score])).toEqual(
        pure.map((r) => [r.id, r.score]),
      );
    });

    it("empty/missing sidecar is safe and does not crash", () => {
      createRegistryPage("lonely", "concept", "Lonely Page", "Just some content here.");
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);
      // Write an explicitly empty store.
      writeFileSync(
        embeddingStorePath(paths),
        JSON.stringify({ version: "1.0", entries: {} }),
        "utf-8",
      );
      const semantic: SemanticContext = { queryVector: [1, 0], weight: 0.5 };
      const results = searchWiki(paths, "lonely", 5, 0, semantic);
      expect(results[0].id).toBe("concepts/lonely");
    });

    it("minScore still filters: weak cosine excluded, strong cosine passes", () => {
      createRegistryPage(
        "orphan",
        "concept",
        "Unrelated Heading",
        "Content with no overlap with the search query at all.",
      );
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      // weight 0.5, SCALE 12 → boost = 6 * cosine. minScore 5.
      // cosine 0.5 → boost 3 < 5 → filtered out.
      writeSidecar(paths, { "concepts/orphan": [1, 0, 0, 0] });
      const weak: SemanticContext = {
        queryVector: normalizeVector([0.5, 0.866, 0, 0]),
        weight: 0.5,
      };
      expect(searchWiki(paths, "zzz nonmatching", 5, 5, weak)).toEqual([]);

      // cosine 1.0 → boost 6 ≥ 5 → passes.
      const strong: SemanticContext = { queryVector: normalizeVector([1, 0, 0, 0]), weight: 0.5 };
      const passed = searchWiki(paths, "zzz nonmatching", 5, 5, strong);
      expect(passed.map((r) => r.id)).toEqual(["concepts/orphan"]);
    });

    it("searchWikiHybrid skips the query embedding when no sidecar exists", async () => {
      createRegistryPage("lex-only", "concept", "Lexical Only", "A page about caching layers.");
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);

      const { embedder, calls } = makeFixedEmbedder([1, 0, 0, 0]);
      const results = await searchWikiHybrid(paths, "caching", 5, 0, false, { embedder });

      // No sidecar → no embedding call at all → pure lexical result.
      expect(calls).toHaveLength(0);
      expect(results[0].id).toBe("concepts/lex-only");
      expect(results.map((r) => [r.id, r.score])).toEqual(
        searchWiki(paths, "caching", 5).map((r) => [r.id, r.score]),
      );
    });

    it("searchWikiHybrid embeds the query once and caches it across calls", async () => {
      createRegistryPage("sem-page", "concept", "Distinct Title", "Body with unrelated wording.");
      const paths = getVaultPaths(wikiDir);
      rebuildMetadataLight(paths);
      // Sidecar vector equals the embedder's fixed output → cosine 1.0.
      writeSidecar(paths, { "concepts/sem-page": [1, 0, 0, 0] }, "mock-embed");

      const { embedder, calls } = makeFixedEmbedder([1, 0, 0, 0], "mock-embed");
      const q = "a query that lexically matches nothing";

      const r1 = await searchWikiHybrid(paths, q, 5, 0, false, { embedder });
      const r2 = await searchWikiHybrid(paths, q, 5, 0, false, { embedder });

      // Semantic-only page surfaced...
      expect(r1[0].id).toBe("concepts/sem-page");
      expect(r2[0].id).toBe("concepts/sem-page");
      // ...and the query was embedded exactly ONCE (cached on the 2nd call).
      expect(calls).toHaveLength(1);
    });
  });
});
