import { describe, expect, it, vi } from "vitest";
import { redisTest } from "@internal/testcontainers";
import { MollifierBuffer } from "@trigger.dev/redis-worker";

vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));

import {
  runStaleSweepOnce,
  startStaleSweepInterval,
} from "~/v3/mollifier/mollifierStaleSweep.server";
import { MollifierStaleSweepState } from "~/v3/mollifier/mollifierStaleSweepState.server";

const SNAPSHOT = {
  taskIdentifier: "hello-world",
  payload: '{"x":1}',
  payloadType: "application/json",
  traceContext: {},
};

// In-memory fake state for unit tests that don't have a Redis container.
// The testcontainer tests use a real MollifierStaleSweepState against
// the test Redis instead.
function makeFakeState() {
  let cursor = 0;
  let orgList: string[] = [];
  const counts = new Map<string, number>();
  let visited = new Set<string>();
  return {
    readCursor: async () => cursor,
    writeCursor: async (v: number) => {
      cursor = v;
    },
    rebuildOrgList: async (orgs: string[]) => {
      orgList = [...orgs];
    },
    readOrgListSlice: async (start: number, count: number) => ({
      orgs: orgList.slice(start, start + count),
      total: orgList.length,
    }),
    setEnvStaleCount: async (envId: string, count: number) => {
      if (count > 0) counts.set(envId, count);
      else counts.delete(envId);
    },
    readAllEnvStaleCounts: async () => new Map(counts),
    markEnvVisited: async (envId: string) => {
      visited.add(envId);
    },
    reconcileVisited: async () => {
      for (const envId of [...counts.keys()]) {
        if (!visited.has(envId)) counts.delete(envId);
      }
      visited = new Set();
    },
    clearAll: async () => {
      cursor = 0;
      orgList = [];
      counts.clear();
      visited = new Set();
    },
    close: async () => {},
  };
}

function spyDeps() {
  // Counter ticks — metric carries no `envId` label (high-cardinality)
  // so the spy is a simple call count. Per-env detail lives on the
  // structured warn log and the snapshot map.
  let staleEntryCount = 0;
  const snapshots: Array<Map<string, number>> = [];
  const warnings: Array<{ message: string; fields: Record<string, unknown> }> = [];
  return {
    get staleEntryCount() {
      return staleEntryCount;
    },
    snapshots,
    warnings,
    deps: {
      recordStaleEntry: () => {
        staleEntryCount += 1;
      },
      reportStaleEntrySnapshot: (snapshot: Map<string, number>) => {
        // Clone so post-sweep assertions see what was reported *at that
        // call site*, not whatever subsequent passes mutate the source
        // map into.
        snapshots.push(new Map(snapshot));
      },
      logger: {
        warn: (message: string, fields: Record<string, unknown>) => {
          warnings.push({ message, fields });
        },
      },
    },
  };
}

describe("runStaleSweepOnce — unit", () => {
  it("returns zeros when the buffer is null", async () => {
    // Mirrors the prod gate: if TRIGGER_MOLLIFIER_ENABLED=0 the buffer
    // singleton is null and the sweep is a no-op. We don't want it to
    // emit a metric (or throw) just because mollifier is disabled.
    const spies = spyDeps();
    const result = await runStaleSweepOnce(
      { staleThresholdMs: 1000 },
      { ...spies.deps, getBuffer: () => null, state: makeFakeState() }
    );
    expect(result).toEqual({
      orgsScanned: 0,
      envsScanned: 0,
      entriesScanned: 0,
      staleCount: 0,
    });
    expect(spies.staleEntryCount).toBe(0);
    expect(spies.warnings).toEqual([]);
    const snapshots = spies.snapshots;
    // An empty snapshot is still reported so any previously-paging env
    // (from a prior sweep before mollifier was disabled) clears.
    expect(snapshots).toHaveLength(1);
    expect(snapshots[0].size).toBe(0);
  });

  it("surfaces readOrgListSlice failures and leaves durable state untouched", async () => {
    // Regression: previously a Redis read failure inside
    // `readOrgListSlice` returned `{ orgs: [], total: 0 }` and the
    // sweep treated that as a clean empty cycle — writing cursor=0,
    // reconciling visited envs against the empty result, and CLEARING
    // the stale-entry gauge. That silenced the very alerts the sweep
    // exists to raise. The fix re-throws; the caller (this function
    // and the interval wrapper above it) must NOT mutate cursor or
    // counts when readOrgListSlice fails.
    const state = makeFakeState();
    // Seed durable state so we can assert it isn't touched on failure.
    await state.writeCursor(42);
    await state.setEnvStaleCount("env_seed", 7);
    await state.rebuildOrgList(["org_pre"]);
    // Inject a failure on the very next slice read.
    const readErr = new Error("Redis read failed");
    let readAttempts = 0;
    const failingState = {
      ...state,
      readOrgListSlice: async (start: number, count: number) => {
        readAttempts += 1;
        throw readErr;
      },
    };
    const spies = spyDeps();
    const buffer = {
      listOrgs: async () => ["org_pre"],
      listEnvsForOrg: async () => [],
      listEntriesForEnv: async () => [],
    } as unknown as MollifierBuffer;

    await expect(
      runStaleSweepOnce(
        { staleThresholdMs: 60_000, maxOrgsPerPass: 10 },
        {
          ...spies.deps,
          state: failingState,
          getBuffer: () => buffer,
          now: () => Date.now(),
        }
      )
    ).rejects.toThrow("Redis read failed");

    expect(readAttempts).toBe(1);
    // Cursor untouched (still the seeded 42, not reset to 0).
    expect(await state.readCursor()).toBe(42);
    // Counts hash untouched — the seeded env's count survives the
    // failed cycle so the gauge keeps reporting its last-known value.
    const counts = await state.readAllEnvStaleCounts();
    expect(counts.get("env_seed")).toBe(7);
    // No snapshot was reported because the function threw before
    // reaching reportStaleEntrySnapshot.
    expect(spies.snapshots).toHaveLength(0);
    expect(spies.staleEntryCount).toBe(0);
  });
});

describe("runStaleSweepOnce — testcontainers", () => {
  redisTest(
    "flags every entry whose dwell exceeds the stale threshold",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const buffer = new MollifierBuffer({ redisOptions });
      try {
        // Three entries across two envs in the same org. The sweep below
        // runs against a `now` advanced by 5 minutes, so all three have
        // dwell ~5min and ALL THREE are stale against a 1-minute
        // threshold — there is no "fresh" entry in this scenario. The
        // assertions below pin the all-three-stale shape.
        await buffer.accept({
          runId: "run_stale_a",
          envId: "env_a",
          orgId: "org_1",
          payload: JSON.stringify(SNAPSHOT),
        });
        await buffer.accept({
          runId: "run_stale_b",
          envId: "env_b",
          orgId: "org_1",
          payload: JSON.stringify(SNAPSHOT),
        });
        await buffer.accept({
          runId: "run_stale_c",
          envId: "env_a",
          orgId: "org_1",
          payload: JSON.stringify(SNAPSHOT),
        });
        // Yank the system clock forward 5 minutes for the sweep — way
        // past the threshold below. The `now` deps seam lets us drive
        // the threshold without actually waiting in real time.
        const futureNow = Date.now() + 5 * 60 * 1000;

        const spies = spyDeps();
        const state = new MollifierStaleSweepState({ redisOptions });
        try {
          const result = await runStaleSweepOnce(
            { staleThresholdMs: 60 * 1000 },
            {
              ...spies.deps,
              getBuffer: () => buffer,
              state,
              now: () => futureNow,
            }
          );

          expect(result.envsScanned).toBe(2);
          expect(result.entriesScanned).toBe(3);
          expect(result.staleCount).toBe(3);
          // All three entries exceed the threshold; each emits one
          // counter tick + one warning.
          expect(spies.staleEntryCount).toBe(3);
          expect(spies.warnings).toHaveLength(3);
          for (const w of spies.warnings) {
            expect(w.message).toBe("mollifier.stale_entry");
            expect(w.fields.staleThresholdMs).toBe(60 * 1000);
            expect(w.fields.dwellMs).toBeGreaterThan(60 * 1000);
          }
          // Snapshot drives the alertable gauge — env_a has 2 stale
          // entries, env_b has 1. Per-env detail is still passed to
          // `reportStaleEntrySnapshot` for forensic value even though the
          // gauge itself aggregates the total.
          expect(spies.snapshots).toHaveLength(1);
          expect(Object.fromEntries(spies.snapshots[0])).toEqual({
            env_a: 2,
            env_b: 1,
          });
        } finally {
          await state.close();
        }
      } finally {
        await buffer.close();
      }
    }
  );

  redisTest(
    "snapshot omits envs that have entries but none stale (durable hash HDEL's zeros)",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      // Critical for alert behaviour: a previous sweep flagged env_a
      // stale, alert fired, drainer caught up. The next sweep must
      // remove env_a from the durable counts hash so the gauge drops
      // below the alert threshold instead of staying latched at the
      // last stale value. With the sharded design the snapshot is
      // sourced from the HASH directly — visiting an env with zero
      // stale entries HDEL's it, so it's simply absent from the
      // snapshot (telemetry sums values, so absence is equivalent to
      // zero for the gauge).
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_just_arrived",
          envId: "env_a",
          orgId: "org_1",
          payload: JSON.stringify(SNAPSHOT),
        });
        const spies = spyDeps();
        await runStaleSweepOnce(
          { staleThresholdMs: 60 * 1000 },
          { ...spies.deps, getBuffer: () => buffer, state }
        );
        expect(spies.snapshots).toHaveLength(1);
        // env_a has entries but none stale → not in the snapshot.
        expect(spies.snapshots[0].has("env_a")).toBe(false);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "leaves fresh entries alone (dwell below threshold)",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      // Regression guard for the inequality direction. A bug that flipped
      // `dwellMs > threshold` to `dwellMs >= threshold` would flag every
      // entry the first time the sweep runs after a perfectly synchronised
      // accept call — the dashboard would page on every burst.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_fresh_only",
          envId: "env_a",
          orgId: "org_1",
          payload: JSON.stringify(SNAPSHOT),
        });
        const spies = spyDeps();
        const result = await runStaleSweepOnce(
          { staleThresholdMs: 60 * 1000 },
          { ...spies.deps, getBuffer: () => buffer, state }
        );
        expect(result.staleCount).toBe(0);
        expect(spies.staleEntryCount).toBe(0);
        expect(spies.warnings).toEqual([]);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "shards work across ticks: cursor advances by maxOrgsPerPass and wraps after a full cycle",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // Without sharding the sweep walks every org/env every tick — at
      // any meaningful backlog that runs longer than the tick interval
      // and the next tick gets dropped by the inFlight guard. Sharding
      // splits the work: each tick visits at most `maxOrgsPerPass` orgs,
      // advances a durable cursor, and resumes from there next tick.
      // Over `ceil(N / cap)` ticks the cycle covers every org.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        for (let i = 0; i < 5; i++) {
          await buffer.accept({
            runId: `run_shard_${i}`,
            envId: `env_shard_${i}`,
            orgId: `org_shard_${i}`,
            payload: JSON.stringify(SNAPSHOT),
          });
        }
        const futureNow = Date.now() + 5 * 60 * 1000;
        const spies = spyDeps();
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 2 };
        const baseDeps = {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        };

        // Tick 1: cursor starts at 0, scans 2 orgs.
        const r1 = await runStaleSweepOnce(cfg, baseDeps);
        expect(r1.orgsScanned).toBe(2);
        expect(spies.snapshots[0].size).toBe(2);

        // Tick 2: cursor was 2, scans 2 more orgs.
        const r2 = await runStaleSweepOnce(cfg, baseDeps);
        expect(r2.orgsScanned).toBe(2);
        // Snapshot is the durable HASH — accumulates across ticks.
        expect(spies.snapshots[1].size).toBe(4);

        // Tick 3: cursor was 4, scans the last 1 org and wraps to 0.
        const r3 = await runStaleSweepOnce(cfg, baseDeps);
        expect(r3.orgsScanned).toBe(1);
        expect(spies.snapshots[2].size).toBe(5);

        // Tick 4: cycle complete, cursor is back at 0 — starts over.
        const r4 = await runStaleSweepOnce(cfg, baseDeps);
        expect(r4.orgsScanned).toBe(2);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "clears an env from the durable snapshot on revisit when it has entries but none currently stale",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // Stale state in the durable hash must be HDEL'd, not just left
      // stale, when a previously-flagged env no longer has any entries
      // whose dwell exceeds the threshold (drainer caught up, alert
      // condition cleared). The same `entry` flips from stale to
      // not-stale between two sweep ticks by varying the sweep's `now`
      // — tick 1 uses a future clock so the entry is flagged stale;
      // tick 2 uses real time so the same entry has near-zero dwell and
      // is no longer stale. The env stays in the active set throughout
      // (queue still has an entry), so the cursor revisits it and the
      // hash field is cleared.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_drain",
          envId: "env_drain",
          orgId: "org_drain",
          payload: JSON.stringify(SNAPSHOT),
        });
        const futureNow = Date.now() + 5 * 60 * 1000;
        const spies = spyDeps();
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 10 };

        // Tick 1 with future clock: entry's dwell is 5min vs 1min
        // threshold → flagged stale.
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        });
        expect(spies.snapshots[0].get("env_drain")).toBe(1);

        // Tick 2 with real time: same entry, but its dwell is now ~ms
        // vs the same 1min threshold → not stale. The env is revisited
        // (cursor wrapped to 0 after tick 1, only 1 org in the list),
        // setEnvStaleCount called with 0 → HDEL.
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
        });
        expect(spies.snapshots[1].has("env_drain")).toBe(false);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "evicts fully-drained envs from the counts hash at cycle wrap (no permanent alert)",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // Devin's BUG report on PR #3754: an env that drains completely
      // between sweep ticks disappears from `mollifier:org-envs:${orgId}`
      // entirely, so the inner loop at runStaleSweepOnce never visits it
      // and `setEnvStaleCount(envId, 0)` (which HDELs the field) is
      // never called. The counts hash retains the env's last-known
      // stale count forever, the gauge stays elevated, and the
      // recommended alert `> 0 for 5m` fires indefinitely.
      //
      // Fix: at cycle wrap (cursor returned to 0) HDEL any env in the
      // counts hash that wasn't visited during the just-completed cycle.
      // Verified here by:
      //   1. Flagging env_will_drain stale, confirming it's in the hash
      //   2. Draining its only entry — now invisible to listEnvsForOrg
      //   3. Running a sweep tick that triggers cycle wrap
      //   4. Asserting the env is no longer in the snapshot
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_will_drain",
          envId: "env_will_drain",
          orgId: "org_will_drain",
          payload: JSON.stringify(SNAPSHOT),
        });
        const futureNow = Date.now() + 5 * 60 * 1000;
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 10 };
        const spies = spyDeps();

        // Tick 1: env_will_drain is flagged stale → enters counts hash.
        // Cursor wraps to 0 (only 1 org in the list).
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        });
        expect(spies.snapshots[0].get("env_will_drain")).toBe(1);

        // Drain the only entry. mollifier:queue:env_will_drain is now
        // empty, and the buffer's atomic Lua removes env_will_drain
        // from `mollifier:org-envs:org_will_drain` (and removes the org
        // from `mollifier:orgs` since it has no other envs). The env is
        // now invisible to listEnvsForOrg.
        const popped = await buffer.pop("env_will_drain");
        expect(popped?.runId).toBe("run_will_drain");

        // Tick 2: cursor was 0 after tick 1's wrap, so this rebuilds
        // the org list (now empty) and immediately wraps again. The
        // wrap-handler must HDEL env_will_drain from the counts hash
        // because it wasn't in the visited set for this cycle.
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        });
        expect(spies.snapshots[1].has("env_will_drain")).toBe(false);
        // And the durable hash is genuinely empty, not just absent from
        // this snapshot.
        expect((await state.readAllEnvStaleCounts()).size).toBe(0);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest("scans across multiple orgs", { timeout: 20_000 }, async ({ redisOptions }) => {
    // The drainer pops with org-level fairness, so the sweep must
    // walk every org/env to surface stale entries across all of them
    // — not just stop at the first env it finds. If a future refactor
    // collapsed listOrgs/listEnvsForOrg into a single env-flat list,
    // this test catches a regression there.
    const buffer = new MollifierBuffer({ redisOptions });
    const state = new MollifierStaleSweepState({ redisOptions });
    try {
      await buffer.accept({
        runId: "run_x",
        envId: "env_x",
        orgId: "org_x",
        payload: JSON.stringify(SNAPSHOT),
      });
      await buffer.accept({
        runId: "run_y",
        envId: "env_y",
        orgId: "org_y",
        payload: JSON.stringify(SNAPSHOT),
      });
      const futureNow = Date.now() + 5 * 60 * 1000;
      const spies = spyDeps();
      const result = await runStaleSweepOnce(
        { staleThresholdMs: 60 * 1000 },
        { ...spies.deps, getBuffer: () => buffer, state, now: () => futureNow }
      );
      expect(result.orgsScanned).toBe(2);
      expect(result.envsScanned).toBe(2);
      expect(result.staleCount).toBe(2);
    } finally {
      await state.close();
      await buffer.close();
    }
  });

  redisTest(
    "state survives process restart: a second state instance picks up the cursor and counts",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // This is the headline reason the sweep state is durable in Redis
      // instead of process-local — a webapp restart mid-cycle must not
      // re-emit the gauge as fresh-zero for previously-flagged envs nor
      // restart the cursor walk from scratch. Simulated here by closing
      // state1 (its Redis client quits cleanly) and constructing state2
      // against the same Redis. The cursor + counts that state1 wrote
      // are visible to state2 on its first tick.
      const buffer = new MollifierBuffer({ redisOptions });
      const state1 = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_a",
          envId: "env_a",
          orgId: "org_a",
          payload: JSON.stringify(SNAPSHOT),
        });
        await buffer.accept({
          runId: "run_b",
          envId: "env_b",
          orgId: "org_b",
          payload: JSON.stringify(SNAPSHOT),
        });
        const futureNow = Date.now() + 5 * 60 * 1000;
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 1 };
        const spies1 = spyDeps();

        // Tick 1 with state1: visits 1 of 2 orgs.
        await runStaleSweepOnce(cfg, {
          ...spies1.deps,
          getBuffer: () => buffer,
          state: state1,
          now: () => futureNow,
        });
        expect(spies1.snapshots[0].size).toBe(1);
      } finally {
        // Simulate webapp restart: state1's Redis client closes cleanly.
        await state1.close();
      }

      // New process boots, constructs a fresh state pointing at the
      // same Redis. The cycle's frozen org_list, the cursor, and the
      // counts hash are all preserved — state2 picks up at the second
      // org of the cycle.
      const state2 = new MollifierStaleSweepState({ redisOptions });
      try {
        const futureNow = Date.now() + 5 * 60 * 1000;
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 1 };
        const spies2 = spyDeps();

        await runStaleSweepOnce(cfg, {
          ...spies2.deps,
          getBuffer: () => buffer,
          state: state2,
          now: () => futureNow,
        });
        // Snapshot now has BOTH envs: the one tick 1 flagged (still in
        // the counts hash from state1) plus the one tick 2 just flagged.
        // A non-durable design would show only the second.
        expect(spies2.snapshots[0].size).toBe(2);
      } finally {
        await state2.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "cycle wrap rebuilds the org list, so orgs that joined mid-cycle get visited on the next cycle",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // The docstring promises "orgs joining mid-cycle wait until the
      // next cycle to be visited." The mechanism is rebuildOrgList at
      // cursor=0: a fresh snapshot of buffer.listOrgs() replaces the
      // previous frozen LIST. Verified here by adding a third org
      // between cycles and asserting it shows up only in the next
      // cycle's snapshot.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_init_a",
          envId: "env_init_a",
          orgId: "org_init_a",
          payload: JSON.stringify(SNAPSHOT),
        });
        await buffer.accept({
          runId: "run_init_b",
          envId: "env_init_b",
          orgId: "org_init_b",
          payload: JSON.stringify(SNAPSHOT),
        });
        const futureNow = Date.now() + 5 * 60 * 1000;
        const spies = spyDeps();
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 10 };
        const baseDeps = {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        };

        // Tick 1: cycle 1. Visits both initial orgs; cursor wraps to 0.
        await runStaleSweepOnce(cfg, baseDeps);
        expect(spies.snapshots[0].size).toBe(2);

        // Mid-flight: a third org joins the buffer. It must NOT have
        // been part of cycle 1's frozen LIST.
        await buffer.accept({
          runId: "run_mid",
          envId: "env_mid",
          orgId: "org_mid",
          payload: JSON.stringify(SNAPSHOT),
        });

        // Tick 2: cycle 2 begins (cursor was 0 after tick 1's wrap).
        // rebuildOrgList captures all 3 orgs; this tick visits all 3.
        const r2 = await runStaleSweepOnce(cfg, baseDeps);
        expect(r2.orgsScanned).toBe(3);
        expect(spies.snapshots[1].size).toBe(3);
        expect(spies.snapshots[1].has("env_mid")).toBe(true);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "empty buffer (no orgs) advances cleanly with zero work and an empty snapshot",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // `mollifier:orgs` is empty (no entries ever accepted, or every
      // entry has been drained). The sweep must handle the boundary:
      // rebuildOrgList with [], readOrgListSlice returns total=0,
      // the org loop is skipped, and the cursor stays at 0 instead of
      // tripping the wrap math.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        const spies = spyDeps();
        const result = await runStaleSweepOnce(
          { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 10 },
          { ...spies.deps, getBuffer: () => buffer, state }
        );
        expect(result).toEqual({
          orgsScanned: 0,
          envsScanned: 0,
          entriesScanned: 0,
          staleCount: 0,
        });
        expect(spies.snapshots).toHaveLength(1);
        expect(spies.snapshots[0].size).toBe(0);
        // Cursor stayed at 0 — nothing to advance through.
        expect(await state.readCursor()).toBe(0);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );

  redisTest(
    "buffer-null branch wipes the durable state so a re-enable starts fresh",
    { timeout: 30_000 },
    async ({ redisOptions }) => {
      // The unit test above asserts the snapshot is empty when the
      // buffer is null, but doesn't verify the durable state was
      // actually cleared. Without clearAll the next re-enable would
      // resume on a stale cursor + carry over a stale counts hash.
      const buffer = new MollifierBuffer({ redisOptions });
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await buffer.accept({
          runId: "run_seed",
          envId: "env_seed",
          orgId: "org_seed",
          payload: JSON.stringify(SNAPSHOT),
        });
        const futureNow = Date.now() + 5 * 60 * 1000;
        const cfg = { staleThresholdMs: 60 * 1000, maxOrgsPerPass: 10 };
        const spies = spyDeps();

        // Tick 1: populate state.
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => buffer,
          state,
          now: () => futureNow,
        });
        expect(spies.snapshots[0].size).toBe(1);
        expect((await state.readAllEnvStaleCounts()).size).toBe(1);

        // Tick 2: mollifier flips OFF — getBuffer returns null. The
        // sweep must clear the durable state.
        await runStaleSweepOnce(cfg, {
          ...spies.deps,
          getBuffer: () => null,
          state,
        });
        expect(spies.snapshots[1].size).toBe(0);
        expect((await state.readAllEnvStaleCounts()).size).toBe(0);
        expect(await state.readCursor()).toBe(0);
      } finally {
        await state.close();
        await buffer.close();
      }
    }
  );
});

describe("MollifierStaleSweepState — direct unit tests", () => {
  redisTest(
    "readCursor returns 0 when the key is absent",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        expect(await state.readCursor()).toBe(0);
      } finally {
        await state.close();
      }
    }
  );

  redisTest(
    "writeCursor + readCursor round-trip; readCursor parses a non-numeric value as 0",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await state.writeCursor(42);
        expect(await state.readCursor()).toBe(42);

        // Defensive: a corrupted/garbage value must not throw or
        // propagate NaN into the sweep's cursor arithmetic.
        await state["redis"].set("mollifier:stale_sweep:cursor", "not-a-number");
        expect(await state.readCursor()).toBe(0);
      } finally {
        await state.close();
      }
    }
  );

  redisTest(
    "rebuildOrgList replaces the previous list (DEL + RPUSH, in order)",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await state.rebuildOrgList(["org_a", "org_b", "org_c"]);
        let slice = await state.readOrgListSlice(0, 10);
        expect(slice.total).toBe(3);
        expect(slice.orgs).toEqual(["org_a", "org_b", "org_c"]);

        // Replacement, not append.
        await state.rebuildOrgList(["org_x"]);
        slice = await state.readOrgListSlice(0, 10);
        expect(slice.total).toBe(1);
        expect(slice.orgs).toEqual(["org_x"]);

        // Empty rebuild leaves the list empty (DEL fires, no RPUSH).
        await state.rebuildOrgList([]);
        slice = await state.readOrgListSlice(0, 10);
        expect(slice.total).toBe(0);
        expect(slice.orgs).toEqual([]);
      } finally {
        await state.close();
      }
    }
  );

  redisTest(
    "setEnvStaleCount HSETs when count > 0 and HDELs when count === 0",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await state.setEnvStaleCount("env_a", 3);
        await state.setEnvStaleCount("env_b", 1);
        let counts = await state.readAllEnvStaleCounts();
        expect(Object.fromEntries(counts)).toEqual({ env_a: 3, env_b: 1 });

        // Zero clears the field (HDEL), not stores 0.
        await state.setEnvStaleCount("env_a", 0);
        counts = await state.readAllEnvStaleCounts();
        expect(Object.fromEntries(counts)).toEqual({ env_b: 1 });
        expect(counts.has("env_a")).toBe(false);
      } finally {
        await state.close();
      }
    }
  );

  redisTest(
    "clearAll DELs cursor, org_list, and counts in one call",
    { timeout: 20_000 },
    async ({ redisOptions }) => {
      const state = new MollifierStaleSweepState({ redisOptions });
      try {
        await state.writeCursor(7);
        await state.rebuildOrgList(["org_a", "org_b"]);
        await state.setEnvStaleCount("env_a", 5);

        await state.clearAll();

        expect(await state.readCursor()).toBe(0);
        expect((await state.readOrgListSlice(0, 10)).total).toBe(0);
        expect((await state.readAllEnvStaleCounts()).size).toBe(0);
      } finally {
        await state.close();
      }
    }
  );
});

describe("startStaleSweepInterval — lifecycle", () => {
  it("stop() waits for an in-flight tick to finish before closing the state", async () => {
    // Devin's BUG report on PR #3754: `stop()` previously called
    // `deps.state.close()` immediately after `clearInterval`, but the
    // `tick` function only checks `stopped` at entry. A tick that was
    // already past that check would keep making `state.*` Redis calls
    // against a now-closed ioredis client, throw, get caught by tick's
    // own try/catch, and log a `mollifier.stale_sweep.failed` warning
    // for every graceful shutdown.
    //
    // The fix tracks the current tick promise so `stop()` can await it
    // before closing. This test pins that order by gating one of the
    // tick's state calls on a Deferred — until we resolve it, the tick
    // can't progress, and `stop()` must hang in the meantime.
    let resolveGate: () => void = () => {};
    const gate = new Promise<void>((r) => {
      resolveGate = r;
    });

    const callOrder: string[] = [];
    let closeCalled = false;
    const state = {
      readCursor: async () => {
        callOrder.push("readCursor:start");
        await gate;
        callOrder.push("readCursor:end");
        return 0;
      },
      writeCursor: async () => {
        callOrder.push("writeCursor");
      },
      rebuildOrgList: async () => {
        callOrder.push("rebuildOrgList");
      },
      readOrgListSlice: async () => {
        callOrder.push("readOrgListSlice");
        // Return zero orgs so the org loop is a no-op — we only care
        // about ordering of state calls vs close, not the work.
        return { orgs: [] as string[], total: 0 };
      },
      setEnvStaleCount: async () => {
        callOrder.push("setEnvStaleCount");
      },
      readAllEnvStaleCounts: async () => {
        callOrder.push("readAllEnvStaleCounts");
        return new Map<string, number>();
      },
      markEnvVisited: async () => {
        callOrder.push("markEnvVisited");
      },
      reconcileVisited: async () => {
        callOrder.push("reconcileVisited");
      },
      clearAll: async () => {
        callOrder.push("clearAll");
      },
      close: async () => {
        callOrder.push("close");
        closeCalled = true;
      },
    };

    const fakeBuffer = {
      listOrgs: async () => [],
      listEnvsForOrg: async () => [],
      listEntriesForEnv: async () => [],
    } as any;

    const handle = startStaleSweepInterval(
      {
        intervalMs: 20,
        staleThresholdMs: 60_000,
        maxOrgsPerPass: 10,
      },
      {
        state,
        getBuffer: () => fakeBuffer,
        recordStaleEntry: () => {},
        reportStaleEntrySnapshot: () => {},
        logger: { warn: () => {} },
        now: () => Date.now(),
      }
    );

    // Wait for the interval to fire one tick. The tick will start, call
    // readCursor, and then block on `gate`.
    await new Promise((r) => setTimeout(r, 80));
    expect(callOrder).toContain("readCursor:start");
    expect(closeCalled).toBe(false);

    // Call stop() concurrently — its promise MUST NOT resolve while the
    // tick is still mid-flight.
    let stopResolved = false;
    const stopPromise = handle.stop().then(() => {
      stopResolved = true;
    });
    await new Promise((r) => setTimeout(r, 50));
    expect(stopResolved).toBe(false);
    expect(closeCalled).toBe(false);

    // Release the gate. The tick can now finish, and only then should
    // stop() resolve and close the state.
    resolveGate();
    await stopPromise;
    expect(stopResolved).toBe(true);
    expect(closeCalled).toBe(true);

    // The tick's readCursor:end MUST appear before the close — otherwise
    // we closed the Redis client out from under an in-flight tick.
    expect(callOrder.indexOf("readCursor:end")).toBeGreaterThan(-1);
    expect(callOrder.indexOf("close")).toBeGreaterThan(callOrder.indexOf("readCursor:end"));
  });
});
