/**
 * NIBOi (codex P2): a consolidation pass that performs ONLY memory-item actions
 * (UPDATE / MERGE / INVALIDATE) — and produces no profile/entity updates — still
 * durably rewrites memory state, so it must refresh the namespace catalog's
 * `lastWriteAt`. Without the fix the touch gate only checked profile/entity
 * updates, leaving the default namespace's write recency stale after
 * consolidation-only mutations.
 */
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { parseConfig } from "../src/config.js";
import { Orchestrator } from "../src/orchestrator.js";

test("consolidation with only an INVALIDATE memory-item action records a catalog write touch", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-consolidate-catalog-"));
  const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "remnic-consolidate-ws-"));
  try {
    const config = parseConfig({
      openaiApiKey: "sk-test",
      memoryDir,
      workspaceDir,
      qmdEnabled: false,
      topicExtractionEnabled: false,
      summarizationEnabled: false,
      identityEnabled: false,
      entitySummaryEnabled: false,
      namespacesEnabled: true,
      namespaceCatalogEnabled: true,
    });

    const orchestrator = new Orchestrator(config) as any;
    const storage = orchestrator.storage;

    // runConsolidation only runs with >= 5 memories. Seed a corpus, then have the
    // pass INVALIDATE one — a durable mutation with NO profile/entity updates.
    const ids: string[] = [];
    for (let i = 0; i < 5; i += 1) {
      ids.push(await storage.writeMemory("fact", `seed fact ${i}`, { source: "test" }));
    }
    const staleId = ids[0];
    orchestrator.extraction = {
      consolidate: async () => ({
        items: [{ action: "INVALIDATE", existingId: staleId }],
        profileUpdates: [],
        entityUpdates: [],
      }),
    };

    // Establish the default namespace in the catalog so we can observe its
    // lastWriteAt advance (vs. an absent record).
    await orchestrator.namespaceCatalog.registerConfiguredNamespaces();
    const before = await orchestrator.namespaceCatalog.getNamespaceRecord(config.defaultNamespace);
    const beforeWriteAt = before?.lastWriteAt;

    await orchestrator.runConsolidationNow();

    // The catalog touch is best-effort/fire-and-forget; let its serialized write
    // chain settle, then read back the record.
    await new Promise((resolve) => setTimeout(resolve, 50));
    const after = await orchestrator.namespaceCatalog.getNamespaceRecord(config.defaultNamespace);

    assert.ok(after, "default namespace record must exist after consolidation");
    assert.ok(
      after!.lastWriteAt,
      "consolidation-only memory-item mutation must record a catalog write touch",
    );
    if (beforeWriteAt) {
      assert.ok(
        new Date(after!.lastWriteAt!).getTime() >= new Date(beforeWriteAt).getTime(),
        "lastWriteAt must advance (or hold) after a consolidation memory-item mutation",
      );
    }
  } finally {
    await rm(memoryDir, { recursive: true, force: true });
    await rm(workspaceDir, { recursive: true, force: true });
  }
});

// NIjwl (codex P2): a consolidation pass with NO LLM outputs (empty items /
// profile / entity) can still mutate the namespace via cleanup maintenance —
// e.g. cleanExpiredTTL deleting an expired memory. Those cleanup-only mutations
// run after the LLM-action block, so the catalog touch must be recorded after all
// mutation-producing maintenance steps, or lastWriteAt stays stale.
test("cleanup-only consolidation (TTL expiry, no LLM outputs) records a catalog write touch", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-consolidate-cleanup-"));
  const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "remnic-consolidate-cleanup-ws-"));
  try {
    const config = parseConfig({
      openaiApiKey: "sk-test",
      memoryDir,
      workspaceDir,
      qmdEnabled: false,
      topicExtractionEnabled: false,
      summarizationEnabled: false,
      identityEnabled: false,
      entitySummaryEnabled: false,
      semanticConsolidationEnabled: false,
      factArchivalEnabled: false,
      lifecyclePolicyEnabled: false,
      namespacesEnabled: true,
      namespaceCatalogEnabled: true,
    });

    const orchestrator = new Orchestrator(config) as any;
    const storage = orchestrator.storage;

    // Seed 5 memories (the consolidation floor); one is already TTL-expired so the
    // cleanup step deletes it — a durable namespace mutation with NO LLM outputs.
    for (let i = 0; i < 4; i += 1) {
      await storage.writeMemory("fact", `keeper fact ${i}`, { source: "test" });
    }
    await storage.writeMemory("fact", "expired speculative fact", {
      source: "test",
      expiresAt: new Date(Date.now() - 60_000).toISOString(),
    });

    // No LLM outputs at all — only cleanup will mutate the namespace.
    orchestrator.extraction = {
      consolidate: async () => ({ items: [], profileUpdates: [], entityUpdates: [] }),
    };

    await orchestrator.namespaceCatalog.registerConfiguredNamespaces();
    await orchestrator.runConsolidationNow();
    await new Promise((resolve) => setTimeout(resolve, 50));

    const record = await orchestrator.namespaceCatalog.getNamespaceRecord(config.defaultNamespace);
    assert.ok(record, "default namespace record must exist after cleanup-only consolidation");
    assert.ok(
      record!.lastWriteAt,
      "a cleanup-only consolidation (TTL expiry) must record a catalog write touch",
    );
  } finally {
    await rm(memoryDir, { recursive: true, force: true });
    await rm(workspaceDir, { recursive: true, force: true });
  }
});

test("summarization records the catalog write touch after source memories are archived", async () => {
  const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-summary-touch-order-"));
  try {
    const config = parseConfig({
      openaiApiKey: "sk-test",
      memoryDir,
      workspaceDir: path.join(memoryDir, "workspace"),
      namespacesEnabled: true,
      namespaceCatalogEnabled: true,
      summarizationTriggerCount: 50,
      summarizationRecentToKeep: 0,
      summarizationProtectedTags: [],
      summarizationImportanceThreshold: 1,
    });

    const orchestrator = Object.create(Orchestrator.prototype) as any;
    const events: string[] = [];
    orchestrator.config = config;
    const storage: {
      dir: string;
      onCatalogWrite?: () => void;
      writeSummary: () => Promise<void>;
      archiveMemories: () => Promise<number>;
    } = {
      dir: memoryDir,
      writeSummary: async () => {
        events.push("summary");
        storage.onCatalogWrite?.();
      },
      archiveMemories: async () => {
        events.push("archive");
        storage.onCatalogWrite?.();
        return 50;
      },
    };
    orchestrator.storage = storage;
    orchestrator.extraction = {
      summarizeMemories: async () => ({
        summaryText: "compressed memory summary",
        keyFacts: ["fact"],
        keyEntities: ["entity"],
      }),
    };
    orchestrator.storageDirNamespace = () => config.defaultNamespace;
    // #1522: the catalog touch now fires at the storage chokepoint via the
    // StorageManager's onCatalogWrite hook. Simulate that on the mock storage
    // so the test verifies the touch fires during the storage writes.
    storage.onCatalogWrite = () => {
      events.push("touch");
    };

    const memories = Array.from({ length: 50 }, (_, index) => ({
      path: path.join(memoryDir, `memory-${index}.md`),
      content: `summarizable fact ${index}`,
      frontmatter: {
        id: `memory-${index}`,
        category: "fact",
        created: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
        tags: [],
        status: "active",
        importance: { score: 0.1, level: "low", reasons: [], keywords: [] },
      },
    }));

    await orchestrator.runSummarization(memories);

    // #1522: with the storage chokepoint, each write fires its own catalog
    // touch. The touch from archiveMemories fires at the end of that operation
    // (after "archive"), preserving the original intent: the catalog
    // lastWriteAt touch covers the archived-state write.
    const lastArchiveIndex = events.lastIndexOf("archive");
    const lastTouchAfterArchive = events.indexOf("touch", lastArchiveIndex);
    assert.notEqual(lastArchiveIndex, -1, "precondition: archiveMemories ran");
    assert.notEqual(
      lastTouchAfterArchive,
      -1,
      `catalog touch must fire during/after archiveMemories; saw ${events.join(" -> ")}`,
    );
  } finally {
    await rm(memoryDir, { recursive: true, force: true });
  }
});
