import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { parseConfig } from "../src/config.js";
import {
  Orchestrator,
  graphPathRelativeToStorage,
  mergeGraphExpandedResults,
} from "../src/orchestrator.js";

test("mergeGraphExpandedResults deduplicates by path and keeps better score", () => {
  const primary = [
    { docid: "a", path: "/tmp/facts/a.md", snippet: "seed A", score: 0.7 },
    { docid: "b", path: "/tmp/facts/b.md", snippet: "seed B", score: 0.6 },
  ];
  const expanded = [
    { docid: "a", path: "/tmp/facts/a.md", snippet: "", score: 0.9 },
    { docid: "c", path: "/tmp/facts/c.md", snippet: "expanded C", score: 0.5 },
  ];

  const merged = mergeGraphExpandedResults(primary, expanded);
  const byPath = new Map(merged.map((m) => [m.path, m]));
  assert.equal(merged.length, 3);
  assert.equal(byPath.get("/tmp/facts/a.md")?.score, 0.9);
  assert.equal(byPath.get("/tmp/facts/a.md")?.snippet, "seed A");
  assert.equal(byPath.get("/tmp/facts/c.md")?.docid, "c");
});

test("mergeGraphExpandedResults still deduplicates when expanded list is empty", () => {
  const primary = [
    { docid: "a1", path: "/tmp/facts/a.md", snippet: "", score: 0.4 },
    { docid: "a2", path: "/tmp/facts/a.md", snippet: "seed A", score: 0.9 },
    { docid: "b1", path: "/tmp/facts/b.md", snippet: "seed B", score: 0.3 },
  ];
  const merged = mergeGraphExpandedResults(primary, []);
  assert.equal(merged.length, 2);
  const byPath = new Map(merged.map((m) => [m.path, m]));
  assert.equal(byPath.get("/tmp/facts/a.md")?.score, 0.9);
  assert.equal(byPath.get("/tmp/facts/a.md")?.snippet, "seed A");
});

test("graphPathRelativeToStorage resolves in-scope paths and rejects out-of-scope paths", () => {
  const storageDir = "/tmp/memory/default";
  assert.equal(
    graphPathRelativeToStorage(storageDir, "/tmp/memory/default/facts/2026-02-22/a.md"),
    "facts/2026-02-22/a.md",
  );
  assert.equal(
    graphPathRelativeToStorage(storageDir, "facts/2026-02-22/a.md"),
    "facts/2026-02-22/a.md",
  );
  assert.equal(graphPathRelativeToStorage(storageDir, "/tmp/memory/other/facts/a.md"), null);
});

test("recallInternal writes graph recall snapshot in graph_mode", async (t) => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
    qmdEnabled: true,
    qmdCollection: "engram-test",
    qmdMaxResults: 3,
    recallPlannerEnabled: true,
    graphRecallEnabled: true,
    multiGraphMemoryEnabled: true,
    verbatimArtifactsEnabled: false,
  });
  const orchestrator = new Orchestrator(cfg);

  const seedId = await orchestrator.storage.writeMemory("fact", "seed memory");
  const seedMemory = await orchestrator.storage.getMemoryById(seedId);
  assert.ok(seedMemory);

  const expandedId = await orchestrator.storage.writeMemory("fact", "expanded memory");
  const expandedMemory = await orchestrator.storage.getMemoryById(expandedId);
  assert.ok(expandedMemory);

  (orchestrator as any).qmd = {
    isAvailable: () => true,
    hybridSearch: async () => [
      {
        docid: seedMemory!.frontmatter.id,
        path: seedMemory!.path,
        snippet: "seed memory",
        score: 0.9,
      },
    ],
    search: async () => [],
  };
  (orchestrator as any).expandResultsViaGraph = async ({ memoryResults }: any) => ({
    merged: [
      ...memoryResults,
      {
        docid: expandedMemory!.frontmatter.id,
        path: expandedMemory!.path,
        snippet: "expanded memory",
        score: 0.8,
      },
    ],
    seedPaths: [seedMemory!.path],
    expandedPaths: [{ path: expandedMemory!.path, score: 0.8, namespace: "default", seed: seedMemory!.path, hopDepth: 1, decayedWeight: 0.7, graphType: "entity" }],
  });

  const out = await (orchestrator as any).recallInternal(
    "what happened in the timeline last week",
    "session-graph",
  );
  assert.match(out, /Relevant Memories/);

  let raw: string;
  try {
    raw = await readFile(path.join(memoryDir, "state", "last_graph_recall.json"), "utf-8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      t.skip("this branch does not yet persist last_graph_recall.json during recallInternal");
      return;
    }
    throw error;
  }
  const snapshot = JSON.parse(raw) as {
    mode: string;
    seedCount: number;
    expandedCount: number;
  };
  assert.equal(snapshot.mode, "graph_mode");
  assert.equal(snapshot.seedCount, 1);
  assert.equal(snapshot.expandedCount, 1);
});

test("recallInternal labels absolute entity graph results as reconstructed entities", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-entity-label-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
    qmdEnabled: true,
    qmdCollection: "engram-test",
    qmdMaxResults: 3,
    recallPlannerEnabled: true,
    graphRecallEnabled: true,
    multiGraphMemoryEnabled: true,
    verbatimArtifactsEnabled: false,
  });
  const orchestrator = new Orchestrator(cfg);

  const seedId = await orchestrator.storage.writeMemory("fact", "seed memory");
  const seedMemory = await orchestrator.storage.getMemoryById(seedId);
  assert.ok(seedMemory);

  const entitySlug = await orchestrator.storage.writeEntity("Alex", "person", ["Owns the roadmap."]);
  assert.ok(entitySlug);
  const entityPath = path.join(memoryDir, "entities", `${entitySlug}.md`);

  (orchestrator as any).qmd = {
    isAvailable: () => true,
    hybridSearch: async () => [
      {
        docid: seedMemory!.frontmatter.id,
        path: seedMemory!.path,
        snippet: "seed memory",
        score: 0.9,
      },
    ],
    search: async () => [],
  };
  (orchestrator as any).expandResultsViaGraph = async ({ memoryResults }: any) => ({
    merged: [
      ...memoryResults,
      {
        docid: entitySlug,
        path: entityPath,
        snippet: "Alex owns the roadmap.",
        score: 0.8,
      },
    ],
    seedPaths: [seedMemory!.path],
    expandedPaths: [
      {
        path: entityPath,
        score: 0.8,
        namespace: "default",
        seed: seedMemory!.path,
        hopDepth: 1,
        decayedWeight: 0.7,
        graphType: "entity",
      },
    ],
  });

  await (orchestrator as any).recallInternal(
    "what happened in the timeline last week",
    "session-graph-entity",
  );

  const snapshot = JSON.parse(
    await readFile(path.join(memoryDir, "state", "last_graph_recall.json"), "utf-8"),
  ) as {
    finalResults?: Array<{ path: string; sourceLabels: string[] }>;
  };
  const entityResult = snapshot.finalResults?.find((result) => result.path === entityPath);
  assert.ok(entityResult);
  assert.deepEqual(entityResult.sourceLabels, ["graph_expanded", "reconstructed_entity"]);
});

test("recallInternal runs bounded graph assist in full mode when enabled", async (t) => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-assist-full-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
    qmdEnabled: true,
    qmdCollection: "engram-test",
    qmdMaxResults: 3,
    recallPlannerEnabled: true,
    graphRecallEnabled: true,
    multiGraphMemoryEnabled: true,
    graphAssistInFullModeEnabled: true,
    graphAssistMinSeedResults: 1,
    verbatimArtifactsEnabled: false,
  });
  const orchestrator = new Orchestrator(cfg);

  const seedId = await orchestrator.storage.writeMemory("fact", "seed memory for full mode assist");
  const seedMemory = await orchestrator.storage.getMemoryById(seedId);
  assert.ok(seedMemory);

  const expandedId = await orchestrator.storage.writeMemory("fact", "expanded memory for full mode assist");
  const expandedMemory = await orchestrator.storage.getMemoryById(expandedId);
  assert.ok(expandedMemory);

  (orchestrator as any).qmd = {
    isAvailable: () => true,
    hybridSearch: async () => [
      {
        docid: seedMemory!.frontmatter.id,
        path: seedMemory!.path,
        snippet: "seed memory for full mode assist",
        score: 0.9,
      },
    ],
    search: async () => [],
  };
  (orchestrator as any).expandResultsViaGraph = async ({ memoryResults }: any) => ({
    merged: [
      ...memoryResults,
      {
        docid: expandedMemory!.frontmatter.id,
        path: expandedMemory!.path,
        snippet: "expanded memory for full mode assist",
        score: 0.8,
      },
    ],
    seedPaths: [seedMemory!.path],
    expandedPaths: [{ path: expandedMemory!.path, score: 0.8, namespace: "default", seed: seedMemory!.path, hopDepth: 1, decayedWeight: 0.7, graphType: "entity" }],
  });

  const out = await (orchestrator as any).recallInternal(
    "Summarize our latest engram status.",
    "session-graph-full-assist",
  );
  assert.match(out, /Relevant Memories/);

  let raw: string;
  try {
    raw = await readFile(path.join(memoryDir, "state", "last_graph_recall.json"), "utf-8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      t.skip("this branch does not yet persist last_graph_recall.json for full-mode graph assist");
      return;
    }
    throw error;
  }
  const snapshot = JSON.parse(raw) as {
    mode: string;
    seedCount: number;
    expandedCount: number;
  };
  assert.equal(snapshot.mode, "full");
  assert.equal(snapshot.seedCount, 1);
  assert.equal(snapshot.expandedCount, 1);
});

test("getLastGraphRecallSnapshot reads persisted snapshot", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-read-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
  });
  const orchestrator = new Orchestrator(cfg);
  await mkdir(path.join(memoryDir, "state"), { recursive: true });
  await writeFile(
    path.join(memoryDir, "state", "last_graph_recall.json"),
    JSON.stringify(
      {
        recordedAt: "2026-02-22T00:00:00.000Z",
        mode: "graph_mode",
        queryHash: "abc123",
        queryLength: 42,
        namespaces: ["default"],
        seedCount: 1,
        expandedCount: 1,
        seeds: ["/tmp/memory/default/facts/a.md"],
        expanded: [{ path: "/tmp/memory/default/facts/b.md", score: 0.7, namespace: "default", seed: "/tmp/memory/default/facts/a.md", hopDepth: 1, decayedWeight: 0.7, graphType: "entity" }],
      },
      null,
      2,
    ),
    "utf-8",
  );

  const snapshot = await orchestrator.getLastGraphRecallSnapshot();
  assert.ok(snapshot);
  assert.equal(snapshot!.mode, "graph_mode");
  assert.equal(snapshot!.seedCount, 1);
  assert.equal(snapshot!.expandedCount, 1);
  assert.equal(snapshot!.expanded[0]?.namespace, "default");
});

test("getLastGraphRecallSnapshot preserves richer fallback and ranking fields when present", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-rich-read-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
  });
  const orchestrator = new Orchestrator(cfg);
  await mkdir(path.join(memoryDir, "state"), { recursive: true });
  await writeFile(
    path.join(memoryDir, "state", "last_graph_recall.json"),
    JSON.stringify(
      {
        recordedAt: "2026-02-22T00:00:00.000Z",
        mode: "full",
        queryHash: "abc123",
        queryLength: 42,
        namespaces: ["default"],
        seedCount: 1,
        expandedCount: 0,
        seeds: ["/tmp/memory/default/facts/a.md"],
        expanded: [],
        status: "skipped",
        reason: "graph assist skipped because no eligible expansion edges were found",
        finalResults: [
          {
            path: "/tmp/memory/default/facts/a.md",
            score: 0.91,
            sourceLabels: ["seed"],
          },
        ],
      },
      null,
      2,
    ),
    "utf-8",
  );

  const snapshot = (await orchestrator.getLastGraphRecallSnapshot()) as
    | ({
        status?: string;
        reason?: string;
        finalResults?: Array<{ path: string; score: number; sourceLabels: string[] }>;
      } & Record<string, unknown>)
    | null;
  assert.ok(snapshot);
  assert.equal(snapshot.status, "skipped");
  assert.equal(snapshot.reason, "graph assist skipped because no eligible expansion edges were found");
  assert.equal(snapshot.finalResults?.length, 1);
  assert.equal(snapshot.finalResults?.[0]?.path, "/tmp/memory/default/facts/a.md");
  assert.equal(snapshot.finalResults?.[0]?.score, 0.91);
  assert.deepEqual(snapshot.finalResults?.[0]?.sourceLabels, ["seed"]);
});

test("explainLastGraphRecall returns human-readable graph explanation", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-explain-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
  });
  const orchestrator = new Orchestrator(cfg);
  await mkdir(path.join(memoryDir, "state"), { recursive: true });
  await writeFile(
    path.join(memoryDir, "state", "last_graph_recall.json"),
    JSON.stringify(
      {
        recordedAt: "2026-02-22T00:00:00.000Z",
        mode: "graph_mode",
        queryHash: "abc123",
        queryLength: 42,
        namespaces: ["default"],
        seedCount: 1,
        expandedCount: 2,
        seeds: ["/tmp/memory/default/facts/a.md"],
        expanded: [
          { path: "/tmp/memory/default/facts/b.md", score: 0.7, namespace: "default", seed: "/tmp/memory/default/facts/a.md", hopDepth: 1, decayedWeight: 0.7, graphType: "entity" },
          { path: "/tmp/memory/default/facts/c.md", score: 0.6, namespace: "default", seed: "/tmp/memory/default/facts/a.md", hopDepth: 2, decayedWeight: 0.49, graphType: "time" },
        ],
      },
      null,
      2,
    ),
    "utf-8",
  );

  const explanation = await orchestrator.explainLastGraphRecall({ maxExpanded: 1 });
  assert.match(explanation, /Last Graph Recall/);
  assert.match(explanation, /Mode: graph_mode/);
  assert.match(explanation, /showing 1/);
  assert.match(explanation, /seed=.*hop=.*type=/);
});

test("explainLastGraphRecall tolerates richer fallback snapshots and surfaces them when supported", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-graph-recall-fallback-explain-"));
  const cfg = parseConfig({
    openaiApiKey: "sk-test",
    memoryDir,
    workspaceDir: path.join(memoryDir, "workspace"),
  });
  const orchestrator = new Orchestrator(cfg);
  await mkdir(path.join(memoryDir, "state"), { recursive: true });
  await writeFile(
    path.join(memoryDir, "state", "last_graph_recall.json"),
    JSON.stringify(
      {
        recordedAt: "2026-02-22T00:00:00.000Z",
        mode: "full",
        queryHash: "abc123",
        queryLength: 42,
        namespaces: ["default"],
        seedCount: 1,
        expandedCount: 0,
        seeds: ["/tmp/memory/default/facts/a.md"],
        expanded: [],
        status: "skipped",
        reason: "graph recall skipped after planner downgrade",
        finalResults: [
          {
            path: "/tmp/memory/default/facts/a.md",
            score: 0.91,
            sourceLabels: ["seed"],
          },
        ],
      },
      null,
      2,
    ),
    "utf-8",
  );

  const explanation = await orchestrator.explainLastGraphRecall({ maxExpanded: 5 });
  assert.match(explanation, /Last Graph Recall/);
  assert.match(explanation, /Mode: full/);
  if (explanation.includes("fallback")) {
    assert.match(explanation, /fallback/i);
    assert.match(explanation, /planner downgrade/i);
  }
  if (explanation.includes("final")) {
    assert.match(explanation, /final/i);
    assert.match(explanation, /seed/i);
  }
});
