/**
 * V3 x402 receipt persistence boundary.
 *
 * This is deliberately isolated from the V2 handlers.  It does not issue a
 * payment challenge, settle a transfer, or make a V3 route live.  A V3 route
 * must separately verify a payment/chain observation and explicitly pass the
 * resulting binding here.  With no configured Redis REST credentials it fails
 * closed rather than falling back to process memory.
 *
 * The REST executor uses Redis EVAL through the same Vercel KV / Upstash REST
 * credential pairs already used by app/api/_lib/payer-log.ts.  Every write
 * that changes receipt ownership or state is one Redis Lua transaction.
 */
import { createHash, randomUUID } from "node:crypto";
import { V3_ADDRESS, V3_IDENTIFIER, V3_REQUEST_ID_SOURCE, isValidV3RequestId } from "../../_lib/validation";
import { V3_API_ROUTES } from "../../_lib/inventory";
import {
  evaluateV3CapabilityEvidence,
  paymentRequirementDigest,
  type V3CapabilityEvidence,
  type V3CapabilityExpectation,
  type V3EvidenceVerifier,
} from "./capabilities";

export type ReceiptState =
  | "claimed"
  | "pending_confirmation"
  | "confirmed"
  | "finalized"
  | "reorged"
  | "expired"
  | "failed";

export type ReceiptFinality = "soft" | "safe" | "finalized";

export interface X402ReceiptBinding {
  schemaVersion: "1.0.0";
  requestId: string;
  paymentId: string;
  authorizationId: string;
  routeId: string;
  route: string;
  /** Exact normalized request path, including concrete path parameters. */
  resource: string;
  method: string;
  network: string;
  chainId: number;
  asset: string;
  amount: string;
  scheme: "exact";
  payee: string;
  nonce: string;
  validAfter: number;
  expiresAt: number;
  requiredFinality: ReceiptFinality;
  /** Minimum observed canonical-block depth before delivery is eligible. */
  minConfirmations: number;
}

export interface ReceiptChainObservation {
  network: string;
  chainId: number;
  txHash: string;
  blockNumber: number;
  blockHash: string;
  headBlockNumber: number;
  observedFinality: ReceiptFinality;
  observedAt: number;
  canonicalBlock: true;
}

export interface ReceiptAuditEvent {
  sequence: number;
  event: "receipt_claimed" | "receipt_transitioned" | "receipt_reorged";
  occurredAt: number;
  previousDigest: string;
  bindingDigest: string;
  snapshot: {
    state: ReceiptState;
    createdAt: number;
    updatedAt: number;
    version: number;
    observation: ReceiptChainObservation | null;
    failureCode: StoredX402Receipt["failureCode"] | null;
  };
  eventDigest: string;
}

export interface StoredX402Receipt {
  schemaVersion: "1.0.0";
  receiptId: string;
  binding: X402ReceiptBinding;
  bindingDigest: string;
  state: ReceiptState;
  createdAt: number;
  updatedAt: number;
  version: number;
  observation?: ReceiptChainObservation;
  failureCode?: "timeout" | "reorg" | "verification_failed";
  auditIntegrity: {
    sequence: number;
    previousDigest: string;
    eventDigest: string;
  };
  auditHistory: ReceiptAuditEvent[];
}

export type ReceiptClaimResult =
  | { outcome: "claimed"; receipt: StoredX402Receipt }
  | { outcome: "existing"; receipt: StoredX402Receipt }
  | { outcome: "conflict"; code: "binding_conflict" | "payment_replayed" | "authorization_replayed" | "nonce_replayed" };

export type ReceiptTransitionResult =
  | { outcome: "updated"; receipt: StoredX402Receipt }
  | { outcome: "existing"; receipt: StoredX402Receipt }
  | { outcome: "conflict"; code: "state_conflict" | "version_conflict" | "transaction_replayed" }
  | { outcome: "missing" };

export interface PrivacyMinimalAuditEvent {
  event: "receipt_claimed" | "receipt_transitioned" | "receipt_timeout_recovered" | "receipt_reorged";
  receiptId: string;
  state: ReceiptState;
  bindingDigestPrefix: string;
  transactionDigestPrefix?: string;
}

export type ReceiptAuditSink = (event: PrivacyMinimalAuditEvent) => void;

export interface ReceiptRedisExecutor {
  eval(script: string, keys: string[], args: string[]): Promise<unknown>;
  get(key: string): Promise<string | null>;
}

export interface X402ReceiptStore {
  readonly available: boolean;
  claim(binding: X402ReceiptBinding): Promise<ReceiptClaimResult>;
  get(requestId: string): Promise<StoredX402Receipt | null>;
  transition(requestId: string, expectedStates: ReceiptState[], nextState: ReceiptState,
    patch?: Pick<Partial<StoredX402Receipt>, "failureCode">): Promise<ReceiptTransitionResult>;
  reconcile(requestId: string, observation: ReceiptChainObservation): Promise<ReceiptTransitionResult>;
  recoverTimeout(requestId: string): Promise<ReceiptTransitionResult>;
}

export interface X402ReceiptStoreOptions {
  prefix?: string;
  now?: () => number;
  retentionMs?: number;
  audit?: ReceiptAuditSink;
  requirements?: readonly V3ReceiptRequirement[];
  capabilityEvidence?: V3CapabilityEvidence;
  evidenceVerifier?: V3EvidenceVerifier;
}

export interface V3ReceiptRequirement {
  readonly id: string;
  readonly route: string;
  readonly method: "GET" | "POST";
  readonly network: `eip155:${number}`;
  readonly chainId: number;
  readonly asset: `0x${string}`;
  readonly amount: string;
  readonly scheme: "exact";
  readonly payee: `0x${string}`;
  readonly deploymentAddress: `0x${string}`;
  readonly deploymentCodeDigest: string;
  readonly requiredFinality: ReceiptFinality;
  readonly minConfirmations: number;
}

const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const IDENTIFIER = V3_IDENTIFIER;
const HASH = /^0x[0-9a-f]{64}$/;
const EVM_ADDRESS = V3_ADDRESS;
const DIGEST = /^[0-9a-f]{64}$/;
const ZERO_DIGEST = "0".repeat(64);
const FINALITIES = new Set<ReceiptFinality>(["soft", "safe", "finalized"]);
const ALLOWED_METHODS = new Set(["GET", "POST"]);
const MAX_OBSERVATION_AGE_MS = 5 * 60 * 1000;

const CLAIM_LUA = `
local existing = redis.call("GET", KEYS[1])
if existing then
  local old = cjson.decode(existing)
  if old.bindingDigest == ARGV[1] then return cjson.encode({outcome="existing", receipt=old}) end
  return cjson.encode({outcome="conflict", code="binding_conflict"})
end
for i = 2, 4 do
  if redis.call("EXISTS", KEYS[i]) == 1 then
    return cjson.encode({outcome="conflict", code=ARGV[i]})
  end
end
local receipt = cjson.decode(ARGV[5])
redis.call("SET", KEYS[1], ARGV[5], "PX", ARGV[6])
for i = 2, 4 do redis.call("SET", KEYS[i], receipt.receiptId, "PX", ARGV[6]) end
return cjson.encode({outcome="claimed", receipt=receipt})
`;

const TRANSITION_LUA = `
local existing = redis.call("GET", KEYS[1])
if not existing then return cjson.encode({outcome="missing"}) end
local old = cjson.decode(existing)
if old.version ~= tonumber(ARGV[1]) then return cjson.encode({outcome="conflict", code="version_conflict"}) end
local allowed = cjson.decode(ARGV[2])
local matches = false
for _, state in ipairs(allowed) do if old.state == state then matches = true end end
if not matches then return cjson.encode({outcome="conflict", code="state_conflict"}) end
local next = cjson.decode(ARGV[3])
local now = tonumber(ARGV[5])
if now >= old.binding.expiresAt and next.state ~= "expired" then
  return cjson.encode({outcome="conflict", code="state_conflict"})
end
if now < old.binding.expiresAt and next.state == "expired" then
  return cjson.encode({outcome="conflict", code="state_conflict"})
end
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[4])
return cjson.encode({outcome="updated", receipt=next})
`;

const RECONCILE_LUA = `
local existing = redis.call("GET", KEYS[1])
if not existing then return cjson.encode({outcome="missing"}) end
local old = cjson.decode(existing)
if old.version ~= tonumber(ARGV[1]) then return cjson.encode({outcome="conflict", code="version_conflict"}) end
if tonumber(ARGV[4]) >= old.binding.expiresAt and (old.state == "claimed" or old.state == "pending_confirmation") then
  local expired = cjson.decode(ARGV[5])
  redis.call("SET", KEYS[1], ARGV[5], "PX", ARGV[3])
  return cjson.encode({outcome="updated", receipt=expired})
end
if ARGV[6] == "1" then return cjson.encode({outcome="existing", receipt=old}) end
local txOwner = redis.call("GET", KEYS[2])
if txOwner and txOwner ~= old.receiptId then
  return cjson.encode({outcome="conflict", code="transaction_replayed"})
end
local next = cjson.decode(ARGV[2])
redis.call("SET", KEYS[1], ARGV[2], "PX", ARGV[3])
redis.call("SET", KEYS[2], old.receiptId, "PX", ARGV[3])
return cjson.encode({outcome="updated", receipt=next})
`;

export function canonicalJson(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
  const object = value as Record<string, unknown>;
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
}

function digest(value: string): string {
  return createHash("sha256").update(value, "utf8").digest("hex");
}

function parseReply(value: unknown): Record<string, unknown> {
  let candidate = value;
  if (Array.isArray(candidate) && candidate.length === 1) candidate = candidate[0];
  if (candidate && typeof candidate === "object" && "result" in candidate) {
    candidate = (candidate as { result: unknown }).result;
  }
  if (typeof candidate === "string") {
    try {
      candidate = JSON.parse(candidate);
    } catch {
      throw new Error("receipt store returned malformed transaction result");
    }
  }
  if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
    throw new Error("receipt store returned an invalid transaction result");
  }
  return candidate as Record<string, unknown>;
}

function parseReceipt(value: unknown): StoredX402Receipt {
  if (!value || typeof value !== "object") throw new Error("receipt store returned an invalid receipt");
  const receipt = value as StoredX402Receipt;
  if (
    receipt.schemaVersion !== "1.0.0" ||
    !receipt.auditIntegrity ||
    !Number.isSafeInteger(receipt.auditIntegrity.sequence) ||
    !DIGEST.test(receipt.auditIntegrity.previousDigest) ||
    !DIGEST.test(receipt.auditIntegrity.eventDigest) ||
    !Array.isArray(receipt.auditHistory) ||
    !hasValidReceiptAuditIntegrity(receipt)
  ) throw new Error("receipt store returned a receipt without valid audit integrity");
  return receipt;
}

function assertIdentifier(name: string, value: string): void {
  if (name === "requestId" ? !isValidV3RequestId(value) : !IDENTIFIER.test(value)) throw new Error(`invalid ${name}`);
}

export function canonicalizeV3Resource(resource: string): string {
  if (!resource.startsWith("/api/v3/") || resource.length > 1024 || resource.includes("?") || resource.includes("#")) {
    throw new Error("invalid resource");
  }
  if (/%(2f|2e|3f|23|5c)/i.test(resource) || /[\\\u0000-\u001f\u007f]/.test(resource)) {
    throw new Error("resource contains forbidden encoding");
  }
  const normalized = new URL(resource, "https://vaultfire.invalid").pathname;
  if (normalized !== resource || resource.includes("//") || resource.endsWith("/")) throw new Error("resource is not canonical");
  return normalized;
}

const ROUTE_PARAMETER_PATTERNS: Readonly<Record<string, string>> = Object.freeze({
  name: "[A-Za-z0-9][A-Za-z0-9._-]{0,79}",
  bondId: "(?:0|[1-9][0-9]{0,77})",
  disputeId: "(?:0|[1-9][0-9]{0,77})",
  taskId: "(?:0|[1-9][0-9]{0,77})",
  proposalId: "(?:0|[1-9][0-9]{0,77})",
  evidenceHash: "0x[0-9a-f]{64}",
  mandateHash: "0x[0-9a-f]{64}",
  challengeId: "0x[0-9a-f]{64}",
  requestId: V3_REQUEST_ID_SOURCE,
});

function routePattern(template: string): RegExp {
  const segments = template.split("/").map((segment) => {
    if (!segment.startsWith(":")) return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const parameter = segment.slice(1);
    const pattern = ROUTE_PARAMETER_PATTERNS[parameter];
    if (!pattern) throw new Error(`unsupported canonical route parameter: ${parameter}`);
    return pattern;
  });
  return new RegExp(`^${segments.join("/")}$`);
}

export function matchesCanonicalV3RouteResource(template: string, resource: string): boolean {
  try {
    canonicalizeV3Resource(resource);
    return routePattern(template).test(resource);
  } catch {
    return false;
  }
}

function assertBinding(
  binding: X402ReceiptBinding,
  now: number,
  requirements: readonly V3ReceiptRequirement[],
): void {
  for (const key of ["requestId", "paymentId", "authorizationId", "nonce"] as const) {
    assertIdentifier(key, binding[key]);
  }
  if (!ALLOWED_METHODS.has(binding.method)) throw new Error("invalid method");
  if (binding.schemaVersion !== "1.0.0") throw new Error("invalid binding schema version");
  canonicalizeV3Resource(binding.resource);
  if (!/^eip155:[1-9]\d{0,14}$/.test(binding.network)) throw new Error("invalid network");
  if (!Number.isSafeInteger(binding.chainId) || binding.chainId <= 0) throw new Error("invalid chainId");
  if (binding.network !== `eip155:${binding.chainId}`) throw new Error("network and chainId mismatch");
  if (!/^[1-9]\d*$/.test(binding.amount)) throw new Error("invalid amount");
  if (binding.scheme !== "exact") throw new Error("invalid price scheme");
  if (!EVM_ADDRESS.test(binding.asset) || !EVM_ADDRESS.test(binding.payee)) throw new Error("invalid asset or payee");
  if (!FINALITIES.has(binding.requiredFinality)) throw new Error("invalid required finality");
  if (
    !Number.isSafeInteger(binding.validAfter) ||
    !Number.isSafeInteger(binding.expiresAt) ||
    binding.validAfter >= binding.expiresAt ||
    now < binding.validAfter ||
    now >= binding.expiresAt
  ) {
    throw new Error("invalid authorization window");
  }
  if (!Number.isSafeInteger(binding.minConfirmations) || binding.minConfirmations < 1 || binding.minConfirmations > 10000) {
    throw new Error("invalid confirmation policy");
  }
  const requirement = requirements.find((candidate) =>
    candidate.id === binding.routeId &&
    candidate.route === binding.route &&
    candidate.method === binding.method &&
    routePattern(candidate.route).test(binding.resource)
  );
  if (!requirement) throw new Error("route/resource/method is not a registered receipt requirement");
  for (const key of ["network", "chainId", "asset", "amount", "scheme", "payee", "requiredFinality", "minConfirmations"] as const) {
    if (binding[key] !== requirement[key]) throw new Error(`binding does not match registered ${key}`);
  }
}

function assertRequirement(requirement: V3ReceiptRequirement): void {
  if (!/^[a-z0-9][a-z0-9-]{1,79}$/.test(requirement.id)) throw new Error("invalid requirement id");
  if (!ALLOWED_METHODS.has(requirement.method)) throw new Error("invalid requirement method");
  if (!requirement.route.startsWith("/api/v3/") || requirement.route.includes("?") || requirement.route.includes("#")) {
    throw new Error("invalid requirement route");
  }
  if (!/^eip155:[1-9]\d{0,14}$/.test(requirement.network) || requirement.network !== `eip155:${requirement.chainId}`) {
    throw new Error("invalid requirement network");
  }
  if (!EVM_ADDRESS.test(requirement.asset) || !EVM_ADDRESS.test(requirement.payee) || !EVM_ADDRESS.test(requirement.deploymentAddress) || !DIGEST.test(requirement.deploymentCodeDigest)) {
    throw new Error("invalid requirement address");
  }
  if (!/^[1-9]\d*$/.test(requirement.amount) || requirement.scheme !== "exact") throw new Error("invalid requirement price");
  if (!FINALITIES.has(requirement.requiredFinality) || !Number.isSafeInteger(requirement.minConfirmations) || requirement.minConfirmations < 1) {
    throw new Error("invalid requirement finality");
  }
  const canonicalRoute = V3_API_ROUTES.find((route) => route.id === requirement.id);
  if (!canonicalRoute || canonicalRoute.method !== requirement.method || canonicalRoute.path !== requirement.route) {
    throw new Error("receipt requirement is not derived from the canonical V3 route registry");
  }
}

function capabilityExpectation(
  binding: X402ReceiptBinding,
  requirement: V3ReceiptRequirement,
): V3CapabilityExpectation {
  const core = {
    schemaVersion: binding.schemaVersion,
    routeId: requirement.id,
    routeTemplate: requirement.route,
    concreteResource: binding.resource,
    method: requirement.method,
    network: requirement.network,
    chainId: requirement.chainId,
    deploymentAddress: requirement.deploymentAddress,
    deploymentCodeDigest: requirement.deploymentCodeDigest,
    asset: requirement.asset,
    payee: requirement.payee,
    amount: requirement.amount,
    scheme: requirement.scheme,
    requiredFinality: requirement.requiredFinality,
    minConfirmations: requirement.minConfirmations,
    validAfter: binding.validAfter,
    expiresAt: binding.expiresAt,
  } as const;
  return { ...core, paymentRequirementDigest: paymentRequirementDigest(core) };
}

function stateForObservation(binding: X402ReceiptBinding, observation: ReceiptChainObservation): ReceiptState {
  if (observation.headBlockNumber - observation.blockNumber + 1 < binding.minConfirmations) {
    return "pending_confirmation";
  }
  if (binding.requiredFinality === "finalized") {
    return observation.observedFinality === "finalized" ? "finalized" : "pending_confirmation";
  }
  if (observation.observedFinality === "finalized") return "finalized";
  if (binding.requiredFinality === "soft" && FINALITIES.has(observation.observedFinality)) return "confirmed";
  if (binding.requiredFinality === "safe" && observation.observedFinality === "safe") return "confirmed";
  return "pending_confirmation";
}

function validTransition(from: ReceiptState, to: ReceiptState): boolean {
  return (
    (from === "claimed" && ["pending_confirmation", "confirmed", "finalized", "expired", "failed"].includes(to)) ||
    (from === "pending_confirmation" && ["confirmed", "finalized", "reorged", "expired", "failed"].includes(to)) ||
    (from === "confirmed" && ["finalized", "reorged"].includes(to)) ||
    (from === "finalized" && to === "reorged")
  );
}

/**
 * Durable V3 receipt store.  It makes no live payment decision itself: callers
 * must not fulfill a response until their verifier has independently reached
 * the binding's required finality.
 */
export class RedisX402ReceiptStore implements X402ReceiptStore {
  readonly available = true;
  private readonly prefix: string;
  private readonly now: () => number;
  private readonly retentionMs: number;
  private readonly audit?: ReceiptAuditSink;
  private readonly requirements: readonly V3ReceiptRequirement[];
  private readonly capabilityEvidence?: V3CapabilityEvidence;
  private readonly evidenceVerifier?: V3EvidenceVerifier;

  constructor(private readonly redis: ReceiptRedisExecutor, options: X402ReceiptStoreOptions = {}) {
    this.prefix = options.prefix ?? "vaultfire:x402:v3:receipt";
    this.now = options.now ?? Date.now;
    this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
    this.audit = options.audit;
    this.capabilityEvidence = options.capabilityEvidence;
    this.evidenceVerifier = options.evidenceVerifier;
    const requirements = [...(options.requirements ?? [])];
    for (const requirement of requirements) assertRequirement(requirement);
    if (new Set(requirements.map((item) => item.id)).size !== requirements.length) throw new Error("duplicate requirement id");
    if (new Set(requirements.map((item) => `${item.method} ${item.route}`)).size !== requirements.length) {
      throw new Error("duplicate requirement route");
    }
    this.requirements = Object.freeze(requirements.map((item) => Object.freeze({ ...item })));
  }

  async claim(binding: X402ReceiptBinding): Promise<ReceiptClaimResult> {
    const now = this.now();
    assertBinding(binding, now, this.requirements);
    const requirement = this.requirements.find((candidate) =>
      candidate.id === binding.routeId && candidate.route === binding.route && candidate.method === binding.method
    );
    if (!requirement) throw new Error("missing canonical receipt requirement");
    this.assertWritesAuthorized(now, capabilityExpectation(binding, requirement));
    const immutableBinding = Object.freeze({ ...binding });
    const bindingDigest = digest(canonicalJson(immutableBinding));
    const receipt: StoredX402Receipt = {
      schemaVersion: "1.0.0",
      receiptId: `x402r-${randomUUID()}`,
      binding: immutableBinding,
      bindingDigest,
      state: "claimed",
      createdAt: now,
      updatedAt: now,
      version: 1,
      auditIntegrity: {
        sequence: 1,
        previousDigest: ZERO_DIGEST,
        eventDigest: "",
      },
      auditHistory: [],
    };
    appendAuditEvent(receipt, "receipt_claimed", now);
    const ttl = Math.max(1, binding.expiresAt - now + this.retentionMs);
    const reply = parseReply(await this.redis.eval(CLAIM_LUA, [
      this.requestKey(binding.requestId),
      this.paymentKey(binding.paymentId),
      this.authorizationKey(binding.authorizationId),
      this.nonceKey(binding.network, binding.nonce),
    ], [
      bindingDigest,
      "payment_replayed",
      "authorization_replayed",
      "nonce_replayed",
      JSON.stringify(receipt),
      String(ttl),
    ]));
    if (reply.outcome === "claimed") {
      const claimed = parseReceipt(reply.receipt);
      this.emit("receipt_claimed", claimed);
      return { outcome: "claimed", receipt: claimed };
    }
    if (reply.outcome === "existing") return { outcome: "existing", receipt: parseReceipt(reply.receipt) };
    return { outcome: "conflict", code: reply.code as ReceiptClaimResult extends { outcome: "conflict"; code: infer C } ? C : never };
  }

  async get(requestId: string): Promise<StoredX402Receipt | null> {
    assertIdentifier("requestId", requestId);
    const raw = await this.redis.get(this.requestKey(requestId));
    if (!raw) return null;
    try {
      return parseReceipt(JSON.parse(raw));
    } catch {
      throw new Error("receipt store returned corrupt receipt data");
    }
  }

  async transition(
    requestId: string,
    expectedStates: ReceiptState[],
    nextState: ReceiptState,
    patch: Pick<Partial<StoredX402Receipt>, "failureCode"> = {},
  ): Promise<ReceiptTransitionResult> {
    const now = this.now();
    const current = await this.get(requestId);
    if (!current) return { outcome: "missing" };
    const requirement = this.requirements.find((candidate) =>
      candidate.id === current.binding.routeId && candidate.route === current.binding.route && candidate.method === current.binding.method
    );
    if (!requirement) throw new Error("missing canonical receipt requirement");
    const expectation = capabilityExpectation(current.binding, requirement);
    if (now >= current.binding.expiresAt && nextState === "expired") {
      this.assertPreviouslyAuthorizedForExpiry(expectation);
    } else {
      this.assertWritesAuthorized(now, expectation);
    }
    if (["confirmed", "finalized", "reorged"].includes(nextState)) {
      return { outcome: "conflict", code: "state_conflict" };
    }
    if (!expectedStates.includes(current.state) || !validTransition(current.state, nextState)) {
      return { outcome: "conflict", code: "state_conflict" };
    }
    if (now >= current.binding.expiresAt && nextState !== "expired") {
      return { outcome: "conflict", code: "state_conflict" };
    }
    const next: StoredX402Receipt = {
      ...current,
      ...patch,
      state: nextState,
      updatedAt: now,
      version: current.version + 1,
      auditIntegrity: { ...current.auditIntegrity },
      auditHistory: [...current.auditHistory],
    };
    appendAuditEvent(next, "receipt_transitioned", now);
    const reply = parseReply(await this.redis.eval(TRANSITION_LUA, [this.requestKey(requestId)], [
      String(current.version),
      JSON.stringify(expectedStates),
      JSON.stringify(next),
      String(this.retentionFor(next)),
      String(now),
    ]));
    if (reply.outcome === "updated") {
      const receipt = parseReceipt(reply.receipt);
      this.emit(nextState === "reorged" ? "receipt_reorged" : "receipt_transitioned", receipt);
      return { outcome: "updated", receipt };
    }
    if (reply.outcome === "missing") return { outcome: "missing" };
    return { outcome: "conflict", code: reply.code as "state_conflict" | "version_conflict" };
  }

  /**
   * Records a separately verified Base (or EVM) observation.  A transaction
   * hash is never trusted as an identifier: it is range-checked, must match
   * the binding chain, and may only belong to one receipt.
   */
  async reconcile(
    requestId: string,
    observation: ReceiptChainObservation,
  ): Promise<ReceiptTransitionResult> {
    const now = this.now();
    const current = await this.get(requestId);
    if (!current) return { outcome: "missing" };
    const requirement = this.requirements.find((candidate) =>
      candidate.id === current.binding.routeId && candidate.route === current.binding.route && candidate.method === current.binding.method
    );
    if (!requirement) throw new Error("missing canonical receipt requirement");
    const expectation = capabilityExpectation(current.binding, requirement);
    if (now >= current.binding.expiresAt && ["claimed", "pending_confirmation"].includes(current.state)) {
      this.assertPreviouslyAuthorizedForExpiry(expectation);
    } else {
      this.assertWritesAuthorized(now, expectation);
    }
    if (!HASH.test(observation.txHash) || !HASH.test(observation.blockHash)) throw new Error("invalid transaction or block hash");
    if (
      !FINALITIES.has(observation.observedFinality) ||
      observation.network !== current.binding.network ||
      observation.chainId !== current.binding.chainId ||
      observation.canonicalBlock !== true ||
      !Number.isSafeInteger(observation.blockNumber) ||
      !Number.isSafeInteger(observation.headBlockNumber) ||
      observation.blockNumber < 0 ||
      observation.headBlockNumber < observation.blockNumber ||
      !Number.isSafeInteger(observation.observedAt) ||
      observation.observedAt < current.createdAt ||
      observation.observedAt > now ||
      now - observation.observedAt > MAX_OBSERVATION_AGE_MS
    ) {
      throw new Error("invalid chain observation");
    }
    const reorged = Boolean(current.observation && (
      current.observation.blockHash.toLowerCase() !== observation.blockHash.toLowerCase() ||
      current.observation.txHash.toLowerCase() !== observation.txHash.toLowerCase()
    ));
    const nextState = reorged ? "reorged" : stateForObservation(current.binding, observation);
    if (nextState !== current.state && !validTransition(current.state, nextState)) {
      return { outcome: "conflict", code: "state_conflict" };
    }
    const sameObservation = canonicalJson(current.observation ?? null) === canonicalJson(observation)
      && current.state === nextState;
    const next: StoredX402Receipt = {
      ...current,
      observation,
      state: nextState,
      failureCode: reorged ? "reorg" : current.failureCode,
      updatedAt: now,
      version: current.version + 1,
      auditIntegrity: { ...current.auditIntegrity },
      auditHistory: [...current.auditHistory],
    };
    appendAuditEvent(next, reorged ? "receipt_reorged" : "receipt_transitioned", now);
    const expired: StoredX402Receipt = {
      ...current,
      state: "expired",
      failureCode: "timeout",
      updatedAt: now,
      version: current.version + 1,
      auditIntegrity: { ...current.auditIntegrity },
      auditHistory: [...current.auditHistory],
    };
    appendAuditEvent(expired, "receipt_transitioned", now);
    const reply = parseReply(await this.redis.eval(RECONCILE_LUA, [
      this.requestKey(requestId),
      this.transactionKey(observation.txHash),
    ], [
      String(current.version),
      JSON.stringify(next),
      String(this.retentionFor(next)),
      String(now),
      JSON.stringify(expired),
      sameObservation ? "1" : "0",
    ]));
    if (reply.outcome === "updated") {
      const receipt = parseReceipt(reply.receipt);
      this.emit(receipt.state === "reorged" ? "receipt_reorged" : "receipt_transitioned", receipt);
      return { outcome: "updated", receipt };
    }
    if (reply.outcome === "existing") return { outcome: "existing", receipt: parseReceipt(reply.receipt) };
    if (reply.outcome === "missing") return { outcome: "missing" };
    return { outcome: "conflict", code: reply.code as "version_conflict" | "transaction_replayed" };
  }

  /** Marks a stalled authorization terminal; identifiers remain reserved for retention. */
  async recoverTimeout(requestId: string): Promise<ReceiptTransitionResult> {
    const current = await this.get(requestId);
    if (!current) return { outcome: "missing" };
    if (this.now() < current.binding.expiresAt) return { outcome: "existing", receipt: current };
    if (!["claimed", "pending_confirmation"].includes(current.state)) return { outcome: "existing", receipt: current };
    const result = await this.transition(requestId, [current.state], "expired", { failureCode: "timeout" });
    if (result.outcome === "updated") this.emit("receipt_timeout_recovered", result.receipt);
    return result;
  }

  private requestKey(requestId: string): string { return `${this.prefix}:request:${requestId}`; }
  private assertWritesAuthorized(now: number, expected: V3CapabilityExpectation): void {
    if (!evaluateV3CapabilityEvidence(this.capabilityEvidence, this.evidenceVerifier, now, expected).satisfied) {
      throw new Error("V3 receipt writes require authenticated capability evidence");
    }
  }
  /**
   * Expiry cleanup authenticates the exact historical authorization at the
   * final millisecond of its window. It never reports the bundle as currently
   * satisfied and can only support the terminal expired transition.
   */
  private assertPreviouslyAuthorizedForExpiry(expected: V3CapabilityExpectation): void {
    if (!evaluateV3CapabilityEvidence(
      this.capabilityEvidence,
      this.evidenceVerifier,
      expected.expiresAt - 1,
      expected,
    ).satisfied) {
      throw new Error("V3 receipt expiry requires previously authenticated capability evidence");
    }
  }
  private paymentKey(paymentId: string): string { return `${this.prefix}:payment:${paymentId}`; }
  private authorizationKey(authorizationId: string): string { return `${this.prefix}:authorization:${authorizationId}`; }
  private nonceKey(network: string, nonce: string): string { return `${this.prefix}:nonce:${digest(`${network}:${nonce}`)}`; }
  private transactionKey(txHash: string): string { return `${this.prefix}:transaction:${txHash.toLowerCase()}`; }
  private retentionFor(receipt: StoredX402Receipt): number {
    return Math.max(1, receipt.binding.expiresAt - this.now() + this.retentionMs);
  }
  private emit(event: PrivacyMinimalAuditEvent["event"], receipt: StoredX402Receipt): void {
    this.audit?.({
      event,
      receiptId: receipt.receiptId,
      state: receipt.state,
      bindingDigestPrefix: receipt.bindingDigest.slice(0, 16),
      ...(receipt.observation ? { transactionDigestPrefix: digest(receipt.observation.txHash).slice(0, 16) } : {}),
    });
  }
}

function auditSnapshot(receipt: StoredX402Receipt): ReceiptAuditEvent["snapshot"] {
  return {
    state: receipt.state,
    createdAt: receipt.createdAt,
    updatedAt: receipt.updatedAt,
    version: receipt.version,
    observation: receipt.observation ? { ...receipt.observation } : null,
    failureCode: receipt.failureCode ?? null,
  };
}

function eventDigest(event: Omit<ReceiptAuditEvent, "eventDigest">, receiptId: string): string {
  return digest(canonicalJson({
    receiptId,
    sequence: event.sequence,
    event: event.event,
    occurredAt: event.occurredAt,
    previousDigest: event.previousDigest,
    bindingDigest: event.bindingDigest,
    snapshot: event.snapshot,
  }));
}

function appendAuditEvent(
  receipt: StoredX402Receipt,
  event: ReceiptAuditEvent["event"],
  occurredAt: number,
): void {
  const previousDigest = receipt.auditHistory.at(-1)?.eventDigest ?? ZERO_DIGEST;
  const next: ReceiptAuditEvent = {
    sequence: receipt.auditHistory.length + 1,
    event,
    occurredAt,
    previousDigest,
    bindingDigest: receipt.bindingDigest,
    snapshot: auditSnapshot(receipt),
    eventDigest: "",
  };
  next.eventDigest = eventDigest(next, receipt.receiptId);
  receipt.auditHistory = [...receipt.auditHistory, next];
  receipt.auditIntegrity = {
    sequence: next.sequence,
    previousDigest: next.previousDigest,
    eventDigest: next.eventDigest,
  };
}

export function hasValidReceiptAuditIntegrity(receipt: StoredX402Receipt): boolean {
  if (digest(canonicalJson(receipt.binding)) !== receipt.bindingDigest) return false;
  if (!Array.isArray(receipt.auditHistory) || receipt.auditHistory.length !== receipt.version) return false;
  let previousDigest = ZERO_DIGEST;
  let previousEvent: ReceiptAuditEvent | undefined;
  for (let index = 0; index < receipt.auditHistory.length; index += 1) {
    const event = receipt.auditHistory[index];
    if (
      event.sequence !== index + 1 ||
      !Number.isSafeInteger(event.occurredAt) ||
      event.occurredAt < receipt.createdAt ||
      Boolean(previousEvent && event.occurredAt < previousEvent.occurredAt) ||
      event.previousDigest !== previousDigest ||
      event.bindingDigest !== receipt.bindingDigest ||
      event.snapshot.version !== event.sequence ||
      event.snapshot.createdAt !== receipt.createdAt ||
      event.snapshot.updatedAt !== event.occurredAt ||
      (index === 0 && (
        event.event !== "receipt_claimed" ||
        event.snapshot.state !== "claimed" ||
        event.snapshot.observation !== null ||
        event.snapshot.failureCode !== null
      )) ||
      Boolean(previousEvent && (
        (previousEvent.snapshot.state !== event.snapshot.state &&
          !validTransition(previousEvent.snapshot.state, event.snapshot.state)) ||
        (event.event === "receipt_reorged") !== (event.snapshot.state === "reorged")
      )) ||
      event.eventDigest !== eventDigest({
        sequence: event.sequence,
        event: event.event,
        occurredAt: event.occurredAt,
        previousDigest: event.previousDigest,
        bindingDigest: event.bindingDigest,
        snapshot: event.snapshot,
      }, receipt.receiptId)
    ) return false;
    previousDigest = event.eventDigest;
    previousEvent = event;
  }
  const head = receipt.auditHistory.at(-1);
  if (!head) return false;
  if (
    receipt.auditIntegrity.sequence !== head.sequence ||
    receipt.auditIntegrity.previousDigest !== head.previousDigest ||
    receipt.auditIntegrity.eventDigest !== head.eventDigest ||
    canonicalJson(head.snapshot) !== canonicalJson(auditSnapshot(receipt))
  ) return false;
  return true;
}

export class ReceiptStoreUnavailableError extends Error {
  readonly code = "v3_receipt_store_unavailable";
  constructor() { super("V3 durable receipt store is unavailable"); }
}

/** Production default. It never accepts writes or substitutes process memory. */
export class UnavailableX402ReceiptStore implements X402ReceiptStore {
  readonly available = false;
  private fail(): never { throw new ReceiptStoreUnavailableError(); }
  async claim(_binding: X402ReceiptBinding): Promise<ReceiptClaimResult> { return this.fail(); }
  async get(_requestId: string): Promise<StoredX402Receipt | null> { return this.fail(); }
  async transition(): Promise<ReceiptTransitionResult> { return this.fail(); }
  async reconcile(): Promise<ReceiptTransitionResult> { return this.fail(); }
  async recoverTimeout(): Promise<ReceiptTransitionResult> { return this.fail(); }
}

export function createProductionReceiptStore(
  env: Record<string, string | undefined> = process.env,
  fetchImpl: typeof fetch = fetch,
): X402ReceiptStore {
  const redis = UpstashRestReceiptRedis.fromEnvironment(env, fetchImpl);
  return redis ? new RedisX402ReceiptStore(redis) : new UnavailableX402ReceiptStore();
}

/**
 * Concrete Upstash/Vercel KV REST adapter.  It never fabricates credentials and
 * does not silently degrade to memory.  Redis errors bubble to the caller so
 * a V3 money path can fail closed.
 */
export class UpstashRestReceiptRedis implements ReceiptRedisExecutor {
  constructor(private readonly url: string, private readonly token: string, private readonly fetchImpl: typeof fetch = fetch) {}

  static fromEnvironment(
    env: Record<string, string | undefined> = process.env,
    fetchImpl: typeof fetch = fetch,
  ): UpstashRestReceiptRedis | null {
    const url = env.KV_REST_API_URL || env.UPSTASH_REDIS_REST_URL;
    const token = env.KV_REST_API_TOKEN || env.UPSTASH_REDIS_REST_TOKEN;
    return url && token ? new UpstashRestReceiptRedis(url.replace(/\/$/, ""), token, fetchImpl) : null;
  }

  async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
    const command = ["EVAL", script, String(keys.length), ...keys, ...args];
    return this.pipeline([command]);
  }

  async get(key: string): Promise<string | null> {
    const response = await this.pipeline([["GET", key]]);
    const item = Array.isArray(response) ? response[0] : response;
    const value = item && typeof item === "object" && "result" in item ? (item as { result: unknown }).result : item;
    return typeof value === "string" ? value : null;
  }

  private async pipeline(commands: string[][]): Promise<unknown> {
    const response = await this.fetchImpl(`${this.url}/pipeline`, {
      method: "POST",
      headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
      body: JSON.stringify(commands),
      signal: AbortSignal.timeout(2_000),
    });
    if (!response.ok) throw new Error(`receipt Redis request failed with HTTP ${response.status}`);
    const body: unknown = await response.json();
    if (!Array.isArray(body) || body.some((item) => item && typeof item === "object" && "error" in item)) {
      throw new Error("receipt Redis returned an error");
    }
    return body;
  }
}
