import test from "node:test";
import assert from "node:assert/strict";
import { SchemaCache, type SchemaFetcher } from "../schema-cache.js";

function makeFetcher(
  labels: string[],
  rels: string[],
  opts: { failOnce?: boolean } = {},
): SchemaFetcher & { calls: number } {
  let called = 0;
  let currentLabels = labels;
  let currentRels = rels;
  return {
    get calls() {
      return called;
    },
    async labels() {
      called++;
      if (opts.failOnce && called === 1) throw new Error("transient");
      return currentLabels;
    },
    async relationshipTypes() {
      return currentRels;
    },
  } as SchemaFetcher & { calls: number };
}

test("start() populates the snapshot and sets ready()", async () => {
  const fetcher = makeFetcher(["Conversation", "Message"], ["PART_OF"]);
  const emitted: string[] = [];
  const cache = new SchemaCache(fetcher, {
    refreshIntervalMs: 0,
    emit: (l) => emitted.push(l),
  });
  assert.equal(cache.ready(), false);
  await cache.start();
  assert.equal(cache.ready(), true);
  const snap = cache.snapshot();
  assert.ok(snap.labels.has("Conversation"));
  assert.ok(snap.labels.has("Message"));
  assert.ok(snap.relationshipTypes.has("PART_OF"));
  assert.ok(emitted.some((l) => l.includes("[schema-cache] refresh")));
  cache.stop();
});

test("start() with Neo4j unreachable leaves cache empty and not ready, but does not throw", async () => {
  const fetcher: SchemaFetcher = {
    async labels() {
      throw new Error("ECONNREFUSED");
    },
    async relationshipTypes() {
      throw new Error("ECONNREFUSED");
    },
  };
  const emitted: string[] = [];
  const cache = new SchemaCache(fetcher, {
    refreshIntervalMs: 0,
    emit: (l) => emitted.push(l),
  });
  await cache.start();
  assert.equal(cache.ready(), false);
  assert.equal(cache.snapshot().labels.size, 0);
  assert.ok(
    emitted.some((l) => l.includes("[schema-cache]") && l.includes("failure")),
    "expected a failure log line",
  );
  cache.stop();
});

test("refresh() picks up a newly-added relationship type", async () => {
  let rels = ["PART_OF"];
  const fetcher: SchemaFetcher = {
    async labels() {
      return ["Conversation"];
    },
    async relationshipTypes() {
      return rels;
    },
  };
  const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0 });
  await cache.start();
  assert.equal(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE"), false);
  rels = ["PART_OF", "MIGRATED_EDGE"];
  await cache.refresh("interval");
  assert.ok(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE"));
  cache.stop();
});

test("refresh() preserves last good cache on transient failure", async () => {
  let fail = false;
  const fetcher: SchemaFetcher = {
    async labels() {
      if (fail) throw new Error("transient");
      return ["Conversation"];
    },
    async relationshipTypes() {
      if (fail) throw new Error("transient");
      return ["PART_OF"];
    },
  };
  const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0 });
  await cache.start();
  assert.equal(cache.ready(), true);
  fail = true;
  const ok = await cache.refresh("interval");
  assert.equal(ok, false);
  assert.equal(cache.ready(), true, "should remain ready after a transient failure");
  assert.ok(cache.snapshot().labels.has("Conversation"));
  cache.stop();
});

test("maybeRebuildOnStaleMiss() triggers refresh when an unknown token has a near match", async () => {
  let rels = ["PART_OF"];
  const fetcher: SchemaFetcher = {
    async labels() {
      return ["Conversation"];
    },
    async relationshipTypes() {
      return rels;
    },
  };
  const cache = new SchemaCache(fetcher, {
    refreshIntervalMs: 0,
    staleMissDebounceMs: 0,
  });
  await cache.start();
  rels = ["PART_OF", "PART_OFF"];
  const triggered = await cache.maybeRebuildOnStaleMiss([
    { token: "PART_OFF", kind: "relationship", nearest: ["PART_OF"], hint: "" },
  ]);
  assert.equal(triggered, true);
  assert.ok(cache.snapshot().relationshipTypes.has("PART_OFF"));
  cache.stop();
});

test("maybeRebuildOnStaleMiss() skips when no near match (likely typo, not migration)", async () => {
  const fetcher = makeFetcher(["Conversation"], ["PART_OF"]);
  const cache = new SchemaCache(fetcher, {
    refreshIntervalMs: 0,
    staleMissDebounceMs: 0,
  });
  await cache.start();
  const callsBefore = fetcher.calls;
  const triggered = await cache.maybeRebuildOnStaleMiss([
    { token: "COMPLETELY_DIFFERENT", kind: "relationship", nearest: ["PART_OF"], hint: "" },
  ]);
  assert.equal(triggered, false);
  assert.equal(fetcher.calls, callsBefore, "no extra fetches when edit distance is far");
  cache.stop();
});

test("maybeRebuildOnStaleMiss() debounces repeated triggers", async () => {
  const fetcher = makeFetcher(["Conversation"], ["PART_OF"]);
  const cache = new SchemaCache(fetcher, {
    refreshIntervalMs: 0,
    staleMissDebounceMs: 5000,
  });
  await cache.start();
  const callsBefore = fetcher.calls;
  const unknown = [
    { token: "PART_OFF", kind: "relationship" as const, nearest: ["PART_OF"], hint: "" },
  ];
  await cache.maybeRebuildOnStaleMiss(unknown);
  const afterFirst = fetcher.calls;
  await cache.maybeRebuildOnStaleMiss(unknown);
  assert.equal(fetcher.calls, afterFirst, "second call within debounce should not refetch");
  assert.ok(afterFirst > callsBefore, "first call did refetch");
  cache.stop();
});
