import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import {
  RedisX402ReceiptStore,
  type ReceiptRedisExecutor,
  UpstashRestReceiptRedis,
  type X402ReceiptBinding,
  type V3ReceiptRequirement,
} from "../../../repos/theloopbreaker-site/app/api/v3/x402/_lib/receipt-store";
import { createTestReceiptCapabilities } from "../../../repos/theloopbreaker-site/app/api/v3/x402/_lib/test-receipt-adapter";

class AtomicRedisDouble implements ReceiptRedisExecutor {
  private readonly values = new Map<string, string>();

  async get(key: string): Promise<string | null> {
    return this.values.get(key) ?? null;
  }

  async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
    // This test double deliberately models Redis EVAL as one indivisible
    // operation shared by independent store clients.
    if (keys.length === 4) {
      const existing = this.values.get(keys[0]);
      if (existing) {
        const old = JSON.parse(existing);
        return [{ result: JSON.stringify(old.bindingDigest === args[0]
          ? { outcome: "existing", receipt: old }
          : { outcome: "conflict", code: "binding_conflict" }) }];
      }
      const conflicts = ["payment_replayed", "authorization_replayed", "nonce_replayed"];
      for (let i = 1; i < 4; i += 1) {
        if (this.values.has(keys[i])) return [{ result: JSON.stringify({ outcome: "conflict", code: conflicts[i - 1] }) }];
      }
      const receipt = JSON.parse(args[4]);
      this.values.set(keys[0], args[4]);
      for (let i = 1; i < 4; i += 1) this.values.set(keys[i], receipt.receiptId);
      return [{ result: JSON.stringify({ outcome: "claimed", receipt }) }];
    }
    if (keys.length === 1) {
      const oldRaw = this.values.get(keys[0]);
      if (!oldRaw) return [{ result: JSON.stringify({ outcome: "missing" }) }];
      const old = JSON.parse(oldRaw);
      if (old.version !== Number(args[0])) return [{ result: JSON.stringify({ outcome: "conflict", code: "version_conflict" }) }];
      if (!JSON.parse(args[1]).includes(old.state)) return [{ result: JSON.stringify({ outcome: "conflict", code: "state_conflict" }) }];
      const next = JSON.parse(args[2]);
      const transitionNow = Number(args[4]);
      if ((transitionNow >= old.binding.expiresAt && next.state !== "expired") ||
          (transitionNow < old.binding.expiresAt && next.state === "expired")) {
        return [{ result: JSON.stringify({ outcome: "conflict", code: "state_conflict" }) }];
      }
      this.values.set(keys[0], args[2]);
      return [{ result: JSON.stringify({ outcome: "updated", receipt: JSON.parse(args[2]) }) }];
    }
    if (keys.length === 2) {
      const oldRaw = this.values.get(keys[0]);
      if (!oldRaw) return [{ result: JSON.stringify({ outcome: "missing" }) }];
      const old = JSON.parse(oldRaw);
      if (old.version !== Number(args[0])) return [{ result: JSON.stringify({ outcome: "conflict", code: "version_conflict" }) }];
      if (Number(args[3]) >= old.binding.expiresAt && ["claimed", "pending_confirmation"].includes(old.state)) {
        this.values.set(keys[0], args[4]);
        return [{ result: JSON.stringify({ outcome: "updated", receipt: JSON.parse(args[4]) }) }];
      }
      if (args[5] === "1") return [{ result: JSON.stringify({ outcome: "existing", receipt: old }) }];
      const owner = this.values.get(keys[1]);
      if (owner && owner !== old.receiptId) return [{ result: JSON.stringify({ outcome: "conflict", code: "transaction_replayed" }) }];
      const next = JSON.parse(args[1]);
      this.values.set(keys[0], args[1]);
      this.values.set(keys[1], old.receiptId);
      return [{ result: JSON.stringify({ outcome: "updated", receipt: next }) }];
    }
    throw new Error(`unexpected script shape ${script.length}/${keys.length}`);
  }
}

const now = 1_800_000_000_000;
const binding = (suffix = "a"): X402ReceiptBinding => ({
  schemaVersion: "1.0.0",
  requestId: `v3r_${createHash("sha256").update(`request:${suffix}`).digest("hex").slice(0, 32)}`,
  paymentId: `payment-${suffix}`,
  authorizationId: `authorization-${suffix}`,
  routeId: "retired-partnership-write",
  route: "/api/v3/x402/actions/create-partnership-bond",
  resource: "/api/v3/x402/actions/create-partnership-bond",
  method: "POST",
  network: "eip155:8453",
  chainId: 8453,
  asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  amount: "20000",
  scheme: "exact",
  payee: "0xfa15ee28939b222b0448261a22156070f0a7813c",
  nonce: `nonce-${suffix}`,
  validAfter: now - 1,
  expiresAt: now + 10_000,
  requiredFinality: "safe",
  minConfirmations: 5,
});

const requirement = (): V3ReceiptRequirement => ({
  id: "retired-partnership-write",
  route: "/api/v3/x402/actions/create-partnership-bond",
  method: "POST",
  network: "eip155:8453",
  chainId: 8453,
  asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  amount: "20000",
  scheme: "exact",
  payee: "0xfa15ee28939b222b0448261a22156070f0a7813c",
  deploymentAddress: "0x3333333333333333333333333333333333333333",
  deploymentCodeDigest: "ab".repeat(32),
  requiredFinality: "safe",
  minConfirmations: 5,
});

const capabilities = () => createTestReceiptCapabilities(
  now, requirement(), binding().resource, binding().validAfter, binding().expiresAt,
);

describe("durable V3 x402 receipt store", () => {
  it("atomically admits one claim across concurrent independent store clients", async () => {
    const redis = new AtomicRedisDouble();
    const first = new RedisX402ReceiptStore(redis, { now: () => now, requirements: [requirement()], ...capabilities() });
    const second = new RedisX402ReceiptStore(redis, { now: () => now, requirements: [requirement()], ...capabilities() });
    const shared = binding("shared");

    const results = await Promise.all([first.claim(shared), second.claim(shared)]);
    expect(results.map((result) => result.outcome).sort()).toEqual(["claimed", "existing"]);
    expect(new Set(results.filter((r) => r.outcome !== "conflict").map((r) => r.receipt.receiptId)).size).toBe(1);
  });

  it("binds a duplicate request to its exact method and route while rejecting a changed binding", async () => {
    const store = new RedisX402ReceiptStore(new AtomicRedisDouble(), { now: () => now, requirements: [requirement()], ...capabilities() });
    const original = binding("route-bound");
    expect(await store.claim(original)).toMatchObject({ outcome: "claimed" });
    expect(await store.claim({ ...original })).toMatchObject({ outcome: "existing" });
    await expect(store.claim({ ...original, route: "/api/v3/mandate/:mandateHash" })).rejects.toThrow(/registered/);
    await expect(store.claim({ ...original, method: "GET" })).rejects.toThrow(/registered/);
  });

  it("keeps the production REST transport's two-second abort fail-closed", async () => {
    const controller = new AbortController();
    const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
    const fetchImpl = vi.fn(((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
      const signal = init?.signal;
      if (!signal) {
        reject(new Error("production transport did not supply an abort signal"));
        return;
      }
      signal.addEventListener("abort", () => reject(signal.reason), { once: true });
    })) as typeof fetch);

    try {
      const pending = new UpstashRestReceiptRedis("https://receipt.example.test", "test-token", fetchImpl)
        .eval("return 1", [], []);
      expect(timeout).toHaveBeenCalledOnce();
      expect(timeout).toHaveBeenCalledWith(2_000);
      controller.abort(new DOMException("simulated receipt REST timeout", "TimeoutError"));
      await expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
      expect(fetchImpl).toHaveBeenCalledOnce();
    } finally {
      timeout.mockRestore();
    }
  });

  it("rejects reused payment, authorization, and nonce identities without storing wallet data in its audit event", async () => {
    const events: unknown[] = [];
    const store = new RedisX402ReceiptStore(new AtomicRedisDouble(), { now: () => now, requirements: [requirement()], audit: (event) => events.push(event), ...capabilities() });
    expect((await store.claim(binding("original"))).outcome).toBe("claimed");

    for (const [field, code] of [
      ["paymentId", "payment_replayed"],
      ["authorizationId", "authorization_replayed"],
      ["nonce", "nonce_replayed"],
    ] as const) {
      const candidate = binding(`reuse-${field}`);
      candidate[field] = binding("original")[field];
      await expect(store.claim(candidate)).resolves.toMatchObject({ outcome: "conflict", code });
    }
    expect(events).toEqual([expect.objectContaining({
      event: "receipt_claimed",
      bindingDigestPrefix: expect.stringMatching(/^[a-f0-9]{16}$/),
    })]);
    expect(JSON.stringify(events)).not.toContain("0xfA15Ee");
    expect(JSON.stringify(events)).not.toContain("nonce-original");
  });

  it("recovers expired pending work without reopening the payment identity, and reconciles a reorg fail-closed", async () => {
    let clock = now;
    const redis = new AtomicRedisDouble();
    const store = new RedisX402ReceiptStore(redis, { now: () => clock, requirements: [requirement()], ...capabilities() });
    const claimed = await store.claim(binding("timeout"));
    if (claimed.outcome !== "claimed") throw new Error("expected a claim");
    expect((await store.transition(binding("timeout").requestId, ["claimed"], "pending_confirmation")).outcome).toBe("updated");
    clock += 20_000;
    await expect(store.recoverTimeout(binding("timeout").requestId)).resolves.toMatchObject({ outcome: "updated", receipt: { state: "expired" } });
    const replayAfterExpiry = binding("timeout-retry");
    replayAfterExpiry.paymentId = "payment-timeout";
    replayAfterExpiry.validAfter = clock - 1;
    replayAfterExpiry.expiresAt = clock + 10_000;
    const replayStore = new RedisX402ReceiptStore(redis, {
      now: () => clock,
      requirements: [requirement()],
      ...createTestReceiptCapabilities(clock, requirement(), replayAfterExpiry.resource, replayAfterExpiry.validAfter, replayAfterExpiry.expiresAt),
    });
    await expect(replayStore.claim(replayAfterExpiry)).resolves.toMatchObject({ outcome: "conflict", code: "payment_replayed" });

    clock = now;
    const settled = await store.claim(binding("reorg"));
    if (settled.outcome !== "claimed") throw new Error("expected a claim");
    const firstObservation = {
      network: "eip155:8453", chainId: 8453, canonicalBlock: true as const,
      txHash: `0x${"11".repeat(32)}`, blockHash: `0x${"aa".repeat(32)}`,
      blockNumber: 100, headBlockNumber: 106, observedFinality: "safe" as const, observedAt: now,
    };
    await expect(store.reconcile(binding("reorg").requestId, firstObservation)).resolves.toMatchObject({ outcome: "updated", receipt: { state: "confirmed" } });
    await expect(store.reconcile(binding("reorg").requestId, { ...firstObservation, blockHash: `0x${"bb".repeat(32)}` }))
      .resolves.toMatchObject({ outcome: "updated", receipt: { state: "reorged", failureCode: "reorg" } });
  });

  it("rejects a transaction hash already attached to another receipt and binds all required fields in a deterministic digest", async () => {
    const store = new RedisX402ReceiptStore(new AtomicRedisDouble(), { now: () => now, requirements: [requirement()], ...capabilities() });
    expect((await store.claim(binding("one"))).outcome).toBe("claimed");
    expect((await store.claim(binding("two"))).outcome).toBe("claimed");
    const observation = {
      network: "eip155:8453", chainId: 8453, canonicalBlock: true as const,
      txHash: `0x${"22".repeat(32)}`, blockHash: `0x${"cc".repeat(32)}`,
      blockNumber: 1, headBlockNumber: 8, observedFinality: "safe" as const, observedAt: now,
    };
    expect((await store.reconcile(binding("one").requestId, observation)).outcome).toBe("updated");
    await expect(store.reconcile(binding("two").requestId, observation)).resolves.toMatchObject({ outcome: "conflict", code: "transaction_replayed" });

    const receipt = await store.get(binding("one").requestId);
    const canonical = JSON.stringify(binding("one"), Object.keys(binding("one")).sort());
    expect(receipt?.bindingDigest).toBe(createHash("sha256").update(canonical).digest("hex"));
  });
});
