/**
 * 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";

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

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

export interface X402ReceiptBinding {
  requestId: string;
  paymentId: string;
  authorizationId: string;
  route: string;
  method: string;
  network: string;
  chainId: number;
  asset: string;
  amount: string;
  payee: string;
  nonce: string;
  validAfter: number;
  expiresAt: number;
  requiredFinality: ReceiptFinality;
  /** Minimum observed canonical-block depth before delivery is eligible. */
  minConfirmations: number;
}

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

export interface StoredX402Receipt {
  receiptId: string;
  binding: X402ReceiptBinding;
  bindingDigest: string;
  state: ReceiptState;
  createdAt: number;
  updatedAt: number;
  version: number;
  observation?: ReceiptChainObservation;
  failureCode?: "timeout" | "reorg" | "verification_failed";
}

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 X402ReceiptStoreOptions {
  prefix?: string;
  now?: () => number;
  retentionMs?: number;
  audit?: ReceiptAuditSink;
}

const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const IDENTIFIER = /^[A-Za-z0-9._:-]{1,160}$/;
const HASH = /^0x[0-9a-fA-F]{64}$/;

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])
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
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})
`;

function canonical(value: unknown): string {
  return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort());
}

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");
  return value as StoredX402Receipt;
}

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

function assertBinding(binding: X402ReceiptBinding, now: number): void {
  for (const key of ["requestId", "paymentId", "authorizationId", "nonce"] as const) {
    assertIdentifier(key, binding[key]);
  }
  if (!/^[A-Z]+$/.test(binding.method)) throw new Error("invalid method");
  if (!binding.route.startsWith("/") || binding.route.length > 512) throw new Error("invalid route");
  if (!/^eip155:[1-9]\d*$/.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.asset || binding.asset.length > 256 || !binding.payee || binding.payee.length > 256) {
    throw new Error("invalid asset or payee");
  }
  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");
  }
}

function stateForObservation(binding: X402ReceiptBinding, observation: ReceiptChainObservation): ReceiptState {
  if (observation.headBlockNumber - observation.blockNumber + 1 < binding.minConfirmations) {
    return "pending_confirmation";
  }
  if (observation.observedFinality === "finalized") return "finalized";
  if (binding.requiredFinality === "soft") 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 {
  private readonly prefix: string;
  private readonly now: () => number;
  private readonly retentionMs: number;
  private readonly audit?: ReceiptAuditSink;

  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;
  }

  async claim(binding: X402ReceiptBinding): Promise<ReceiptClaimResult> {
    const now = this.now();
    assertBinding(binding, now);
    const bindingDigest = digest(canonical(binding));
    const receipt: StoredX402Receipt = {
      receiptId: `x402r-${randomUUID()}`,
      binding,
      bindingDigest,
      state: "claimed",
      createdAt: now,
      updatedAt: now,
      version: 1,
    };
    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 current = await this.get(requestId);
    if (!current) return { outcome: "missing" };
    if (!expectedStates.includes(current.state) || !validTransition(current.state, nextState)) {
      return { outcome: "conflict", code: "state_conflict" };
    }
    const next: StoredX402Receipt = {
      ...current,
      ...patch,
      state: nextState,
      updatedAt: this.now(),
      version: current.version + 1,
    };
    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)),
    ]));
    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 current = await this.get(requestId);
    if (!current) return { outcome: "missing" };
    if (!HASH.test(observation.txHash) || !HASH.test(observation.blockHash)) throw new Error("invalid transaction or block hash");
    if (
      !Number.isSafeInteger(observation.blockNumber) ||
      !Number.isSafeInteger(observation.headBlockNumber) ||
      observation.blockNumber < 0 ||
      observation.headBlockNumber < observation.blockNumber ||
      !Number.isSafeInteger(observation.observedAt)
    ) {
      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 next: StoredX402Receipt = {
      ...current,
      observation,
      state: nextState,
      failureCode: reorged ? "reorg" : current.failureCode,
      updatedAt: this.now(),
      version: current.version + 1,
    };
    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)),
    ]));
    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 === "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 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) } : {}),
    });
  }
}

/**
 * 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): 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) : 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;
  }
}
