/*
 * Wires a SpendControls configuration onto an upstream x402Client.
 */

import { PaymentRequirementsV1Schema, PaymentRequirementsV2Schema } from "@x402/core/schemas";

import { normalizeAsset, normalizeNetwork, normalizePayee } from "./normalize.js";
import { SpendTracker } from "./spend-tracker.js";
import {
  parseAmount,
  parseDuration,
  SpendControlError,
  type Address,
  type Amount,
  type Asset,
  type SpendControlErrorCode,
  type SpendControls,
  type SpendLedgerEntry,
} from "./types.js";

import type {
  AfterPaymentCreationHook,
  BeforePaymentCreationHook,
  OnPaymentCreationFailureHook,
  OnPaymentResponseHook,
  PaymentPolicy,
  x402Client,
} from "@x402/core/client";
import type { Network, PaymentPayload, PaymentRequirements } from "@x402/core/types";

/** Default thresholds (as fractions of the cap) for `onApproachingLimit`. */
export const DEFAULT_APPROACHING_LIMIT_THRESHOLDS: readonly number[] = [0.8, 0.95];

/**
 * Hard ceiling on `PaymentRequirements.maxTimeoutSeconds`, independent of
 * whatever value the resource server sends. `maxTimeoutSeconds` feeds
 * directly into the EIP-3009 `validBefore` the wallet signs, so an
 * unbounded server-supplied value could grant an authorization an
 * arbitrarily long redemption window.
 */
export const MAX_PAYMENT_TIMEOUT_SECONDS = 3600;

const STORE_ASSET_MUTEX = new WeakMap<object, Map<string, Promise<unknown>>>();
const STORE_PROVISIONAL_ATOMIC = new WeakMap<object, Map<string, bigint>>();
const APPLIED_MARKER = Symbol.for("@coinbase/cdp-sdk/x402/spend-controls/applied");
const RECORDED_ENTRY = Symbol("@coinbase/cdp-sdk/x402/spend-controls/recorded-entry");

/**
 * Symbol-keyed property on a client that carries its settlement-aware confirm/rollback registry.
 */
export const SPEND_CONTROLS_REGISTRY = Symbol.for("@coinbase/cdp-sdk/x402/spend-controls/registry");

type PendingPayment = {
  entry: SpendLedgerEntry;
  notifyKey: string;
  reqAsset: string;
  notifiedThresholds: Set<number>;
};

/**
 * Settlement-aware finalization handlers attached to a client by {@link applySpendControls}.
 */
export interface SpendControlsRegistry {
  /**
   * Mark a previously-created payment as settled on-chain.
   *
   * @param paymentPayload - The payment payload to confirm.
   */
  confirm(paymentPayload: PaymentPayload): Promise<void>;
  /**
   * Undo a previously-recorded provisional spend entry.
   *
   * Only safe for a payload that was never transmitted — a sent authorization
   * is independently redeemable and can't be safely un-counted. Not called by
   * the SDK's own `onPaymentResponse` wiring; for callers with out-of-band
   * knowledge that a specific payload was never sent.
   *
   * @param paymentPayload - The payment payload to roll back.
   */
  rollback(paymentPayload: PaymentPayload): Promise<void>;
}

/**
 * The active spend controls configuration after defaults are filled in.
 */
export type ResolvedSpendControls = Readonly<{
  maxAmountPerPayment?: { atomic: bigint; asset?: string };
  maxCumulativeSpend?: { atomic: bigint; asset?: string };
  maxCumulativeSpendWindowMs?: number;
  allowedNetworks: ReadonlySet<Network>;
  allowedAssets: ReadonlySet<Asset>;
  allowedPayees: ReadonlySet<Address>;
  approachingLimitThresholds: ReadonlyArray<number>;
  onApproachingLimit?: (spent: Amount, limit: Amount) => void;
  tracker: SpendTracker;
}>;

const FILTER_FAILURE_NETWORK_NOT_ALLOWED = "network_not_allowed" satisfies SpendControlErrorCode;
const FILTER_FAILURE_ASSET_NOT_ALLOWED = "asset_not_allowed" satisfies SpendControlErrorCode;
const FILTER_FAILURE_PAYEE_NOT_ALLOWED = "payee_not_allowed" satisfies SpendControlErrorCode;

type PolicyFilterFailure =
  | typeof FILTER_FAILURE_NETWORK_NOT_ALLOWED
  | typeof FILTER_FAILURE_ASSET_NOT_ALLOWED
  | typeof FILTER_FAILURE_PAYEE_NOT_ALLOWED;

type FilterRule = {
  readonly code: PolicyFilterFailure;
  readonly message: string;
  readonly errorPriority: number;
  readonly check: (req: PaymentRequirements) => boolean;
};

const pinGuardrailsBeforeHookLast = (
  client: x402Client,
  guardrailsHook: BeforePaymentCreationHook,
): void => {
  /*
   * Access the internal hooks array to keep the spend-controls hook last so that
   * any subsequently registered hooks (e.g. balance check) run before our cap check.
   * This relies on x402Client's private `beforePaymentCreationHooks` array name.
   * If @x402/core renames that field we can no longer guarantee ordering; the
   * spend-control hook still fires (it is already registered), just not
   * necessarily last, so we warn rather than fail. The characterization test in
   * apply.test.ts pins this assumption and turns an upstream rename into a red
   * test at dependency-bump time.
   */
  type WithHookArray = { beforePaymentCreationHooks?: BeforePaymentCreationHook[] };
  const hooks = (client as unknown as WithHookArray).beforePaymentCreationHooks;
  if (!Array.isArray(hooks)) {
    // eslint-disable-next-line no-console
    console.warn(
      "[@coinbase/cdp-sdk/x402] Unable to pin the spend-control hook last: the x402Client no " +
        "longer exposes its `beforePaymentCreationHooks` array (likely an incompatible @x402/core " +
        "version). Spend controls remain active, but may not run after other before-payment hooks.",
    );
    return;
  }

  const original = client.onBeforePaymentCreation.bind(client);
  (client as unknown as { onBeforePaymentCreation: unknown }).onBeforePaymentCreation = (
    hook: BeforePaymentCreationHook,
  ): x402Client => {
    const idx = hooks.indexOf(guardrailsHook);
    if (idx === -1) return original(hook);
    hooks.splice(idx, 0, hook);
    return client;
  };
};

const buildAllowedSet = <T extends string>(
  values: T[] | undefined,
  normalize: (value: T) => T,
): ReadonlySet<T> => {
  if (!values || values.length === 0) return new Set();
  return new Set(values.map(normalize));
};

const dedupeThresholds = (thresholds: number[] | undefined): number[] => {
  const fallback = DEFAULT_APPROACHING_LIMIT_THRESHOLDS;
  const source = thresholds && thresholds.length > 0 ? thresholds : fallback;
  return Array.from(new Set(source.filter(t => Number.isFinite(t) && t > 0 && t <= 1))).sort(
    (a, b) => a - b,
  );
};

const assetMatches = (capAsset: string | undefined, requirementAsset: string): boolean => {
  if (!capAsset) return true;
  return normalizeAsset(capAsset) === normalizeAsset(requirementAsset);
};

const getStoreMutex = (store: object): Map<string, Promise<unknown>> => {
  const existing = STORE_ASSET_MUTEX.get(store);
  if (existing) return existing;
  const created = new Map<string, Promise<unknown>>();
  STORE_ASSET_MUTEX.set(store, created);
  return created;
};

/**
 * Extracts the atomic payment amount from a requirement, keyed on the
 * negotiated x402 version (v1 signs `maxAmountRequired`, v2 signs `amount`)
 * rather than field presence — a spoofed second field is never read.
 *
 * Also rejects the requirement outright (as malformed) if:
 *  - it carries both `amount` and `maxAmountRequired` — a well-formed
 *    single-version response never carries both;
 *  - it fails to validate against the version-specific `@x402/core/schemas`
 *    shape, so unexpected/missing fields are caught before signing;
 *  - its `maxTimeoutSeconds` exceeds {@link MAX_PAYMENT_TIMEOUT_SECONDS},
 *    since that value is used directly to compute the signed authorization's
 *    validity window.
 *
 * @param req - The selected payment requirement.
 * @param x402Version - The negotiated x402 protocol version.
 * @returns The atomic amount that will actually be signed.
 */
const parseAtomicFromRequirement = (req: PaymentRequirements, x402Version: number): bigint => {
  const untyped = req as unknown as { amount?: string; maxAmountRequired?: string };
  const hasAmount = "amount" in untyped && untyped.amount !== undefined;
  const hasMaxAmountRequired =
    "maxAmountRequired" in untyped && untyped.maxAmountRequired !== undefined;
  if (hasAmount && hasMaxAmountRequired) {
    throw new SpendControlError(
      "malformed_requirement",
      "PaymentRequirements must not include both `amount` (v2) and `maxAmountRequired` (v1) " +
        "in the same object — a well-formed single-version response never carries both.",
      { asset: req.asset, network: req.network, payTo: req.payTo },
    );
  }

  let raw: string;
  let maxTimeoutSeconds: number;
  if (x402Version === 1) {
    const validated = PaymentRequirementsV1Schema.safeParse(req);
    if (!validated.success) {
      throw new SpendControlError(
        "malformed_requirement",
        `PaymentRequirements failed v1 schema validation: ${validated.error.message}`,
        { asset: req.asset, network: req.network, payTo: req.payTo },
      );
    }
    raw = validated.data.maxAmountRequired;
    maxTimeoutSeconds = validated.data.maxTimeoutSeconds;
  } else {
    const validated = PaymentRequirementsV2Schema.safeParse(req);
    if (!validated.success) {
      throw new SpendControlError(
        "malformed_requirement",
        `PaymentRequirements failed v2 schema validation: ${validated.error.message}`,
        { asset: req.asset, network: req.network, payTo: req.payTo },
      );
    }
    raw = validated.data.amount;
    maxTimeoutSeconds = validated.data.maxTimeoutSeconds;
  }

  if (maxTimeoutSeconds > MAX_PAYMENT_TIMEOUT_SECONDS) {
    throw new SpendControlError(
      "max_timeout_exceeded",
      `PaymentRequirements maxTimeoutSeconds ${maxTimeoutSeconds} exceeds the maximum allowed ${MAX_PAYMENT_TIMEOUT_SECONDS}s`,
      {
        attempted: String(maxTimeoutSeconds),
        limit: String(MAX_PAYMENT_TIMEOUT_SECONDS),
        asset: req.asset,
        network: req.network,
        payTo: req.payTo,
      },
    );
  }

  let parsed: bigint;
  try {
    parsed = BigInt(raw);
  } catch {
    throw new SpendControlError(
      "amount_unparseable",
      `PaymentRequirements amount ${JSON.stringify(raw)} is not a valid atomic integer`,
      {
        attempted: String(raw),
        asset: req.asset,
        network: req.network,
        payTo: req.payTo,
      },
    );
  }
  if (parsed < 0n) {
    throw new SpendControlError(
      "amount_unparseable",
      `PaymentRequirements amount ${JSON.stringify(raw)} must be a non-negative atomic integer`,
      {
        attempted: parsed.toString(),
        asset: req.asset,
        network: req.network,
        payTo: req.payTo,
      },
    );
  }
  return parsed;
};

/**
 * Attach a {@link SpendControls} configuration to an `x402Client`.
 *
 * Can only be called once per client — a second call throws
 * `SpendControlError` (`code: "already_applied"`).
 *
 * @param client - The client to attach controls to.
 * @param controls - The spend controls configuration to apply.
 * @returns A frozen snapshot of the resolved configuration.
 */
export function applySpendControls(
  client: x402Client,
  controls: SpendControls,
): ResolvedSpendControls {
  const taggedClient = client as unknown as Record<symbol, unknown>;
  if (taggedClient[APPLIED_MARKER]) {
    throw new SpendControlError(
      "already_applied",
      "applySpendControls() was called more than once on the same x402Client. " +
        "Each client must have exactly one set of spend controls.",
    );
  }

  const maxAmountPerPayment = controls.maxAmountPerPayment
    ? parseAmount(controls.maxAmountPerPayment.atomic, controls.maxAmountPerPayment.asset)
    : undefined;
  const maxCumulativeSpend = controls.maxCumulativeSpend
    ? parseAmount(controls.maxCumulativeSpend.atomic, controls.maxCumulativeSpend.asset)
    : undefined;
  const maxCumulativeSpendWindowMs =
    controls.maxCumulativeSpendWindow !== undefined
      ? parseDuration(controls.maxCumulativeSpendWindow)
      : undefined;

  if (maxCumulativeSpend && !maxCumulativeSpend.asset) {
    throw new SpendControlError(
      "amount_unparseable",
      "maxCumulativeSpend requires an `asset` — cross-asset atomic units cannot be summed.",
      { input: controls.maxCumulativeSpend as unknown },
    );
  }

  const allowedNetworks = buildAllowedSet(controls.allowedNetworks, n => normalizeNetwork(n));
  const allowedAssets = buildAllowedSet(controls.allowedAssets, normalizeAsset);
  const rawAllowedPayees = controls.allowedPayees ?? [];
  const normalizedAllowedPayeesByNetwork = new Map<string, ReadonlySet<Address>>();

  const thresholds = dedupeThresholds(controls.approachingLimitThresholds);

  const tracker = new SpendTracker({
    maxLedgerEntries: controls.maxLedgerEntries,
    store: controls.store,
  });

  const notifiedThresholdsByAsset = new Map<string, Set<number>>();

  const getNotified = (asset: string): Set<number> => {
    let set = notifiedThresholdsByAsset.get(asset);
    if (!set) {
      set = new Set();
      notifiedThresholdsByAsset.set(asset, set);
    }
    return set;
  };

  /*
   * Dual-lookup structure for pending payments.
   *
   * `pendingByPayload` is the primary fast path keyed on object identity.
   * `pendingByFingerprint` is the fallback keyed on payload content — it
   * handles the case where a transport or wrapper clones the payload object
   * before passing it back through `onPaymentResponse` (e.g. JSON round-trip,
   * shallow spread). Without this, rollback/confirm would silently no-op on
   * a cloned payload, leaving provisional spend permanently over-counted.
   *
   * A fingerprint is built from the canonically serialized `accepted`
   * requirements and `payload` content. In production, x402 signatures always
   * include a nonce (EIP-3009 nonce / Solana blockhash), so two independent
   * payment operations for the same amount will produce distinct fingerprints.
   * The queue handles the rare edge case of identical fingerprints gracefully
   * by popping the oldest entry first.
   */
  const pendingByPayload = new WeakMap<PaymentPayload, PendingPayment>();
  const pendingByFingerprint = new Map<string, PendingPayment[]>();

  const buildPayloadFingerprint = (p: PaymentPayload): string | null => {
    try {
      return JSON.stringify({ accepted: p.accepted, payload: p.payload });
    } catch {
      return null;
    }
  };

  const registerPending = (paymentPayload: PaymentPayload, pending: PendingPayment): void => {
    pendingByPayload.set(paymentPayload, pending);
    const fp = buildPayloadFingerprint(paymentPayload);
    if (fp === null) return;
    const queue = pendingByFingerprint.get(fp);
    if (queue) {
      queue.push(pending);
    } else {
      pendingByFingerprint.set(fp, [pending]);
    }
  };

  const lookupPending = (paymentPayload: PaymentPayload): PendingPayment | undefined => {
    const byIdentity = pendingByPayload.get(paymentPayload);
    if (byIdentity !== undefined) return byIdentity;
    const fp = buildPayloadFingerprint(paymentPayload);
    if (fp === null) return undefined;
    return pendingByFingerprint.get(fp)?.[0];
  };

  const unregisterPending = (paymentPayload: PaymentPayload, pending: PendingPayment): void => {
    pendingByPayload.delete(paymentPayload);
    const fp = buildPayloadFingerprint(paymentPayload);
    if (fp === null) return;
    const queue = pendingByFingerprint.get(fp);
    if (!queue) return;
    const idx = queue.indexOf(pending);
    if (idx >= 0) queue.splice(idx, 1);
    if (queue.length === 0) pendingByFingerprint.delete(fp);
  };

  const localAssetMutex = new Map<string, Promise<unknown>>();
  const sharedStoreKey = controls.store as object | undefined;
  const localProvisionalAtomicByAsset = new Map<string, bigint>();

  const getProvisionalMap = (): Map<string, bigint> => {
    if (!sharedStoreKey) return localProvisionalAtomicByAsset;
    let map = STORE_PROVISIONAL_ATOMIC.get(sharedStoreKey);
    if (!map) {
      map = new Map<string, bigint>();
      STORE_PROVISIONAL_ATOMIC.set(sharedStoreKey, map);
    }
    return map;
  };

  const withAssetLock = <T>(asset: string, fn: () => Promise<T>): Promise<T> => {
    const lockMap = sharedStoreKey ? getStoreMutex(sharedStoreKey) : localAssetMutex;
    const prev = lockMap.get(asset) ?? Promise.resolve();
    const next = prev.then(fn, fn);
    lockMap.set(
      asset,
      next.catch(() => {}),
    );
    return next;
  };

  const readRecordedEntry = (ctx: object): SpendLedgerEntry | undefined =>
    (ctx as Record<symbol, SpendLedgerEntry | undefined>)[RECORDED_ENTRY];
  const writeRecordedEntry = (ctx: object, entry: SpendLedgerEntry): void => {
    (ctx as Record<symbol, SpendLedgerEntry>)[RECORDED_ENTRY] = entry;
  };
  const clearRecordedEntry = (ctx: object): void => {
    delete (ctx as Record<symbol, SpendLedgerEntry | undefined>)[RECORDED_ENTRY];
  };

  const filterRules: ReadonlyArray<FilterRule> = [
    {
      code: FILTER_FAILURE_NETWORK_NOT_ALLOWED,
      message: "All payment requirements were filtered out by allowedNetworks",
      errorPriority: 2,
      check: req => {
        if (allowedNetworks.size === 0) return true;
        try {
          return allowedNetworks.has(normalizeNetwork(req.network));
        } catch {
          return false;
        }
      },
    },
    {
      code: FILTER_FAILURE_ASSET_NOT_ALLOWED,
      message: "All payment requirements were filtered out by allowedAssets",
      errorPriority: 0,
      check: req => {
        if (allowedAssets.size === 0) return true;
        return allowedAssets.has(normalizeAsset(req.asset));
      },
    },
    {
      code: FILTER_FAILURE_PAYEE_NOT_ALLOWED,
      message: "All payment requirements were filtered out by allowedPayees",
      errorPriority: 1,
      check: req => {
        if (rawAllowedPayees.length === 0) return true;
        const normalizedPayee = normalizePayee(req.network, req.payTo);
        let canonicalReqNetwork: Network;
        try {
          canonicalReqNetwork = normalizeNetwork(req.network);
        } catch {
          return false;
        }
        let normalizedAllowList = normalizedAllowedPayeesByNetwork.get(canonicalReqNetwork);
        if (!normalizedAllowList) {
          normalizedAllowList = new Set(
            rawAllowedPayees.map(payee => normalizePayee(canonicalReqNetwork, payee)),
          );
          normalizedAllowedPayeesByNetwork.set(canonicalReqNetwork, normalizedAllowList);
        }
        return normalizedAllowList.has(normalizedPayee);
      },
    },
  ];

  const policy: PaymentPolicy = (_x402Version, paymentRequirements) => {
    const perOptionFailures: PolicyFilterFailure[] = [];

    const filtered = paymentRequirements.filter(req => {
      for (const rule of filterRules) {
        if (!rule.check(req)) {
          perOptionFailures.push(rule.code);
          return false;
        }
      }
      return true;
    });

    if (filtered.length === 0 && perOptionFailures.length > 0) {
      const byPriority = [...filterRules].sort((a, b) => a.errorPriority - b.errorPriority);
      for (const { code, message } of byPriority) {
        if (perOptionFailures.includes(code)) {
          throw new SpendControlError(code, message);
        }
      }
    }

    return filtered;
  };
  client.registerPolicy(policy);

  const beforeHook: BeforePaymentCreationHook = async context => {
    const req = context.selectedRequirements;
    const atomic = parseAtomicFromRequirement(req, context.paymentRequired.x402Version);
    const normalizedReqAsset = normalizeAsset(req.asset);

    if (
      maxAmountPerPayment &&
      atomic > maxAmountPerPayment.atomic &&
      assetMatches(maxAmountPerPayment.asset, req.asset)
    ) {
      throw new SpendControlError(
        "per_payment_cap",
        `Payment amount ${atomic} exceeds per-payment cap ${maxAmountPerPayment.atomic}` +
          (maxAmountPerPayment.asset ? ` for asset ${maxAmountPerPayment.asset}` : ""),
        {
          attempted: atomic.toString(),
          limit: maxAmountPerPayment.atomic.toString(),
          asset: req.asset,
          network: req.network,
          payTo: req.payTo,
        },
      );
    }

    if (maxCumulativeSpend && assetMatches(maxCumulativeSpend.asset, req.asset)) {
      const lockKey = normalizedReqAsset;
      await withAssetLock(lockKey, async () => {
        const now = Date.now();
        if (maxCumulativeSpendWindowMs !== undefined) {
          await tracker.prune(now, maxCumulativeSpendWindowMs);
        }
        const since =
          maxCumulativeSpendWindowMs !== undefined ? now - maxCumulativeSpendWindowMs : undefined;
        const total = await tracker.total({ asset: normalizedReqAsset, since });
        if (total + atomic > maxCumulativeSpend.atomic) {
          throw new SpendControlError(
            "cumulative_cap",
            `Payment of ${atomic} would push cumulative spend to ${total + atomic}, exceeding cap ${maxCumulativeSpend.atomic}` +
              (maxCumulativeSpend.asset ? ` for asset ${maxCumulativeSpend.asset}` : "") +
              (maxCumulativeSpendWindowMs ? ` within ${maxCumulativeSpendWindowMs}ms window` : ""),
            {
              attempted: (total + atomic).toString(),
              limit: maxCumulativeSpend.atomic.toString(),
              asset: req.asset,
              network: req.network,
              payTo: req.payTo,
            },
          );
        }
        const entry = await tracker.record({
          atomicAmount: atomic,
          asset: normalizedReqAsset,
          network: req.network,
          payTo: normalizePayee(req.network, req.payTo),
        });
        writeRecordedEntry(context as unknown as object, entry);
        const pMap = getProvisionalMap();
        pMap.set(normalizedReqAsset, (pMap.get(normalizedReqAsset) ?? 0n) + atomic);
      });
    }

    return undefined;
  };
  client.onBeforePaymentCreation(beforeHook);
  pinGuardrailsBeforeHookLast(client, beforeHook);

  const fireThresholdsForPayment = async (
    req: PaymentRequirements,
    entry: SpendLedgerEntry,
    notifyKey: string,
  ): Promise<Set<number>> => {
    const fired = new Set<number>();
    await withAssetLock(notifyKey, async () => {
      const pMap = getProvisionalMap();
      const prevProv = pMap.get(notifyKey) ?? 0n;
      pMap.set(notifyKey, prevProv >= entry.atomicAmount ? prevProv - entry.atomicAmount : 0n);

      const notify = controls.onApproachingLimit;
      if (!maxCumulativeSpend || !notify || !assetMatches(maxCumulativeSpend.asset, req.asset)) {
        return;
      }
      const limit = maxCumulativeSpend.atomic;
      if (limit <= 0n) return;

      const now = Date.now();
      if (maxCumulativeSpendWindowMs !== undefined) {
        await tracker.prune(now, maxCumulativeSpendWindowMs);
      }
      const since =
        maxCumulativeSpendWindowMs !== undefined ? now - maxCumulativeSpendWindowMs : undefined;

      const trackerTotal = await tracker.total({ asset: notifyKey, since });
      const provisional = pMap.get(notifyKey) ?? 0n;
      const confirmedAfter = trackerTotal - provisional;
      const confirmedBefore = confirmedAfter - entry.atomicAmount;

      const notified = getNotified(notifyKey);
      for (const threshold of thresholds) {
        const scaledThreshold = BigInt(Math.round(threshold * 1_000_000));
        const lhsBefore = confirmedBefore * 1_000_000n;
        const lhsAfterThis = confirmedAfter * 1_000_000n;
        const rhs = scaledThreshold * limit;
        const wasBelow = lhsBefore < rhs;
        const nowAtOrAbove = lhsAfterThis >= rhs;
        if (wasBelow) notified.delete(threshold);
        if (wasBelow && nowAtOrAbove && !notified.has(threshold)) {
          notified.add(threshold);
          fired.add(threshold);
          try {
            notify(
              { atomic: confirmedAfter, asset: req.asset },
              { atomic: limit, asset: maxCumulativeSpend.asset ?? req.asset },
            );
          } catch (e) {
            // eslint-disable-next-line no-console
            console.warn("[@coinbase/cdp-sdk/x402] onApproachingLimit threw:", e);
          }
        }
      }
    });
    return fired;
  };

  const rollbackEntry = async (pending: PendingPayment): Promise<void> => {
    await withAssetLock(pending.notifyKey, async () => {
      const pMap = getProvisionalMap();
      const prev = pMap.get(pending.notifyKey) ?? 0n;
      pMap.set(
        pending.notifyKey,
        prev >= pending.entry.atomicAmount ? prev - pending.entry.atomicAmount : 0n,
      );
      try {
        await tracker.removeEntry(pending.entry);
      } catch (e) {
        // eslint-disable-next-line no-console
        console.warn("[@coinbase/cdp-sdk/x402] SpendTracker rollback failed:", e);
      }
    });
    if (pending.notifiedThresholds.size > 0) {
      const notified = getNotified(pending.notifyKey);
      for (const t of pending.notifiedThresholds) {
        notified.delete(t);
      }
    }
  };

  const afterHook: AfterPaymentCreationHook = async context => {
    const recordedEntry = readRecordedEntry(context as unknown as object);
    clearRecordedEntry(context as unknown as object);
    if (!recordedEntry) return;
    registerPending(context.paymentPayload, {
      entry: recordedEntry,
      notifyKey: normalizeAsset(context.selectedRequirements.asset),
      reqAsset: context.selectedRequirements.asset,
      notifiedThresholds: new Set(),
    });
  };
  client.onAfterPaymentCreation(afterHook);

  const failureHook: OnPaymentCreationFailureHook = async context => {
    const recordedEntry = readRecordedEntry(context as unknown as object);
    if (!recordedEntry) return undefined;
    clearRecordedEntry(context as unknown as object);
    const notifyKey = normalizeAsset((context.selectedRequirements as PaymentRequirements).asset);
    await withAssetLock(notifyKey, async () => {
      const pMap = getProvisionalMap();
      const prev = pMap.get(notifyKey) ?? 0n;
      pMap.set(
        notifyKey,
        prev >= recordedEntry.atomicAmount ? prev - recordedEntry.atomicAmount : 0n,
      );
      try {
        await tracker.removeEntry(recordedEntry);
      } catch (e) {
        // eslint-disable-next-line no-console
        console.warn("[@coinbase/cdp-sdk/x402] SpendTracker rollback failed:", e);
      }
    });
    return undefined;
  };
  client.onPaymentCreationFailure(failureHook);

  const registry: SpendControlsRegistry = {
    async confirm(paymentPayload) {
      const pending = lookupPending(paymentPayload);
      if (!pending) return;
      unregisterPending(paymentPayload, pending);
      const fired = await fireThresholdsForPayment(
        {
          asset: pending.reqAsset,
          network: pending.entry.network,
        } as PaymentRequirements,
        pending.entry,
        pending.notifyKey,
      );
      pending.notifiedThresholds = fired;
    },
    async rollback(paymentPayload) {
      const pending = lookupPending(paymentPayload);
      if (!pending) return;
      unregisterPending(paymentPayload, pending);
      await rollbackEntry(pending);
    },
  };
  (taggedClient as Record<symbol, unknown>)[SPEND_CONTROLS_REGISTRY] = registry;

  /*
   * Always confirm: by the time this fires the authorization is already
   * transmitted and independently redeemable, so a self-reported failure
   * isn't a trustworthy reason to release counted spend.
   */
  const paymentResponseHook: OnPaymentResponseHook = async ctx => {
    await registry.confirm(ctx.paymentPayload);
  };
  client.onPaymentResponse(paymentResponseHook);

  taggedClient[APPLIED_MARKER] = true;

  return Object.freeze({
    maxAmountPerPayment,
    maxCumulativeSpend,
    maxCumulativeSpendWindowMs,
    allowedNetworks,
    allowedAssets,
    /*
     * The snapshot returns trimmed raw payee strings. Actual enforcement uses
     * normalizePayee() per-network at check time (see the filterRules payee check above).
     */
    allowedPayees: new Set(rawAllowedPayees.map(p => p.trim())),
    approachingLimitThresholds: thresholds,
    onApproachingLimit: controls.onApproachingLimit,
    tracker,
  });
}

/**
 * Look up the settlement-aware {@link SpendControlsRegistry} attached to an
 * `x402Client` by {@link applySpendControls}.
 *
 * @param client - The x402Client to look up the registry for.
 * @returns The registry, or undefined if spend controls were not applied.
 */
export function getSpendControlsRegistry(client: x402Client): SpendControlsRegistry | undefined {
  const tagged = client as unknown as Record<symbol, SpendControlsRegistry | undefined>;
  return tagged[SPEND_CONTROLS_REGISTRY];
}
