import test from "node:test";
import assert from "node:assert/strict";
import {
  runMemCorrectBenchmark,
  memcorrectDefinition,
  summarizeAggregateMetrics,
} from "./runner.js";
import { generateMemCorrectCorpus, corpusHash } from "./generator.js";
import { PromptOnlyBaselineAdapter } from "./adapters.js";
import {
  listBenchmarks,
  getBenchmark,
  getRegisteredBenchmark,
} from "../../../registry.js";
import { BENCHMARK_RESULT_SCHEMA } from "../../../schema.js";
import type { ResolvedRunBenchmarkOptions } from "../../../types.js";
import type { MemCorrectSystemAdapter } from "./types.js";

function options(
  overrides: Partial<ResolvedRunBenchmarkOptions> = {},
): ResolvedRunBenchmarkOptions {
  return {
    mode: "quick",
    benchmark: memcorrectDefinition,
    system: {
      describe: () => "fake",
      store: async () => undefined,
      query: async () => "",
      recall: async () => "",
      search: async () => [],
      reset: async () => undefined,
      getStats: async () => ({ totalMessages: 0, totalSummaryNodes: 0, maxDepth: 0 }),
      destroy: async () => undefined,
    },
    ...overrides,
  } as unknown as ResolvedRunBenchmarkOptions;
}

// ---------------------------------------------------------------------------
// Registry integration
// ---------------------------------------------------------------------------

test("registry: memcorrect-v1 is registered and resolvable", () => {
  const all = listBenchmarks();
  assert.ok(
    all.some((b) => b.id === "memcorrect-v1"),
    "memcorrect-v1 must appear in listBenchmarks()",
  );
  assert.equal(getBenchmark("memcorrect-v1")?.id, "memcorrect-v1");
  const registered = getRegisteredBenchmark("memcorrect-v1");
  assert.ok(registered, "memcorrect-v1 must have a registered runner");
  assert.equal(typeof registered?.run, "function");
});

test("registry: memcorrect-v1 is tier=remnic, status=ready, runnerAvailable", () => {
  assert.equal(memcorrectDefinition.tier, "remnic");
  assert.equal(memcorrectDefinition.status, "ready");
  assert.equal(memcorrectDefinition.runnerAvailable, true);
});

test('runner: benchmarkOptions.adapter "prompt-only" selects the baseline adapter', async () => {
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: "prompt-only" },
      limit: 1,
    }),
  );
  assert.equal(result.config.adapterMode, "prompt-only-baseline");
});

test('runner: benchmarkOptions.adapter "remnic" wraps the bench system adapter', async () => {
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: "remnic" },
      limit: 1,
    }),
  );
  assert.equal(result.config.adapterMode, "remnic-native");
});

test("runner: an unknown string adapter mode is rejected with the valid options listed", async () => {
  await assert.rejects(
    runMemCorrectBenchmark(
      options({
        benchmarkOptions: { adapter: "mem0ish" },
        limit: 1,
      }),
    ),
    /must be one of \["remnic", "prompt-only"\]/,
  );
});

// ---------------------------------------------------------------------------
// End-to-end with the prompt-only baseline
// ---------------------------------------------------------------------------

test("runner: baseline run emits directional scores (collateral_delta excluded; scope/reassertion conditional)", async () => {
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 4,
    }),
  );
  assert.ok(result.results.tasks.length === 4, "limit=4 → 4 tasks");
  const alwaysPresent = [
    "uptake_at_next",
    "uptake_latency",
    "uptake_latency_censored",
    "non_resurrection",
    "false_apply",
  ];
  for (const task of result.results.tasks) {
    for (const key of alwaysPresent) {
      assert.ok(key in task.scores, `task ${task.taskId} missing score ${key}`);
    }
    // collateral_delta is target-zero (not directional): must NOT appear in
    // per-task directional scores, but IS carried in the full metric bundle.
    assert.ok(!("collateral_delta" in task.scores), "collateral_delta must not pollute directional scores");
    const detail = task.details as { metrics?: { memcorrect?: { collateral_delta?: unknown } }; shape?: string };
    assert.equal(typeof detail.metrics?.memcorrect?.collateral_delta, "number", "collateral_delta in details bundle");
    // scope_precision / reassertion appear only for their applicable shapes.
    const shape = detail.shape;
    const hasScope = "scope_precision" in task.scores;
    const hasReassertion = "reassertion" in task.scores;
    assert.equal(hasScope, shape === "scoped", `scope_precision presence must match scoped shape (got ${shape})`);
    assert.equal(hasReassertion, shape === "re-assertion", `reassertion presence must match re-assertion shape (got ${shape})`);
  }
  // Aggregate headline bundle present in config.benchmarkOptions.
  const bundle = summarizeAggregateMetrics(result);
  assert.ok(bundle, "aggregate bundle must be attached");
  assert.ok("uptake_at_next" in bundle);
  assert.ok("provenance_fidelity" in bundle);
});

test("runner: baseline scores near-zero on non_resurrection-under-reingest (the structural floor)", async () => {
  // The append-everything baseline never retires anything, so re-ingesting
  // the original transcript re-surfaces the retired fact. non_resurrection
  // must collapse toward 0 — this is the sanity requirement from the issue
  // (baseline near-zero, Remnic must not).
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 6,
    }),
  );
  const bundle = summarizeAggregateMetrics(result);
  assert.ok(bundle, "bundle missing");
  assert.ok(
    bundle.non_resurrection < 0.5,
    `baseline non_resurrection should be low (re-ingest resurrects), got ${bundle.non_resurrection}`,
  );
});

test("runner: adds two specialized judge scores per scenario without replacing deterministic metrics", async () => {
  const calls: string[] = [];
  const specializedJudge = {
    async score() { return 1; },
    async judgeMemCorrectCorrectionAcceptance() {
      calls.push("correction");
      return {
        score: 0.75,
        decision: "partial" as const,
        reason: "private model explanation",
        rubricVersion: "memcorrect-correction-acceptance-v1",
        tokens: { input: 3, output: 2 },
        latencyMs: 7,
        model: "gpt-5.6",
      };
    },
    async judgeMemCorrectStaleMemoryHarm() {
      calls.push("stale");
      return {
        score: 1,
        decision: "pass" as const,
        reason: "private model explanation",
        rubricVersion: "memcorrect-stale-memory-harm-v1",
        tokens: { input: 4, output: 1 },
        latencyMs: 9,
        model: "gpt-5.6",
      };
    },
  };
  const result = await runMemCorrectBenchmark(options({
    benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
    judgeProvider: { provider: "openai", model: "gpt-5.6", rubricVersion: "openai-responses-bench-v1" },
    memCorrectJudge: specializedJudge,
    limit: 1,
  }));

  assert.deepEqual(calls, ["correction", "stale"]);
  const task = result.results.tasks[0]!;
  assert.ok("uptake_at_next" in task.scores);
  assert.equal(task.scores.judge_correction_acceptance, 0.75);
  assert.equal(task.scores.judge_stale_harm_avoidance, 1);
  assert.equal(result.results.aggregates.judge_correction_acceptance?.mean, 0.75);
  assert.deepEqual(task.tokens, { input: 7, output: 3 });
  assert.deepEqual(
    { calls: result.cost.judgeModelCalls, input: result.cost.inputTokens, output: result.cost.outputTokens },
    { calls: 2, input: 7, output: 3 },
  );
  assert.doesNotMatch(JSON.stringify(task.details), /private model explanation/);
  assert.deepEqual(
    (result.config.benchmarkOptions as any).judgeTelemetry,
    { calls: 2, inputTokens: 7, outputTokens: 3, latencyMs: 16 },
  );
});

test("runner: propagates categorized specialized judge failures", async () => {
  const refusal = Object.assign(new Error("refused"), { code: "refusal" });
  await assert.rejects(
    runMemCorrectBenchmark(options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      judgeProvider: { provider: "openai", model: "gpt-5.6" },
      memCorrectJudge: {
        async score() { return 1; },
        async judgeMemCorrectCorrectionAcceptance() { throw refusal; },
        async judgeMemCorrectStaleMemoryHarm() {
          throw new Error("must not be reached");
        },
      },
      limit: 1,
    })),
    (error: unknown) => error === refusal && (error as { code?: string }).code === "refusal",
  );
});

// ---------------------------------------------------------------------------
// Determinism: two runs, same seed, identical corpus hash + identical metrics
// ---------------------------------------------------------------------------

test("runner: identical seed → identical datasetHash and identical deterministic metrics", async () => {
  const seed = 4242;
  const r1 = await runMemCorrectBenchmark(
    options({
      seed,
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 4,
    }),
  );
  const r2 = await runMemCorrectBenchmark(
    options({
      seed,
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 4,
    }),
  );
  assert.equal(r1.meta.datasetHash, r2.meta.datasetHash);
  // Per-task deterministic metrics identical (latency is non-deterministic,
  // so compare scores only).
  for (let i = 0; i < r1.results.tasks.length; i += 1) {
    assert.deepEqual(
      r1.results.tasks[i].scores,
      r2.results.tasks[i].scores,
      `task ${i} metrics diverged`,
    );
  }
});

test("runner: datasetHash matches the standalone corpus hash for the same seed", async () => {
  const seed = 4242;
  const result = await runMemCorrectBenchmark(
    options({
      seed,
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 2,
    }),
  );
  // The runner uses QUICK_OPTIONS shape with the override seed; the corpus
  // hash is over ALL scenarios (limit only trims which run), so compare
  // against the full quick corpus.
  const corpus = generateMemCorrectCorpus({
    personaCount: 2,
    factsPerPersona: 4,
    seed,
    nowIso: "2026-07-05T00:00:00.000Z",
    maintenanceCycles: 3,
    uptakeLatencyCap: 5,
  });
  assert.equal(result.meta.datasetHash, corpusHash(corpus));
  assert.equal(
    (result.config.benchmarkOptions as Record<string, unknown>).nowIso,
    "2026-07-05T00:00:00.000Z",
  );
});

// ---------------------------------------------------------------------------
// Adapter resolution
// ---------------------------------------------------------------------------

test("runner: benchmarkOptions.adapter is used when it satisfies the MemCorrect contract", async () => {
  let resetCalls = 0;
  const custom: MemCorrectSystemAdapter = {
    label: "custom-fake",
    async reset() {
      resetCalls += 1;
    },
    async ingestTurn() {},
    async recall() {
      return [];
    },
    async correct() {},
    async runMaintenance() {},
  };
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: custom },
      limit: 2,
    }),
  );
  assert.equal(result.config.adapterMode, "custom-fake");
  assert.ok(resetCalls >= 2, `reset should run per scenario, got ${resetCalls}`);
});

// ---------------------------------------------------------------------------
// Schema round-trip
// ---------------------------------------------------------------------------

test("runner: emitted result validates against BENCHMARK_RESULT_SCHEMA", async () => {
  const result = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 3,
    }),
  );
  // The schema is a JSON-schema object; validate by shape via the schema's
  // required-key contract. (BENCHMARK_RESULT_SCHEMA is consumed by the
  // publishing pipeline; here we assert the structural invariants it pins.)
  const required: readonly string[] = BENCHMARK_RESULT_SCHEMA.required;
  for (const key of required) {
    assert.ok(key in result, `result missing required top-level key ${key}`);
  }
  assert.ok(result.meta.datasetHash?.length === 64, "datasetHash must be sha-256");
  assert.deepEqual(result.meta.seeds, [0xc077e7 & 0xffffffff]);
  assert.equal(result.meta.benchmark, "memcorrect-v1");
  assert.equal(typeof result.meta.runId, "string");
  assert.equal(typeof result.meta.gitDirty, "boolean");
  assert.equal(typeof result.meta.gitDirtyEntryCount, "number");
});

// ---------------------------------------------------------------------------
// Runtime-profile compatibility (the bench must accept a resolved profile)
// ---------------------------------------------------------------------------

test("runner: accepts a local-lab runtime profile without changing the hermetic corpus", async () => {
  const result = await runMemCorrectBenchmark(
    options({
      runtimeProfile: "local-lab",
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 2,
    }),
  );
  assert.equal(result.config.runtimeProfile, "local-lab");
  // The hermetic corpus hash is independent of the runtime profile.
  const baseline = await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 2,
    }),
  );
  assert.equal(result.meta.datasetHash, baseline.meta.datasetHash);
});

test("runner: onTaskComplete fires for each scenario with a correct total", async () => {
  const completed: Array<{ count: number; total?: number }> = [];
  await runMemCorrectBenchmark(
    options({
      benchmarkOptions: { adapter: new PromptOnlyBaselineAdapter() },
      limit: 3,
      onTaskComplete: (_task, count, total) => {
        completed.push({ count, total });
      },
    }),
  );
  assert.equal(completed.length, 3);
  assert.deepEqual(
    completed.map((c) => c.count),
    [1, 2, 3],
  );
  assert.ok(completed.every((c) => c.total === 3));
});

// ---------------------------------------------------------------------------
// Anti-event probe turn isolation (Thread 3: bump turn before recall)
// ---------------------------------------------------------------------------

test("runner: anti-event probe gets its own interaction turn (not the ingest's)", async () => {
  // When the uptake probe fails and the anti-event probe is the first recall
  // to reflect the correction, uptake_latency must measure the delta from the
  // probe's own interaction turn — not the anti-event ingest's turn. Before
  // the fix the probe shared the ingest's turn, underreporting latency by one.
  const corpus = generateMemCorrectCorpus({
    personaCount: 2,
    factsPerPersona: 4,
    seed: 4242,
    nowIso: "2026-07-05T00:00:00.000Z",
    maintenanceCycles: 3,
    uptakeLatencyCap: 5,
  });
  const scenario = corpus.scenarios[0]!;
  assert.ok(scenario.antiEvents.length > 0, "test scenario must have anti-events");
  const baselineRecalls = scenario.unrelatedProbes.length;
  let corrected = false;
  let recallIdx = 0;
  const adapter: MemCorrectSystemAdapter = {
    label: "turn-isolation-test",
    async reset() { corrected = false; recallIdx = 0; },
    async ingestTurn() {},
    async correct() { corrected = true; },
    async recall() {
      const idx = recallIdx++;
      if (!corrected) return []; // baseline probes
      // Uptake probe (idx === baselineRecalls) fails; anti-event probe
      // (idx === baselineRecalls + 1) is the first to reflect the correction.
      if (idx === baselineRecalls) return [];
      if (idx === baselineRecalls + 1) return [...scenario.correction.correctedContent];
      return [];
    },
    async runMaintenance() {},
  };
  const result = await runMemCorrectBenchmark(
    options({ benchmarkOptions: { adapter }, limit: 1, seed: 4242 }),
  );
  const bundle = summarizeAggregateMetrics(result);
  assert.ok(bundle, "bundle missing");
  // After the fix: the anti-event probe lands at correctionTurnIndex + 3
  // (one turn for the ingest, one for the probe's own interaction, plus the
  // uptake probe's turn). Before the fix it was +2 (probe shared ingest's turn).
  assert.equal(bundle.uptake_latency, 3, "anti-event probe must get its own interaction turn");
  assert.equal(bundle.uptake_latency_censored, 0, "within cap, not censored");
});
