import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CostGuard, GuardExceededError, TimeGuard, guardFromJSON, isGuardExceededError, } from "./guard.js";
import { StateStack } from "./state/stateStack.js";
describe("GuardExceededError", () => {
    it("is an Error subclass with name, type, limit, spent fields", () => {
        const err = new GuardExceededError("cost", 2.0, 2.5);
        expect(err).toBeInstanceOf(Error);
        expect(err).toBeInstanceOf(GuardExceededError);
        expect(err.name).toBe("GuardExceededError");
        expect(err.type).toBe("cost");
        expect(err.limit).toBe(2.0);
        expect(err.spent).toBe(2.5);
        expect(err.message).toContain("cost");
        expect(err.message).toContain("2");
    });
    it("supports the time variant", () => {
        const err = new GuardExceededError("time", 1000, 1234);
        expect(err.type).toBe("time");
        expect(err.limit).toBe(1000);
        expect(err.spent).toBe(1234);
    });
});
describe("isGuardExceededError", () => {
    it("returns true for a GuardExceededError instance", () => {
        expect(isGuardExceededError(new GuardExceededError("cost", 1, 2))).toBe(true);
    });
    it("returns false for a plain Error", () => {
        expect(isGuardExceededError(new Error("oops"))).toBe(false);
    });
    it("returns false for non-error values", () => {
        expect(isGuardExceededError(null)).toBe(false);
        expect(isGuardExceededError(undefined)).toBe(false);
        expect(isGuardExceededError("error")).toBe(false);
        expect(isGuardExceededError({})).toBe(false);
    });
});
describe("CostGuard", () => {
    it("tracks its own spent counter via charge()", () => {
        const stack = new StateStack();
        const g = new CostGuard(2.0);
        g.install(stack);
        expect(g.check(stack)).toBeNull();
        g.charge(1.9);
        expect(g.check(stack)).toBeNull(); // 1.9 ≤ 2.0
        g.charge(0.2);
        const err = g.check(stack);
        expect(err).toBeInstanceOf(GuardExceededError);
        expect(err.type).toBe("cost");
        expect(err.limit).toBe(2.0);
        expect(err.spent).toBeCloseTo(2.1, 5);
    });
    it("check is independent of stack.localCost", () => {
        // Cost guard no longer derives spent from stack.localCost — the
        // counter is its own field. This is what lets the same guard
        // instance be shared across parent + child branches.
        const stack = new StateStack();
        stack.localCost = 999;
        const g = new CostGuard(1.0);
        g.install(stack);
        expect(g.check(stack)).toBeNull(); // localCost ignored
        g.charge(2);
        expect(g.check(stack).spent).toBeCloseTo(2, 5);
    });
    it("cloneForBranch returns the same instance (shared reference)", () => {
        const parent = new StateStack();
        const g = new CostGuard(5);
        g.install(parent);
        const child = new StateStack();
        const cloned = g.cloneForBranch(parent, child);
        expect(cloned).toBe(g); // same JS object — not a clone
        // Charging through either reference mutates the same counter.
        cloned.charge(3);
        expect(g.check(parent)).toBeNull();
        g.charge(3);
        expect(cloned.check(child).spent).toBeCloseTo(6, 5); // 3 + 3
    });
    it("round-trips through JSON via spent counter", () => {
        const g = new CostGuard(3.0);
        g.install(new StateStack());
        g.charge(1.5);
        const json = g.toJSON();
        expect(json).toEqual({ kind: "cost", costLimit: 3.0, spent: 1.5 });
        const restored = guardFromJSON(json);
        expect(restored).toBeInstanceOf(CostGuard);
        restored.charge(2);
        expect(restored.check(new StateStack()).spent).toBeCloseTo(3.5, 5);
    });
    it("fromJSON throws on the legacy {costAtPush} shape", () => {
        // Clean breaking change: pre-shared-cost-guards checkpoints used
        // {costLimit, costAtPush} (no `spent`). Restoring them silently
        // would set spent=undefined → check() compares undefined > limit
        // → false → guard never trips. Fail loudly instead.
        expect(() => CostGuard.fromJSON({ costLimit: 2.0, costAtPush: 0.5 })).toThrow(/spent/);
    });
});
describe("TimeGuard", () => {
    beforeEach(() => {
        vi.useFakeTimers();
    });
    afterEach(() => {
        vi.useRealTimers();
    });
    it("install composes its AbortController into stack.abortSignal", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        expect(stack.abortSignal).toBeDefined();
        expect(stack.abortSignal.aborted).toBe(false);
    });
    it("uninstall restores the previous abort signal", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        g.uninstall(stack);
        expect(stack.abortSignal).toBeUndefined();
    });
    it("composes with an existing stack.abortSignal", () => {
        const stack = new StateStack();
        const outerCtl = new AbortController();
        stack.abortSignal = outerCtl.signal;
        const g = new TimeGuard(1000);
        g.install(stack);
        const composed = stack.abortSignal;
        expect(composed).not.toBe(outerCtl.signal); // composed signal
        outerCtl.abort();
        expect(composed.aborted).toBe(true);
    });
    it("trips after timeLimit ms and check() returns the error", () => {
        const stack = new StateStack();
        const g = new TimeGuard(500);
        g.install(stack);
        expect(g.check(stack)).toBeNull();
        vi.advanceTimersByTime(500);
        expect(stack.abortSignal.aborted).toBe(true);
        const err = g.check(stack);
        expect(err).toBeInstanceOf(GuardExceededError);
        expect(err.type).toBe("time");
        expect(err.limit).toBe(500);
    });
    it("pause is idempotent — multiple calls charge elapsedMs once", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        vi.advanceTimersByTime(200);
        g.pause();
        g.pause();
        g.pause();
        const json = g.toJSON();
        expect(json.elapsedMs).toBeCloseTo(200, 0);
    });
    it("resume is idempotent — multiple calls arm timer once", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        vi.advanceTimersByTime(200);
        g.pause();
        g.resume(stack);
        g.resume(stack); // second call is a no-op
        g.resume(stack);
        vi.advanceTimersByTime(800); // total compute time now 1000
        expect(stack.abortSignal.aborted).toBe(true);
    });
    it("pause/resume cycle preserves remaining budget", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        vi.advanceTimersByTime(300);
        g.pause();
        // Time passes while paused — does NOT count.
        vi.advanceTimersByTime(10000);
        g.resume(stack);
        vi.advanceTimersByTime(699);
        expect(stack.abortSignal.aborted).toBe(false);
        vi.advanceTimersByTime(1);
        expect(stack.abortSignal.aborted).toBe(true);
    });
    it("toJSON during a live window charges the in-flight delta", () => {
        const stack = new StateStack();
        const g = new TimeGuard(1000);
        g.install(stack);
        vi.advanceTimersByTime(123);
        const json = g.toJSON();
        expect(json.elapsedMs).toBeCloseTo(123, 0);
    });
    it("fromJSON round-trips elapsedMs and timeLimit; starts paused", () => {
        const json = {
            kind: "time",
            timeLimit: 1000,
            elapsedMs: 400,
        };
        const restored = guardFromJSON(json);
        expect(restored).toBeInstanceOf(TimeGuard);
        // Resume should re-arm with 600ms remaining.
        const stack = new StateStack();
        restored.resume(stack);
        vi.advanceTimersByTime(599);
        expect(stack.abortSignal.aborted).toBe(false);
        vi.advanceTimersByTime(1);
        expect(stack.abortSignal.aborted).toBe(true);
    });
    it("cloneForBranch returns undefined", () => {
        const parent = new StateStack();
        const child = new StateStack();
        const g = new TimeGuard(1000);
        g.install(parent);
        expect(g.cloneForBranch(parent, child)).toBeUndefined();
    });
    it("check() charges in-flight window so spent reflects elapsed time", () => {
        // Without inFlight-charging, a tripped guard whose check is called
        // mid-window would report spent=0 because no pause has happened.
        // Assert the real elapsed (~250) — a bug that returned timeLimit
        // instead of elapsedMs + inFlight would slip past `>= 100`.
        const stack = new StateStack();
        const g = new TimeGuard(100);
        g.install(stack);
        vi.advanceTimersByTime(250);
        const err = g.check(stack);
        expect(err.spent).toBeGreaterThan(200);
        expect(err.spent).toBeLessThan(300);
    });
    it("check() consumes the trip — subsequent calls return null", () => {
        // The stdlib `guard`'s `try` catches the first trip and translates
        // it to a Failure. The next runner step would re-trip on the same
        // still-aborted signal without consumption.
        const stack = new StateStack();
        const g = new TimeGuard(100);
        g.install(stack);
        vi.advanceTimersByTime(150);
        expect(g.check(stack)).toBeInstanceOf(GuardExceededError);
        expect(g.check(stack)).toBeNull();
        expect(g.check(stack)).toBeNull();
    });
    it("isTripped() stays true after check consumes the trip", () => {
        // Runner.shouldSkip uses isTripped() to distinguish a still-aborted
        // signal owned by an already-consumed guard (don't silent-halt
        // cleanup steps) from a race-loser branch cancel.
        const stack = new StateStack();
        const g = new TimeGuard(100);
        g.install(stack);
        expect(g.isTripped()).toBe(false);
        vi.advanceTimersByTime(150);
        expect(g.isTripped()).toBe(true);
        g.check(stack); // consume
        expect(g.isTripped()).toBe(true);
    });
});
describe("guardFromJSON", () => {
    it("throws on an unknown kind rather than silently returning undefined", () => {
        expect(() => guardFromJSON({ kind: "depth" })).toThrow();
    });
});
describe("CostGuard.isTripped", () => {
    it("always returns false — cost guards don't mutate abortSignal", () => {
        const stack = new StateStack();
        const g = new CostGuard(1);
        g.install(stack);
        expect(g.isTripped()).toBe(false);
        g.charge(100); // way over limit
        expect(g.check(stack)).toBeInstanceOf(GuardExceededError);
        // Even with a tripped check, the CostGuard doesn't claim the abort
        // signal — it has none.
        expect(g.isTripped()).toBe(false);
    });
});
describe("StateStack guard lifecycle", () => {
    it("pushGuard installs and popGuard uninstalls", () => {
        const stack = new StateStack();
        const g = new CostGuard(5);
        stack.pushGuard(g);
        expect(stack.guards).toContain(g);
        // spend=5 is at the limit; check is strictly > so still null. At
        // 5.01 it trips.
        g.charge(5);
        expect(g.check(stack)).toBeNull();
        g.charge(0.01);
        expect(g.check(stack).spent).toBeCloseTo(5.01, 5);
        stack.popGuard();
        expect(stack.guards).not.toContain(g);
    });
    it("toJSON / fromJSON round-trips a mixed guard stack", () => {
        const stack = new StateStack();
        stack.localCost = 2;
        stack.pushGuard(new CostGuard(3));
        const json = stack.toJSON();
        const restored = StateStack.fromJSON(json);
        expect(restored.guards.length).toBe(1);
        expect(restored.guards[0]).toBeInstanceOf(CostGuard);
    });
});
