import type { VaultfireV3Config } from "./types";

/**
 * The fields a server challenges the payer to authorize. Amounts deliberately
 * remain strings: x402 amounts are atomic values and must never pass through a
 * JavaScript number.
 */
export interface X402Challenge {
  x402Version: 2;
  requestId: string;
  method: string;
  path: string;
  amount: string;
  asset: string;
  network: string;
  payee: string;
  /** Inclusive Unix-millisecond not-before time, bound into the payer signature. */
  validAfter: number;
  /** Exclusive Unix-millisecond expiry time, bound into the payer signature. */
  expiresAt: number;
  finality: "soft" | "safe" | "finalized";
}

export interface X402Payment {
  x402Version: 2;
  paymentId: string;
  requestId: string;
  method: string;
  path: string;
  amount: string;
  asset: string;
  network: string;
  payee: string;
  /** Copied from the challenge and covered by the payment signature. */
  validAfter: number;
  expiresAt: number;
  payer: string;
  signature: string;
}

export interface X402Receipt {
  receiptId: string;
  txHash: string;
  blockNumber: number;
  chainId: number;
  /** Must match the submitted payment, not merely a similarly priced route. */
  paymentId: string;
  /** Correlates delivery to the server-issued challenge. */
  requestId: string;
  method: string;
  path: string;
  network: string;
  asset: string;
  amount: string;
  payer: string;
  payee: string;
  x402Version: 2;
  finality: "soft" | "safe" | "finalized";
}

/** A wallet adapter. The SDK does not prescribe a chain or wallet library. */
export interface X402PaymentSigner {
  payer: string;
  sign(message: string): Promise<string> | string;
}

export interface X402ClientOptions {
  signer?: X402PaymentSigner;
  fetch?: typeof fetch;
  /** Number of retries after the first paid request. Defaults to two. */
  maxRetries?: number;
  /** Deterministic hook for callers/tests that own retry scheduling. */
  sleep?: (milliseconds: number) => Promise<void>;
  now?: () => number;
}

export class X402HttpError extends Error {
  constructor(
    public readonly status: number,
    public readonly body: unknown,
  ) {
    super(`x402 request failed with HTTP ${status}`);
    this.name = "X402HttpError";
  }
}

/**
 * Canonical, length-delimited serialization for wallet signatures. It avoids
 * JSON key-order ambiguity and prevents delimiters in a value changing the
 * signed meaning of adjacent fields.
 */
export function canonicalX402Payment(payment: Omit<X402Payment, "signature">): string {
  const fields: Array<[string, string]> = [
    ["x402Version", String(payment.x402Version)],
    ["paymentId", payment.paymentId],
    ["requestId", payment.requestId],
    ["method", payment.method],
    ["path", payment.path],
    ["amount", payment.amount],
    ["asset", payment.asset],
    ["network", payment.network],
    ["payee", payment.payee],
    ["validAfter", String(payment.validAfter)],
    ["expiresAt", String(payment.expiresAt)],
    ["payer", payment.payer],
  ];
  return fields.map(([name, value]) => `${name}:${value.length}:${value}`).join("\n");
}

export function encodeX402Payment(payment: X402Payment): string {
  return Buffer.from(JSON.stringify(payment), "utf8").toString("base64url");
}

export function decodeX402Payment(value: string): X402Payment {
  try {
    const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
    if (!isX402PaymentShape(parsed)) throw new Error("invalid payment shape");
    return parsed;
  } catch {
    throw new Error("malformed X-Payment header");
  }
}

/**
 * JSON input is untrusted even where TypeScript describes it as X402Payment.
 * In particular, paymentId is used as a replay-protection key by servers and
 * must be a scalar canonical string, never an object/array whose identity
 * changes on each JSON parse.
 */
function isX402PaymentShape(value: unknown): value is X402Payment {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  const payment = value as Record<string, unknown>;
  const nonEmptyStrings = [
    "paymentId", "requestId", "method", "path", "amount", "asset", "network",
    "payee", "payer", "signature",
  ];
  return (
    payment.x402Version === 2 &&
    nonEmptyStrings.every((field) => typeof payment[field] === "string" && payment[field].length > 0) &&
    typeof payment.amount === "string" &&
    /^[1-9]\d*$/.test(payment.amount) &&
    typeof payment.validAfter === "number" &&
    Number.isSafeInteger(payment.validAfter) &&
    typeof payment.expiresAt === "number" &&
    Number.isSafeInteger(payment.expiresAt)
  );
}

function responseJson(response: Response): Promise<unknown> {
  return response.json().catch(() => undefined);
}

function retryable(response: Response | undefined, error: unknown): boolean {
  // Do not turn a terminal protocol response into paid retries merely because
  // it was represented as X402HttpError in the loop below.
  if (error instanceof X402HttpError) return false;
  if (error) return true; // transport failures include a server-side HTTP delivery timeout
  return !!response && response.status >= 500 && response.status <= 599;
}

/**
 * Standards-neutral x402 client flow:
 *  1. obtain a 402 challenge;
 *  2. bind and sign every challenge field;
 *  3. retry the *same signed payment* on transient facilitator/delivery
 *     failures, which lets the server return its stored receipt safely.
 */
export class X402ClientV3 {
  private readonly options: X402ClientOptions;
  private paymentCounter = 0;

  constructor(
    protected readonly cfg: VaultfireV3Config,
    options: X402ClientOptions = {},
  ) {
    this.options = options;
  }

  async pricedFetch<T = unknown>(
    input: RequestInfo | URL,
    init: RequestInit = {},
  ): Promise<{ data: T; receipt: X402Receipt }> {
    const fetchImpl = this.options.fetch ?? globalThis.fetch;
    if (!fetchImpl) throw new Error("No fetch implementation is available");
    const signer = this.options.signer;
    if (!signer) throw new Error("x402 signer is required for pricedFetch");
    if (signer.payer !== this.cfg.x402.payer) {
      throw new Error("x402 signer payer does not match configured payer");
    }

    const url = this.resolveUrl(input);
    const initial = await fetchImpl(url, init);
    if (initial.status !== 402) {
      const body = await responseJson(initial);
      if (!initial.ok) throw new X402HttpError(initial.status, body);
      throw new Error("priced endpoint returned success without a payment challenge");
    }

    const challenge = this.readChallenge(await responseJson(initial), {
      method: (init.method ?? "GET").toUpperCase(),
      path: new URL(url).pathname,
    });
    const unsigned = {
      x402Version: challenge.x402Version,
      paymentId: this.nextPaymentId(),
      requestId: challenge.requestId,
      method: challenge.method,
      path: challenge.path,
      amount: challenge.amount,
      asset: challenge.asset,
      network: challenge.network,
      payee: challenge.payee,
      validAfter: challenge.validAfter,
      expiresAt: challenge.expiresAt,
      payer: signer.payer,
    };
    const payment: X402Payment = {
      ...unsigned,
      signature: await signer.sign(canonicalX402Payment(unsigned)),
    };
    const headers = new Headers(init.headers);
    headers.set("X-Payment", encodeX402Payment(payment));

    const retries = this.options.maxRetries ?? 2;
    let lastError: unknown;
    for (let attempt = 0; attempt <= retries; attempt += 1) {
      let response: Response | undefined;
      try {
        response = await fetchImpl(url, { ...init, headers });
        const body = await responseJson(response);
        if (response.ok) {
          return {
            data: body as T,
            receipt: this.readReceipt(response, body, {
              paymentId: payment.paymentId,
              chainId: this.cfg.defaultChainId,
              requestId: challenge.requestId,
              method: challenge.method,
              path: challenge.path,
              network: challenge.network,
              asset: challenge.asset,
              amount: challenge.amount,
              payer: payment.payer,
              payee: challenge.payee,
              x402Version: challenge.x402Version,
              finality: challenge.finality,
            }),
          };
        }
        if (!retryable(response, undefined) || attempt === retries) {
          throw new X402HttpError(response.status, body);
        }
      } catch (error) {
        lastError = error;
        if (!retryable(response, error) || attempt === retries) throw error;
      }
      await (this.options.sleep ?? defaultSleep)(10 * 2 ** attempt);
    }
    throw lastError instanceof Error ? lastError : new Error("x402 retry exhausted");
  }

  private resolveUrl(input: RequestInfo | URL): string {
    if (typeof input === "string") return new URL(input, this.cfg.baseUrl).toString();
    if (input instanceof URL) return input.toString();
    return input.url;
  }

  private readChallenge(
    body: unknown,
    expected?: Pick<X402Challenge, "method" | "path">,
  ): X402Challenge {
    const challenge = (body as { x402?: unknown })?.x402 as X402Challenge | undefined;
    const required: Array<keyof X402Challenge> = [
      "x402Version", "requestId", "method", "path", "amount", "asset",
      "network", "payee", "validAfter", "expiresAt", "finality",
    ];
    if (!challenge || required.some((key) => challenge[key] === undefined || challenge[key] === null)) {
      throw new Error("402 response did not contain a complete x402 challenge");
    }
    if (challenge.x402Version !== 2) {
      throw new Error("402 response is not x402 protocol version 2");
    }
    const now = (this.options.now ?? Date.now)();
    if (
      typeof challenge.validAfter !== "number" ||
      !Number.isSafeInteger(challenge.validAfter) ||
      typeof challenge.expiresAt !== "number" ||
      !Number.isSafeInteger(challenge.expiresAt) ||
      challenge.validAfter >= challenge.expiresAt ||
      now < challenge.validAfter ||
      now >= challenge.expiresAt
    ) {
      throw new Error("402 response contained an expired or invalid x402 challenge");
    }
    if (
      typeof challenge.requestId !== "string" ||
      challenge.requestId.length === 0 ||
      typeof challenge.method !== "string" ||
      !/^[A-Z]+$/.test(challenge.method) ||
      typeof challenge.path !== "string" ||
      !challenge.path.startsWith("/") ||
      typeof challenge.amount !== "string" ||
      !/^[1-9]\d*$/.test(challenge.amount) ||
      typeof challenge.asset !== "string" ||
      challenge.asset.length === 0 ||
      typeof challenge.network !== "string" ||
      typeof challenge.payee !== "string" ||
      challenge.payee.length === 0 ||
      !["soft", "safe", "finalized"].includes(challenge.finality)
    ) {
      throw new Error("402 response contained malformed x402 payment fields");
    }
    const network = /^eip155:([1-9]\d*)$/.exec(challenge.network);
    if (!network || !Number.isSafeInteger(Number(network[1])) || Number(network[1]) !== this.cfg.defaultChainId) {
      throw new Error("402 response selected an unsupported payment network");
    }
    const configuredAsset = this.cfg.x402.assetPerChain[this.cfg.defaultChainId];
    if (
      typeof configuredAsset !== "string" ||
      configuredAsset.length === 0 ||
      challenge.asset.toLowerCase() !== configuredAsset.toLowerCase()
    ) {
      throw new Error("402 response selected an unexpected payment asset");
    }
    const configuredPayee = this.cfg.x402.payeePerChain[this.cfg.defaultChainId];
    if (
      typeof configuredPayee !== "string" ||
      configuredPayee.length === 0 ||
      challenge.payee.toLowerCase() !== configuredPayee.toLowerCase()
    ) {
      throw new Error("402 response selected an unexpected payment payee");
    }
    if (
      expected &&
      (challenge.method !== expected.method || challenge.path !== expected.path)
    ) {
      throw new Error("402 response challenge is not bound to the requested method and resource");
    }
    return challenge;
  }

  private readReceipt(
    response: Response,
    body: unknown,
    expected?: Pick<
      X402Receipt,
      "paymentId" | "chainId" | "requestId" | "method" | "path" | "network" | "asset" |
      "amount" | "payer" | "payee" | "x402Version" | "finality"
    >,
  ): X402Receipt {
    const header = response.headers.get("X-Payment-Receipt");
    let receipt: unknown;
    try {
      receipt = header
        ? JSON.parse(Buffer.from(header, "base64url").toString("utf8"))
        : (body as { receipt?: unknown })?.receipt;
    } catch {
      throw new Error("x402 response contained a malformed receipt");
    }
    if (!receipt || typeof receipt !== "object") throw new Error("x402 response did not contain a receipt");
    const candidate = receipt as Partial<X402Receipt>;
    if (
      typeof candidate.receiptId !== "string" ||
      candidate.receiptId.length === 0 ||
      typeof candidate.txHash !== "string" ||
      !/^0x[0-9a-fA-F]{64}$/.test(candidate.txHash) ||
      typeof candidate.blockNumber !== "number" ||
      !Number.isSafeInteger(candidate.blockNumber) ||
      candidate.blockNumber < 0 ||
      typeof candidate.chainId !== "number" ||
      !Number.isSafeInteger(candidate.chainId) ||
      candidate.chainId <= 0 ||
      typeof candidate.paymentId !== "string" ||
      typeof candidate.requestId !== "string" ||
      typeof candidate.method !== "string" ||
      typeof candidate.path !== "string" ||
      typeof candidate.network !== "string" ||
      typeof candidate.asset !== "string" ||
      typeof candidate.amount !== "string" ||
      typeof candidate.payer !== "string" ||
      typeof candidate.payee !== "string" ||
      candidate.x402Version !== 2 ||
      !["soft", "safe", "finalized"].includes(candidate.finality ?? "")
    ) {
      throw new Error("x402 response contained an incomplete receipt");
    }
    if (expected && Object.entries(expected).some(([key, value]) => candidate[key as keyof X402Receipt] !== value)) {
      throw new Error("x402 response receipt is not bound to the submitted payment");
    }
    return candidate as X402Receipt;
  }

  private nextPaymentId(): string {
    this.paymentCounter += 1;
    const now = (this.options.now ?? Date.now)();
    return `sdk-${now.toString(36)}-${this.paymentCounter.toString(36)}`;
  }
}

function defaultSleep(milliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
