// ROKI Connect - Swift client.
//
// Generated from openapi.yaml v2.0.0. Do not edit by hand: run sdk/generate.mjs.
// Swift 5.9, Foundation and CryptoKit only. No packages, no build settings, one file.
//
// WHAT THIS FILE IS NOT
//
// This is an API client, not a payment SDK. It never sees a card number, it charges nothing on
// the device, and it does not present or complete a 3-D Secure challenge. Card entry always
// happens on a ROKI surface:
//
//   * Hosted checkout (mode 1): open the `checkout_url` that comes back, in
//     SFSafariViewController or ASWebAuthenticationSession. Do not rebuild that form.
//   * Embedded components (mode 2): the card fields live in a ROKI iframe on the web, and the
//     confirm call is a server call. A 3-D Secure challenge comes back as status `pending`
//     with an `authentication_url` the customer has to load; the outcome then arrives by
//     webhook, not as the return value of anything here.
//   * Landing on `success_url` proves nothing - anyone can open that URL. Confirm with
//     `getPayment` or with the `payment.approved` webhook.
//
// WHERE THE SECRET KEY LIVES
//
// `sk_live_...` is a bearer credential for the merchant's whole account. A key compiled
// into an app, sitting in an .xcconfig or in Info.plist, or fetched into the app at runtime, is an
// extracted key: `strings` on the .ipa finds it in seconds, and the Keychain does not help,
// because the app has to be able to read it back. Put this file in your backend - Vapor, a macOS
// tool, a script - and let the app talk to that backend. Reaching for this file from an iOS target
// is the sign that what you need is a server endpoint.
//
//   let roki = try RokiConnect(secretKey: ProcessInfo.processInfo.environment["ROKI_SECRET_KEY"] ?? "")
//   let payment = try await roki.createPayment([
//       "amount": 150.00,                        // decimal units - L 150.00, never cents
//       "external_reference": String(order.id),
//       "name": "Order #\(order.id)",
//   ])
//   guard let link = payment["checkout_url"]?.string, let checkout = URL(string: link) else { return }
//
// The environment is the key: sk_test_ is sandbox, sk_live_ is production. Same routes.

import Foundation

// @preconcurrency: on Apple platforms URLSession is Sendable, on Linux swift-corelibs-foundation
// has not marked it yet. Without this the Linux build fills with Sendable warnings about a type
// this file does not own and cannot fix.
#if canImport(FoundationNetworking)
@preconcurrency import FoundationNetworking      // Linux: URLSession ships in its own module
#endif

// CryptoKit on Apple platforms, swift-crypto on Linux. The HMAC API is identical on purpose, so
// webhook verification is the same source in a Vapor service as in a macOS tool.
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif

#if canImport(OSLog)
import OSLog
#endif

// MARK: - Errors

/// Everything this client raises: the local checks that run before a request leaves, and whatever
/// the API answered when it was not a 2xx.
///
/// Match on the case you care about:
///
///     catch RokiError.api(let status, _, _, _, _) where status == 422 { ... }
///
/// or read `localizedDescription`, which spells the whole thing out in one sentence.
public enum RokiError: Error {
    /// No secret key was given.
    case missingSecretKey

    /// A `pk_` publishable key was given. It authenticates nothing on this API.
    case publishableKey

    /// Fields the operation requires that the body does not carry.
    case missingRequiredFields(operation: String, fields: [String])

    /// A field name this API does not define. ROKI would answer 201 and ignore it, so the request
    /// is refused here instead. `alias` is filled when the name belongs to another gateway,
    /// `suggestion` when it is close to a real one.
    case unknownField(operation: String, field: String, alias: String?, suggestion: String?)

    /// A value in the body has no JSON form - a `Date`, a nil `Optional`, a model
    /// object. `field` is the key path that carries it.
    case unencodableBody(operation: String, field: String, reason: String)

    /// The base URL and the path did not make a URL. Almost always a `baseURL` typo.
    case invalidURL(String)

    /// The request never completed: no network, DNS, TLS, timeout, or a cancelled Task.
    case transport(method: String, path: String, underlying: any Error)

    /// A 2xx that was not JSON on a route the contract says is JSON, or a body that could not be
    /// parsed. The bytes are attached. The routes that answer something else on purpose - the
    /// receipt PDF - return `Data` from their own method and never land here.
    case unexpectedResponseBody(method: String, path: String, status: Int, contentType: String?, data: Data)

    /// The API answered with a status outside 2xx.
    case api(status: Int, message: String, method: String, path: String, response: RokiJSON)

    /// HTTP status, or 0 when the failure happened before there was a response.
    public var status: Int {
        switch self {
        case .api(let status, _, _, _, _): return status
        case .unexpectedResponseBody(_, _, let status, _, _): return status
        default: return 0
        }
    }

    /// The parsed error body, when there was one.
    public var response: RokiJSON? {
        if case .api(_, _, _, _, let response) = self { return response }
        return nil
    }

    /// Field-level validation errors, when the API sent them. The keys are stable; the messages
    /// are localised by `Accept-Language`, so branch on the keys and show the text.
    public var validationErrors: [String: [String]] {
        guard let errors = response?["errors"]?.object else { return [:] }
        return errors.reduce(into: [String: [String]]()) { out, pair in
            if let list = pair.value.array {
                out[pair.key] = list.compactMap { $0.string }
            } else if let single = pair.value.string {
                out[pair.key] = [single]
            }
        }
    }
}

extension RokiError: LocalizedError {
    public var errorDescription: String? {
        switch self {
        case .missingSecretKey:
            return "Missing secret key. Read it from the environment or your server's secret store, "
                 + "never hard-code it and never ship it inside an app binary."
        case .publishableKey:
            return "That is a publishable key. Server calls need the sk_ secret key."
        case .missingRequiredFields(let operation, let fields):
            return "\(operation): missing required field(s): \(fields.joined(separator: ", "))"
        case .unknownField(let operation, let field, let alias, let suggestion):
            var text = "\(operation): \"\(field)\" is not a field of this API, and ROKI would "
                     + "accept the request and ignore it."
            if let alias {
                text += " Coming from another gateway? \(alias)"
            } else if let suggestion {
                text += " Did you mean \"\(suggestion)\"?"
            }
            return text
        case .unencodableBody(let operation, let field, let reason):
            return "\(operation): \"\(field)\" cannot be sent as JSON. \(reason)"
        case .invalidURL(let text):
            return "\(text) is not a valid URL. Check the baseURL in the configuration."
        case .transport(let method, let path, let underlying):
            return "Network error calling \(method) \(path): \(underlying.localizedDescription)"
        case .unexpectedResponseBody(let method, let path, let status, let contentType, let data):
            return "Non-JSON response from \(method) \(path) (HTTP \(status), "
                 + "\(contentType ?? "no content type"), \(data.count) bytes). "
                 + "The bytes are attached to this error."
        case .api(let status, let message, let method, let path, _):
            return "ROKI \(method) \(path) failed with \(status): \(message)"
        }
    }
}

// MARK: - JSON

/// A JSON value exactly as the API sent it.
///
/// Responses are not decoded into a generated model per endpoint, and that is deliberate: this API
/// adds fields without warning - `warnings` itself arrived that way - and a strict
/// `Codable` model drops in silence what it does not know, which is the very failure
/// this client exists to prevent. Read what you need:
///
///     let total = payment["total"]?.decimal
///     let url   = payment["checkout_url"]?.string
///     let first = list["data"]?[0]?["id"]?.int
///
/// or hand it to your own type once you are sure of the shape:
///
///     let mine = try payment.decode(MyPayment.self)
///
/// Dynamic member lookup reaches keys too - `payment.checkout_url?.string` - except for the
/// nine names this enum already defines: string, int, double, decimal, bool, array, object, isNull
/// and description. ROKI's own `description` field is one of those, so that one has to be
/// read as `payment["description"]`.
@dynamicMemberLookup
public enum RokiJSON: Sendable, Equatable, Codable, CustomStringConvertible {
    case null
    case bool(Bool)
    case int(Int)
    case double(Double)
    case string(String)
    case array([RokiJSON])
    case object([String: RokiJSON])

    // MARK: Accessors

    public var isNull: Bool { self == .null }

    public var string: String? {
        if case .string(let value) = self { return value }
        return nil
    }

    public var bool: Bool? {
        if case .bool(let value) = self { return value }
        return nil
    }

    public var int: Int? {
        switch self {
        case .int(let value): return value
        case .double(let value): return Int(exactly: value.rounded())
        default: return nil
        }
    }

    public var double: Double? {
        switch self {
        case .int(let value): return Double(value)
        case .double(let value): return value
        default: return nil
        }
    }

    /// Money, without the binary-floating-point surprise.
    ///
    /// A JSON `1500.10` becomes a `Double` whose shortest round-trip text is
    /// still "1500.1", so going through that text gives back exactly 1500.1 rather than the
    /// 1500.0999999999999 that a binary-to-decimal conversion can produce. Add and compare totals
    /// with this, not with `double`.
    public var decimal: Decimal? {
        switch self {
        case .int(let value): return Decimal(value)
        case .double(let value): return Decimal(string: String(value))
        case .string(let value): return Decimal(string: value)
        default: return nil
        }
    }

    public var array: [RokiJSON]? {
        if case .array(let value) = self { return value }
        return nil
    }

    public var object: [String: RokiJSON]? {
        if case .object(let value) = self { return value }
        return nil
    }

    public subscript(key: String) -> RokiJSON? {
        if case .object(let value) = self { return value[key] }
        return nil
    }

    public subscript(index: Int) -> RokiJSON? {
        if case .array(let value) = self, value.indices.contains(index) { return value[index] }
        return nil
    }

    public subscript(dynamicMember key: String) -> RokiJSON? { self[key] }

    /// Re-decodes this value into your own type. Useful once a shape is stable enough to model -
    /// and harmless, because the untyped value is still there when a new field shows up.
    public func decode<T: Decodable>(
        _ type: T.Type = T.self,
        using decoder: JSONDecoder = JSONDecoder()
    ) throws -> T {
        let data = try JSONEncoder().encode(self)
        return try decoder.decode(T.self, from: data)
    }

    // MARK: Codable

    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            self = .null
        } else if let value = try? container.decode(Bool.self) {
            self = .bool(value)
        } else if let value = try? container.decode(Int.self) {
            self = .int(value)
        } else if let value = try? container.decode(Double.self) {
            self = .double(value)
        } else if let value = try? container.decode(String.self) {
            self = .string(value)
        } else if let value = try? container.decode([RokiJSON].self) {
            self = .array(value)
        } else if let value = try? container.decode([String: RokiJSON].self) {
            self = .object(value)
        } else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unrecognised JSON value")
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .null: try container.encodeNil()
        case .bool(let value): try container.encode(value)
        case .int(let value): try container.encode(value)
        case .double(let value): try container.encode(value)
        case .string(let value): try container.encode(value)
        case .array(let value): try container.encode(value)
        case .object(let value): try container.encode(value)
        }
    }

    public var description: String {
        guard let data = try? JSONEncoder().encode(self),
              let text = String(data: data, encoding: .utf8) else { return "RokiJSON" }
        return text
    }
}

// MARK: - Migration aliases

/// Field names from other gateways, mapped to what ROKI actually calls them.
///
/// A caseless enum rather than a struct: there is nothing here to instantiate.
public enum RokiFieldAliases {
    /// The type is written out on purpose - an unannotated literal this size sends the type
    /// checker down its slow path and adds seconds to every clean build.
    public static let map: [String: String] = [
        "amount": "In Stripe this is \"amount\"; in ROKI it is \"amount\". Same name, different unit: ROKI's amount is decimal units (1500.00 = L 1,500.00, minimum 0.01), so divide Stripe's integer by 100 before sending, and enforce your own ceiling because ROKI enforces no maximum and would silently create a...",
        "amount_cents": "In Stripe this is \"amount_cents\"; in ROKI it is \"amount\". ROKI has no cents field anywhere: convert to decimal units and send amount; this is one of the few wrong names that fails loudly rather than silently, returning 422 'El campo amount es obligatorio.' because amount is required.",
        "currency": "In Stripe this is \"currency\"; in ROKI it is \"currency_code\". POST /payments wants the ISO 4217 *numeric* code as a string ('340' = HNL), not the alphabetic one; the alphabetic form ('HNL') is accepted only on /payments/token-charge, /payment-methods/{id}/charge and /confirm, and the response echoes...",
        "payment_method_types": "Stripe has \"payment_method_types\", ROKI has no equivalent. ROKI is card-only and the integration mode is chosen by which endpoint you call - POST /payments for hosted checkout, POST /confirm for embedded card fields, POST /payment-methods/{id}/charge for a saved card - so drop the field entirely,...",
        "success_url": "In Stripe this is \"success_url\"; in ROKI it is \"success_url\". Identical name on POST /payments, but the embedded-components endpoint POST /confirm calls it success_redirect_url; only http/https is accepted (custom app schemes like myapp:// return 422), and a customer landing there is never proof of...",
        "cancel_url": "In Stripe this is \"cancel_url\"; in ROKI it is \"cancel_url\". Same name on POST /payments; the embedded-components equivalent is failed_redirect_url on POST /confirm, which means 'the charge failed', not 'the customer backed out', so the two are not interchangeable.",
        "customer_email": "In Stripe this is \"customer_email\"; in ROKI it is \"customer.email\". In ROKI it lives inside the optional customer prefill object (customer.email, alongside name, phone, identity_number), it creates no customer record, it stays editable unless you also send lock_customer_fields: true, and it is echoed back...",
        "metadata": "In Stripe and Mercado Pago this is \"metadata\"; in ROKI it is \"metadata\". Same name and same purpose - returned untouched on retrieval and in every webhook, never shown to the customer - but it must be a JSON object or you get 422 ('El campo metadata debe ser un array.'), and when you send none ROKI may return...",
        "client_reference_id": "In Stripe this is \"client_reference_id\"; in ROKI it is \"external_reference\". ROKI's external_reference is required (max 191) and, despite the name, is NOT unique - two creates with the same value produce two separately payable links - so duplicate protection has to come from Idempotency-Key; you can filter by it...",
        "capture_method": "Stripe has \"capture_method\", ROKI has no equivalent. ROKI has no authorize/capture split and no capture endpoint - every approved charge is captured immediately - so express reversal instead: try POST /payments/{transaction_id}/void (full amount only, before settlement) and fall back to POST...",
        "receipt_email": "Stripe has \"receipt_email\", ROKI has no equivalent. ROKI never emails a receipt from the API: fetch GET /payments/{transaction_id}/receipt for the public receipt_url (or /receipt/download for the PDF) and deliver it yourself - and be aware customer.email only prefills the checkout form, it...",
        "setup_future_usage": "Stripe has \"setup_future_usage\", ROKI has no equivalent. You cannot request card saving from the ROKI API at all - the customer must tick 'save my card' on ROKI's own checkout, after which the payment_method.saved webhook delivers an opaque pm_* that you charge later via POST...",
        "payment_intent": "In Stripe this is \"payment_intent\"; in ROKI it is \"transaction_id\". ROKI splits this into two handles: the numeric id for GET /payments/{id}, and the UUID transaction_id - null until the payment is actually charged - which is what void, refund and receipts require; passing the numeric id to those routes...",
        "customer": "Stripe has \"customer\", ROKI has no equivalent. ROKI has no Customer resource and no customer ids, so there is nothing to pass here - identify people by the data itself: send the inline customer prefill object (name, email, phone, identity_number) on POST /payments, and look up saved...",
        "description": "In Stripe and Mercado Pago this is \"description\"; in ROKI it is \"description\". Same name but capped at 120 characters in ROKI, and it is not the label the customer sees at checkout - that is the required name field (max 191), which Stripe has no equivalent for, so a straight port leaves your checkout page unlabelled.",
        "statement_descriptor": "Stripe and Mercado Pago have \"statement_descriptor\", ROKI has no equivalent. ROKI exposes no per-payment statement descriptor - it comes from the merchant's terminal configuration - so remove the field rather than sending it, because it would be silently ignored with a 201; name and description only affect what the...",
        "line_items": "Stripe has \"line_items\", ROKI has no equivalent. ROKI takes one pre-computed total: sum the cart in your own backend and send amount in decimal units plus a single name label, keep the per-line breakdown in metadata for reconciliation, and use sales_tax_type/sales_tax_value when you need...",
        "mode": "Stripe has \"mode\", ROKI has no equivalent. ROKI has no mode parameter and no subscription engine - POST /payments is always a one-time hosted checkout - so drive recurring billing from your own scheduler by calling POST /payments/token-charge with a saved payment_token each cycle,...",
        "url": "In Stripe this is \"url\"; in ROKI it is \"checkout_url\". ROKI returns checkout_url on the 201 from POST /payments - redirect to the exact string returned and never hardcode, allow-list or pattern-match the domain or slug, because the live value (aura.roki.systems/pay/link/{12-char lowercase...",
        "amount_total": "In Stripe this is \"amount_total\"; in ROKI it is \"total\". ROKI's total is authoritative and in decimal units (subtotal + sales_tax_amount + service_fee_amount) - read it back from the response instead of recomputing it, and remember a customer-selected tip is chosen at checkout, so the amount...",
        "expires_at": "In Stripe this is \"expires_at\"; in ROKI it is \"expires_at\". Same name, completely different format: ROKI wants a string like '2026-08-13 18:00:00' (or ISO 8601) in Honduras local time UTC-6 with no offset marker and it must be in the future or you get 422 - passing a Unix timestamp, or parsing the...",
        "payment_method": "In Stripe this is \"payment_method\"; in ROKI it is \"payment_token\". ROKI's saved cards carry the same pm_ prefix, which makes this the most convincing wrong guess in the migration: the field is payment_token in the body of POST /payments/token-charge (or the {payment_method_id} path segment of POST...",
        "application_fee_amount": "Stripe has \"application_fee_amount\", ROKI has no equivalent. ROKI has no platform or marketplace fee field; its only fee flag is the boolean service_fee_enabled, which means something different - it passes ROKI's own processing costs to the customer by reverse calculation so the merchant nets amount...",
        "locale": "In Stripe this is \"locale\"; in ROKI it is \"Accept-Language\". ROKI has no body field for this - send the Accept-Language: es|en header instead (Spanish is the default for a missing, empty or unsupported value) - but note its narrower reach: it localizes API and validation messages only, routing...",
        "items": "Mercado Pago has \"items\", ROKI has no equivalent. ROKI has no line items at all - sum the cart in your own backend and send one amount, and if you need the breakdown preserved put it in metadata, which ROKI returns untouched on retrieval and in every webhook.",
        "unit_price": "Mercado Pago has \"unit_price\", ROKI has no equivalent. Nothing per-unit exists in ROKI - multiply by quantity, sum every item, and send the result as amount (decimal units, minimum 0.01, no maximum enforced by the API, so cap it on your side).",
        "quantity": "Mercado Pago has \"quantity\", ROKI has no equivalent. Fold quantity into the total before calling ROKI, and never leave the key in the body as a hopeful extra: ROKI silently ignores unknown fields and still returns 201, so the mistake is invisible.",
        "transaction_amount": "In Mercado Pago this is \"transaction_amount\"; in ROKI it is \"amount\". The closest 1:1 match in the whole migration - also decimal units and not cents (150.50 = L 150.50), also required, minimum 0.01 - but ROKI may add sales tax, tip and service fee on top, so the authoritative charged figure is total in the...",
        "payer": "In Mercado Pago this is \"payer\"; in ROKI it is \"customer\". ROKI customer accepts only name, email, phone and identity_number (no surname, no address), and it is only a PREFILL of the hosted checkout that the customer can still edit unless you also send lock_customer_fields: true.",
        "external_reference": "In Mercado Pago this is \"external_reference\"; in ROKI it is \"external_reference\". Identical name but REQUIRED in ROKI (max 191) and explicitly NOT unique - sending it twice without an Idempotency-Key creates two separate payable payments - and it is filterable via GET /payments?external_reference=.",
        "notification_url": "Mercado Pago has \"notification_url\", ROKI has no equivalent. ROKI webhooks are not per payment - register the endpoint URL and copy its signing secret once per environment at https://aura.roki.systems/merchant/connect/webhooks, then verify the ROKI-Signature HMAC-SHA256 over timestamp + \".\" +...",
        "back_urls": "Mercado Pago has \"back_urls\", ROKI has no equivalent. There is no nested object in ROKI - the two flat top-level fields success_url and cancel_url replace it, and both accept only http/https (a custom app scheme like myapp://done returns 422, so mobile return trips need Universal Links / App...",
        "auto_return": "Mercado Pago has \"auto_return\", ROKI has no equivalent. There is no switch to set - the ROKI hosted checkout always offers the return to success_url - and as in Mercado Pago the redirect is never proof of a charge, so treat it purely as UX and confirm server-side.",
        "init_point": "In Mercado Pago this is \"init_point\"; in ROKI it is \"checkout_url\". The clean equivalent and the heart of the migration - ROKI returns it straight from POST /payments with no preference step, there is exactly one URL for both environments, and the slug is a bare 12-character lowercase string with no plink_...",
        "sandbox_init_point": "Mercado Pago has \"sandbox_init_point\", ROKI has no equivalent. ROKI has no second URL - the environment is decided solely by the API key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes, so use checkout_url in both and just swap the key.",
        "preference_id": "In Mercado Pago this is \"preference_id\"; in ROKI it is \"id\". ROKI has no preference object - POST /payments creates the payment itself - and the returned numeric id serves only GET /payments/{id}, so persist it, because the only other way back to a payment is paging GET /payments.",
        "payment_method_id": "Mercado Pago has \"payment_method_id\", ROKI has no equivalent. The most dangerous false friend in this table - ROKI does have a payment_method_id, but it is a URL PATH PARAMETER carrying an opaque saved-card reference (pm_7k2n9xqf31ab), not a brand selector, and ROKI Connect offers no method selection...",
        "token": "In Mercado Pago this is \"token\"; in ROKI it is \"payment_token\". Same architecture, but one ROKI name covers two non-interchangeable values: mode 2 sends the iframe SDK's tok_* to POST https://aura.roki.systems/api/connect/embed/confirm alongside publishable_key (note the base is /api/connect/embed, not...",
        "installments": "Mercado Pago has \"installments\", ROKI has no equivalent. ROKI Connect exposes no installment plans whatsoever and there is no workaround - and because unknown fields are silently dropped, leaving installments in the body returns 201 while the customer is charged in full, so strip it out...",
        "currency_id": "In Mercado Pago this is \"currency_id\"; in ROKI it is \"currency_code\". Top-level rather than per-item, and inconsistent inside ROKI itself: POST /payments requires the NUMERIC ISO 4217 code (\"340\" = HNL) while POST /payments/token-charge and the embed confirm accept the alphabetic \"HNL\" - and only currencies...",
        "expires": "Mercado Pago has \"expires\", ROKI has no equivalent. ROKI has no on/off switch - simply send expires_at to set an expiry, or omit it entirely for a link with no expiry.",
        "expiration_date_to": "In Mercado Pago this is \"expiration_date_to\"; in ROKI it is \"expires_at\". Must be in the future and is interpreted in Honduras time (UTC-6) with NO offset in the string in either direction, so parsing expires_at, created_at or paid_at as UTC shifts every value by six hours - enough to make a live link look...",
        "capture": "Mercado Pago has \"capture\", ROKI has no equivalent. ROKI has no auth/capture split - every approved charge captures - so the reversal path replaces it: try POST /payments/{transaction_id}/void (full amount only, pre-settlement) and fall back to POST /payments/{transaction_id}/refund (full...",
        "binary_mode": "Mercado Pago has \"binary_mode\", ROKI has no equivalent. Not configurable in ROKI - a payment is pending until it is charged, and in mode 2 a 3-D Secure challenge returns status: \"pending\" with an authentication_url the customer must complete, the final outcome arriving by webhook.",
        "status_detail": "Mercado Pago has \"status_detail\", ROKI has no equivalent. ROKI returns only status (pending, paid, partially_refunded, refunded, voided, expired, disabled); decline detail arrives in a different shape entirely, as processor-passthrough IsoResponseCode plus a capitalized Errors[] array of {Code,...",
    ]
}

// MARK: - Client

/// The ROKI Connect API, one method per operation in the spec.
///
/// A value type: hold it in a property, pass it between tasks, keep one per key. It carries no
/// mutable state, so there is nothing to serialise and no reason to make it an actor.
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public struct RokiConnect: Sendable {
    /// The openapi.yaml version this file was generated from.
    public static let specVersion = "2.0.0"

    /// This client's own version. Plain semver, so SwiftPM and any range check order it.
    ///
    /// The build fingerprint is kept out of it on purpose: glued to the version it reads as an
    /// unknown prerelease to a semver comparator - lower than the bare version - which is how the
    /// PHP twin of this file started reporting itself as out of date while it was current.
    public static let sdkVersion = "2.0.0"

    /// Fingerprint of the contract, the alias map and the templates this file came out of.
    ///
    /// Not a version and never compared as one: it only tells two copies apart. It rides along in
    /// `User-Agent`, which is free text nobody sorts.
    public static let build = "12e0e82"

    public static let defaultBaseURL = "https://aura.roki.systems/api/connect/v1"

    public struct Configuration: Sendable {
        /// Base for every route except the embedded-components confirm, which carries its own
        /// base in the spec and overrides this.
        public var baseURL: String

        /// Applies to each request. A timeout is not a failed payment: the payment may well exist,
        /// so retry with the same `Idempotency-Key` rather than with a fresh one.
        public var timeout: TimeInterval

        /// Reject unknown fields before sending. Leave this on: the API answers 201 to a
        /// misspelled field and ignores it, so a typo becomes a silent production bug instead of
        /// a local error.
        public var strict: Bool

        /// `Accept-Language`: es or en. It localises API and validation messages only.
        /// ROKI itself falls back to Spanish when the header is missing; this client sends English
        /// by default, so that the API's messages and its own read the same way.
        public var language: String

        /// Inject your own to share a connection pool, set headers app-wide, or point tests at a
        /// stub protocol.
        public var session: URLSession

        /// Called with (warnings, method, path) whenever the API reports it ignored a field.
        /// Defaults to a warning on the `la.roki.connect` logger. With `strict` on,
        /// this firing means the spec this SDK was generated from is ahead of the live API -
        /// regenerate.
        public var onWarnings: (@Sendable ([String], String, String) -> Void)?

        public init(
            baseURL: String = RokiConnect.defaultBaseURL,
            timeout: TimeInterval = 30,
            strict: Bool = true,
            language: String = "en",
            session: URLSession = .shared,
            onWarnings: (@Sendable ([String], String, String) -> Void)? = nil
        ) {
            self.baseURL = baseURL
            self.timeout = timeout
            self.strict = strict
            self.language = language
            self.session = session
            self.onWarnings = onWarnings
        }
    }

    private let secretKey: String
    private let base: String
    private let timeout: TimeInterval
    private let strict: Bool
    private let language: String
    private let session: URLSession
    private let onWarnings: @Sendable ([String], String, String) -> Void

    public init(secretKey: String, configuration: Configuration = Configuration()) throws {
        // Trimmed because a key read from a file or a CI secret usually arrives with a trailing
        // newline, and a header value with one in it fails as an invalid key, which reads as a
        // credentials problem rather than a whitespace problem.
        let key = secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !key.isEmpty else { throw RokiError.missingSecretKey }
        guard !key.hasPrefix("pk_") else { throw RokiError.publishableKey }

        self.secretKey = key
        self.base = configuration.baseURL.hasSuffix("/")
            ? String(configuration.baseURL.dropLast())
            : configuration.baseURL
        self.timeout = configuration.timeout
        self.strict = configuration.strict
        self.language = configuration.language
        self.session = configuration.session
        self.onWarnings = configuration.onWarnings ?? Self.logWarnings
    }

    /// True when this client is talking to the sandbox.
    public var isSandbox: Bool { secretKey.hasPrefix("sk_test_") }

    // MARK: - Operations

    /// Which merchant does this key belong to
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func getMerchant() async throws -> RokiJSON {
        return try await send(
            method: "GET",
            path: "/me",
            body: nil,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "getMerchant"
        )
    }

    /// List payments
    ///
    /// - Parameters:
    ///   - perPage: Page size. Defaults to 20. **Note `limit` is ignored** - only `per_page`
    ///     changes the page size.
    ///   - page: 1-based page number. Use `meta.last_page` to know when to stop.
    ///   - status: Filter by payment status. An unrecognized value returns `422` rather than
    ///     being ignored.
    ///   - externalReference: Filter by your own order id. Since `external_reference` is not
    ///     unique, this can return more than one payment.
    ///   - from: Start of a creation-date range, `YYYY-MM-DD`, Honduras time.
    ///   - to: End of the creation-date range, inclusive.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func listPayments(
        perPage: Int? = nil,
        page: Int? = nil,
        status: String? = nil,
        externalReference: String? = nil,
        from: String? = nil,
        to: String? = nil
    ) async throws -> RokiJSON {
        var query: [(String, String)] = []
        if let perPage { query.append(("per_page", String(perPage))) }
        if let page { query.append(("page", String(page))) }
        if let status { query.append(("status", status)) }
        if let externalReference { query.append(("external_reference", externalReference)) }
        if let from { query.append(("from", from)) }
        if let to { query.append(("to", to)) }
        return try await send(
            method: "GET",
            path: "/payments",
            body: nil,
            query: query,
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "listPayments"
        )
    }

    /// Create a payment
    ///
    /// - Important: `amount` is in decimal units, never cents. `150.50` is L 150.50; there is
    ///   no cents field anywhere in this API. The Stripe habit of multiplying by 100 charges a
    ///   hundred times too much, and passing an integer you already had in cents does the same,
    ///   silently, because the API accepts numeric strings and enforces no maximum.
    ///   `Decimal(string: "150.50")` survives the round trip exactly if you keep money in
    ///   `Decimal`.
    ///
    /// - Parameters:
    ///   - body: The JSON body. Required: `amount`, `external_reference`, `name`. Also
    ///     accepted: `currency_code`, `description`, `metadata`, `success_url`, `cancel_url`,
    ///     `expires_at`, `customer`, `lock_customer_fields`, `reusable`, `sales_tax_type`,
    ///     `sales_tax_value`, `tip_enabled`, `tip_type`, `tip_value`,
    ///     `tip_customer_selectable`, `tip_preset_percentages`, `tip_allow_custom`,
    ///     `tip_min_amount`, `tip_max_amount`, `service_fee_enabled`. Any other key throws
    ///     `RokiError.unknownField` before the request leaves the process: ROKI would answer
    ///     201 and ignore it.
    ///   - idempotencyKey: Sent as `Idempotency-Key`. One is generated when you pass nil.
    ///     Replaying a key returns the first response even when the body differs, so derive
    ///     yours from the order's contents and not only from its id.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func createPayment(
        _ body: [String: Any],
        idempotencyKey: String? = nil
    ) async throws -> RokiJSON {
        try validate(
            body,
            known: [
                "amount", "external_reference", "name", "currency_code", "description",
                "metadata", "success_url", "cancel_url", "expires_at", "customer",
                "lock_customer_fields", "reusable", "sales_tax_type", "sales_tax_value",
                "tip_enabled", "tip_type", "tip_value", "tip_customer_selectable",
                "tip_preset_percentages", "tip_allow_custom", "tip_min_amount",
                "tip_max_amount", "service_fee_enabled",
            ],
            required: ["amount", "external_reference", "name"],
            operation: "createPayment"
        )
        return try await send(
            method: "POST",
            path: "/payments",
            body: body,
            query: [],
            idempotencyKey: idempotencyKey ?? Self.newIdempotencyKey(),
            baseOverride: nil,
            operation: "createPayment"
        )
    }

    /// Retrieve a payment
    ///
    /// - Parameters:
    ///   - id: Numeric payment identifier returned at creation.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func getPayment(id: Int) async throws -> RokiJSON {
        return try await send(
            method: "GET",
            path: "/payments/\(id)",
            body: nil,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "getPayment"
        )
    }

    /// Void a transaction
    ///
    /// - Parameters:
    ///   - transactionID: The **transaction** UUID from the payment response - not the numeric
    ///     payment `id`. Passing a numeric payment id returns **404 with `code:
    ///     "transaction_id_expected"`** and a message naming the identifier it wanted. The
    ///     endpoint exists and answered; only the kind of identifier was wrong. This used to be
    ///     a routing 404 (`"The route ... could not be found."`). It is not any more, verified
    ///     2026-08-21. Client code that classifies this error by matching the message text for
    ///     `"could not be found"` now files it under "no such payment" and sends the developer
    ///     hunting for a bad id instead of a bad identifier type. Branch on `code`.
    ///   - body: The JSON body. Also accepted: `reason`. Any other key throws
    ///     `RokiError.unknownField` before the request leaves the process: ROKI would answer
    ///     201 and ignore it.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func voidTransaction(
        transactionID: String,
        _ body: [String: Any] = [:]
    ) async throws -> RokiJSON {
        try validate(body, known: ["reason"], required: [], operation: "voidTransaction")
        return try await send(
            method: "POST",
            path: "/payments/\(Self.escape(transactionID))/void",
            body: body,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "voidTransaction"
        )
    }

    /// Refund a transaction
    ///
    /// - Important: `amount` is in decimal units, never cents. `150.50` is L 150.50; there is
    ///   no cents field anywhere in this API. The Stripe habit of multiplying by 100 charges a
    ///   hundred times too much, and passing an integer you already had in cents does the same,
    ///   silently, because the API accepts numeric strings and enforces no maximum.
    ///   `Decimal(string: "150.50")` survives the round trip exactly if you keep money in
    ///   `Decimal`.
    ///
    /// - Parameters:
    ///   - transactionID: The **transaction** UUID from the payment response - not the numeric
    ///     payment `id`. Passing a numeric payment id returns **404 with `code:
    ///     "transaction_id_expected"`** and a message naming the identifier it wanted. The
    ///     endpoint exists and answered; only the kind of identifier was wrong. This used to be
    ///     a routing 404 (`"The route ... could not be found."`). It is not any more, verified
    ///     2026-08-21. Client code that classifies this error by matching the message text for
    ///     `"could not be found"` now files it under "no such payment" and sends the developer
    ///     hunting for a bad id instead of a bad identifier type. Branch on `code`.
    ///   - body: The JSON body. Required: `amount`. Also accepted: `reason`. Any other key
    ///     throws `RokiError.unknownField` before the request leaves the process: ROKI would
    ///     answer 201 and ignore it.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func refundTransaction(
        transactionID: String,
        _ body: [String: Any]
    ) async throws -> RokiJSON {
        try validate(
            body,
            known: ["amount", "reason"],
            required: ["amount"],
            operation: "refundTransaction"
        )
        return try await send(
            method: "POST",
            path: "/payments/\(Self.escape(transactionID))/refund",
            body: body,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "refundTransaction"
        )
    }

    /// Get receipt metadata
    ///
    /// - Parameters:
    ///   - transactionID: The **transaction** UUID from the payment response - not the numeric
    ///     payment `id`. Passing a numeric payment id returns **404 with `code:
    ///     "transaction_id_expected"`** and a message naming the identifier it wanted. The
    ///     endpoint exists and answered; only the kind of identifier was wrong. This used to be
    ///     a routing 404 (`"The route ... could not be found."`). It is not any more, verified
    ///     2026-08-21. Client code that classifies this error by matching the message text for
    ///     `"could not be found"` now files it under "no such payment" and sends the developer
    ///     hunting for a bad id instead of a bad identifier type. Branch on `code`.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func getReceipt(transactionID: String) async throws -> RokiJSON {
        return try await send(
            method: "GET",
            path: "/payments/\(Self.escape(transactionID))/receipt",
            body: nil,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "getReceipt"
        )
    }

    /// Download the receipt PDF
    ///
    /// - Note: this route answers application/pdf, not JSON, so this method hands back the
    ///   bytes as `Data`. Write them with `Data.write(to:)` or show them in a `PDFView`. The
    ///   human-readable page is `receipt_url`, from `getReceipt`.
    ///
    /// - Parameters:
    ///   - transactionID: The **transaction** UUID from the payment response - not the numeric
    ///     payment `id`. Passing a numeric payment id returns **404 with `code:
    ///     "transaction_id_expected"`** and a message naming the identifier it wanted. The
    ///     endpoint exists and answered; only the kind of identifier was wrong. This used to be
    ///     a routing 404 (`"The route ... could not be found."`). It is not any more, verified
    ///     2026-08-21. Client code that classifies this error by matching the message text for
    ///     `"could not be found"` now files it under "no such payment" and sends the developer
    ///     hunting for a bad id instead of a bad identifier type. Branch on `code`.
    ///
    /// - Returns: The raw application/pdf bytes.
    /// - Throws: `RokiError`.
    public func downloadReceipt(transactionID: String) async throws -> Data {
        return try await sendData(
            method: "GET",
            path: "/payments/\(Self.escape(transactionID))/receipt/download",
            body: nil,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "downloadReceipt"
        )
    }

    /// List a customer's saved cards
    ///
    /// - Parameters:
    ///   - customerIdentityNumber: National identity number of the customer. Preferred
    ///     identifier.
    ///   - customerEmail: Customer email, when the identity number is unknown.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func listPaymentMethods(
        customerIdentityNumber: String? = nil,
        customerEmail: String? = nil
    ) async throws -> RokiJSON {
        var query: [(String, String)] = []
        if let customerIdentityNumber { query.append(("customer[identity_number]", customerIdentityNumber)) }
        if let customerEmail { query.append(("customer[email]", customerEmail)) }
        return try await send(
            method: "GET",
            path: "/payment-methods",
            body: nil,
            query: query,
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "listPaymentMethods"
        )
    }

    /// Revoke a saved card
    ///
    /// - Parameters:
    ///   - paymentMethodID: Opaque saved-card reference, prefixed `pm_`.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func revokePaymentMethod(paymentMethodID: String) async throws -> RokiJSON {
        return try await send(
            method: "DELETE",
            path: "/payment-methods/\(Self.escape(paymentMethodID))",
            body: nil,
            query: [],
            idempotencyKey: nil,
            baseOverride: nil,
            operation: "revokePaymentMethod"
        )
    }

    /// Charge a saved card
    ///
    /// - Important: `amount` is in decimal units, never cents. `150.50` is L 150.50; there is
    ///   no cents field anywhere in this API. The Stripe habit of multiplying by 100 charges a
    ///   hundred times too much, and passing an integer you already had in cents does the same,
    ///   silently, because the API accepts numeric strings and enforces no maximum.
    ///   `Decimal(string: "150.50")` survives the round trip exactly if you keep money in
    ///   `Decimal`.
    ///
    /// - Parameters:
    ///   - paymentMethodID: Opaque saved-card reference, prefixed `pm_`.
    ///   - body: The JSON body. Required: `amount`. Also accepted: `currency_code`,
    ///     `external_reference`, `metadata`. Any other key throws `RokiError.unknownField`
    ///     before the request leaves the process: ROKI would answer 201 and ignore it.
    ///   - idempotencyKey: Sent as `Idempotency-Key`. Mandatory on this endpoint - omitting it
    ///     returns 422 - so one is generated when you pass nil. Replaying a key returns the
    ///     first response even when the body differs, so derive yours from the order's contents
    ///     and not only from its id.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func chargeSavedCard(
        paymentMethodID: String,
        _ body: [String: Any],
        idempotencyKey: String? = nil
    ) async throws -> RokiJSON {
        try validate(
            body,
            known: ["amount", "currency_code", "external_reference", "metadata"],
            required: ["amount"],
            operation: "chargeSavedCard"
        )
        return try await send(
            method: "POST",
            path: "/payment-methods/\(Self.escape(paymentMethodID))/charge",
            body: body,
            query: [],
            idempotencyKey: idempotencyKey ?? Self.newIdempotencyKey(),
            baseOverride: nil,
            operation: "chargeSavedCard"
        )
    }

    /// Charge a saved card (alias)
    ///
    /// - Important: `amount` is in decimal units, never cents. `150.50` is L 150.50; there is
    ///   no cents field anywhere in this API. The Stripe habit of multiplying by 100 charges a
    ///   hundred times too much, and passing an integer you already had in cents does the same,
    ///   silently, because the API accepts numeric strings and enforces no maximum.
    ///   `Decimal(string: "150.50")` survives the round trip exactly if you keep money in
    ///   `Decimal`.
    ///
    /// - Parameters:
    ///   - body: The JSON body. Required: `payment_token`, `amount`. Also accepted:
    ///     `currency_code`, `external_reference`, `metadata`. Any other key throws
    ///     `RokiError.unknownField` before the request leaves the process: ROKI would answer
    ///     201 and ignore it.
    ///   - idempotencyKey: Sent as `Idempotency-Key`. Mandatory on this endpoint - omitting it
    ///     returns 422 - so one is generated when you pass nil. Replaying a key returns the
    ///     first response even when the body differs, so derive yours from the order's contents
    ///     and not only from its id.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func tokenCharge(
        _ body: [String: Any],
        idempotencyKey: String? = nil
    ) async throws -> RokiJSON {
        try validate(
            body,
            known: ["payment_token", "amount", "currency_code", "external_reference", "metadata"],
            required: ["payment_token", "amount"],
            operation: "tokenCharge"
        )
        return try await send(
            method: "POST",
            path: "/payments/token-charge",
            body: body,
            query: [],
            idempotencyKey: idempotencyKey ?? Self.newIdempotencyKey(),
            baseOverride: nil,
            operation: "tokenCharge"
        )
    }

    /// Confirm an embedded-components payment
    ///
    /// - Important: `amount` is in decimal units, never cents. `150.50` is L 150.50; there is
    ///   no cents field anywhere in this API. The Stripe habit of multiplying by 100 charges a
    ///   hundred times too much, and passing an integer you already had in cents does the same,
    ///   silently, because the API accepts numeric strings and enforces no maximum.
    ///   `Decimal(string: "150.50")` survives the round trip exactly if you keep money in
    ///   `Decimal`.
    ///
    /// - Parameters:
    ///   - body: The JSON body. Required: `amount`, `currency_code`, `payment_token`,
    ///     `publishable_key`. Also accepted: `external_reference`, `success_redirect_url`,
    ///     `failed_redirect_url`, `description`, `metadata`. Any other key throws
    ///     `RokiError.unknownField` before the request leaves the process: ROKI would answer
    ///     201 and ignore it.
    ///
    /// - Returns: The decoded JSON response.
    /// - Throws: `RokiError`.
    public func confirmEmbeddedPayment(_ body: [String: Any]) async throws -> RokiJSON {
        try validate(
            body,
            known: [
                "amount", "currency_code", "payment_token", "publishable_key",
                "external_reference", "success_redirect_url", "failed_redirect_url",
                "description", "metadata",
            ],
            required: ["amount", "currency_code", "payment_token", "publishable_key"],
            operation: "confirmEmbeddedPayment"
        )
        return try await send(
            method: "POST",
            path: "/confirm",
            body: body,
            query: [],
            idempotencyKey: nil,
            baseOverride: "https://aura.roki.systems/api/connect/embed",
            operation: "confirmEmbeddedPayment"
        )
    }

    // MARK: - Webhooks

    /// Verify a webhook signature.
    ///
    /// `rawBody` must be the exact bytes received. In Vapor read
    /// `request.body.data` before any decoding; anywhere else, keep the raw `Data`
    /// from the request. Re-encoding a parsed value changes key order, spacing and number
    /// formatting, and the HMAC will never match.
    ///
    /// Header: `ROKI-Signature: t={timestamp},v1={hex}`
    ///
    /// - Parameter tolerance: How far the timestamp may be from now, in seconds. Pass 0 to skip
    ///   the check - only in tests. A genuinely signed event replayed tomorrow is still a valid
    ///   signature, and the timestamp is the only thing that says so.
    public static func verifyWebhook(
        rawBody: Data,
        signatureHeader: String,
        signingSecret: String,
        tolerance: TimeInterval = 300,
        now: Date = Date()
    ) -> Bool {
        // Parsed by hand rather than with a regex literal: Regex needs macOS 13 / iOS 16, and this
        // one function is the piece most likely to run on an older deployment target.
        var stamp: String?
        var received: String?
        for part in signatureHeader.split(separator: ",") {
            let pair = part.split(separator: "=", maxSplits: 1)
            guard pair.count == 2 else { continue }
            let name = pair[0].trimmingCharacters(in: .whitespaces).lowercased()
            let value = pair[1].trimmingCharacters(in: .whitespaces)
            if name == "t" { stamp = value } else if name == "v1" { received = value }
        }

        guard let stamp, !stamp.isEmpty, stamp.allSatisfy(\.isNumber), let seconds = Double(stamp),
              let received, received.count == 64,
              let signature = Data(rokiHex: received), signature.count == 32 else { return false }

        if tolerance > 0, abs(now.timeIntervalSince1970 - seconds) > tolerance {
            return false   // replay of an old, genuinely signed event
        }

        var signed = Data("\(stamp).".utf8)
        signed.append(rawBody)

        // isValidAuthenticationCode, not == on two digests: CryptoKit compares in constant time by
        // design, so the comparison cannot leak the expected signature through its own timing.
        return HMAC<SHA256>.isValidAuthenticationCode(
            signature,
            authenticating: signed,
            using: SymmetricKey(data: Data(signingSecret.utf8))
        )
    }

    /// Convenience for a body you are holding as text. Prefer the `Data` form: the
    /// signature covers bytes, and a body that is not valid UTF-8 cannot survive the round trip.
    public static func verifyWebhook(
        rawBody: String,
        signatureHeader: String,
        signingSecret: String,
        tolerance: TimeInterval = 300,
        now: Date = Date()
    ) -> Bool {
        verifyWebhook(
            rawBody: Data(rawBody.utf8),
            signatureHeader: signatureHeader,
            signingSecret: signingSecret,
            tolerance: tolerance,
            now: now
        )
    }

    /// A key stable enough that a retry is the same payment, unique enough that two are not.
    ///
    /// A v4 UUID is 122 bits from the system CSPRNG - the same thing the other ROKI clients build
    /// out of 16 random bytes. When you can, derive the key from the order's contents instead:
    /// replaying a key returns the first payment even if the body changed.
    public static func newIdempotencyKey() -> String { UUID().uuidString.lowercased() }

    // MARK: - Escape hatch

    /// The raw bytes of a GET, for a route this file does not know about.
    ///
    /// The routes the contract declares binary already have their own method returning
    /// `Data` - `downloadReceipt` is one - so this is not the way to fetch a
    /// receipt any more. It is here for whatever the API grows before this file is regenerated.
    ///
    ///     // Only for a path this file has no method for. If it has one, call that.
    ///     let bytes = try await roki.rawGET("/a-route-added-since-this-file-was-generated")
    ///
    /// - Parameter path: Already-escaped path, appended to the base URL.
    public func rawGET(_ path: String, query: [(String, String)] = []) async throws -> Data {
        try await sendData(
            method: "GET", path: path, body: nil, query: query,
            idempotencyKey: nil, baseOverride: nil, operation: "rawGET"
        )
    }

    // MARK: - Internals

    private func validate(
        _ body: [String: Any],
        known: [String],
        required: [String],
        operation: String
    ) throws {
        let missing = required.filter { body[$0] == nil }
        guard missing.isEmpty else {
            throw RokiError.missingRequiredFields(operation: operation, fields: missing)
        }
        guard strict, !known.isEmpty else { return }

        // Sorted, because Swift seeds Dictionary hashing per process: without this, a body with
        // two bad fields reports a different one on every run and the bug looks intermittent.
        for field in body.keys.sorted() where !known.contains(field) {
            let alias = RokiFieldAliases.map[field]
            throw RokiError.unknownField(
                operation: operation,
                field: field,
                alias: alias,
                suggestion: alias == nil ? rokiClosest(field, in: known) : nil
            )
        }
    }

    private func send(
        method: String,
        path: String,
        body: [String: Any]?,
        query: [(String, String)],
        idempotencyKey: String?,
        baseOverride: String?,
        operation: String
    ) async throws -> RokiJSON {
        let (data, http) = try await perform(
            method: method, path: path, body: body, query: query,
            idempotencyKey: idempotencyKey, baseOverride: baseOverride, operation: operation
        )
        let ok = (200..<300).contains(http.statusCode)

        // Revoking a saved card answers 200 with nothing in it. That is not a broken response.
        if ok, data.isEmpty { return .null }

        guard let json = try? JSONDecoder().decode(RokiJSON.self, from: data) else {
            throw RokiError.unexpectedResponseBody(
                method: method, path: path, status: http.statusCode,
                contentType: http.value(forHTTPHeaderField: "Content-Type"), data: data
            )
        }
        guard ok else { throw Self.apiError(status: http.statusCode, json: json, method: method, path: path) }

        // The API names the fields it ignored. Absent when the request was clean, so a quiet log
        // is the proof your field names are right. The text is localised by Accept-Language:
        // surface it, never branch on it.
        if let warnings = json["warnings"]?.array, !warnings.isEmpty {
            onWarnings(warnings.map { $0.string ?? $0.description }, method, path)
        }

        return json
    }

    /// The exact bytes of a route the contract declares is not JSON.
    ///
    /// Failures on those routes still arrive as JSON - a 404 for passing the numeric id instead of
    /// the `transaction_id` UUID is the usual one - so `failure` decodes them
    /// and they surface as the same `RokiError` as everywhere else. Only the success
    /// path hands back bytes.
    private func sendData(
        method: String,
        path: String,
        body: [String: Any]?,
        query: [(String, String)],
        idempotencyKey: String?,
        baseOverride: String?,
        operation: String
    ) async throws -> Data {
        let (data, http) = try await perform(
            method: method, path: path, body: body, query: query,
            idempotencyKey: idempotencyKey, baseOverride: baseOverride, operation: operation
        )
        guard (200..<300).contains(http.statusCode) else {
            throw failure(data: data, http: http, method: method, path: path)
        }
        return data
    }

    private func perform(
        method: String,
        path: String,
        body: [String: Any]?,
        query: [(String, String)],
        idempotencyKey: String?,
        baseOverride: String?,
        operation: String
    ) async throws -> (Data, HTTPURLResponse) {
        let absolute = (baseOverride ?? base) + path
        var components = URLComponents(string: absolute)
        if !query.isEmpty {
            components?.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) }
        }
        guard let url = components?.url else { throw RokiError.invalidURL(absolute) }

        var request = URLRequest(url: url, timeoutInterval: timeout)
        request.httpMethod = method
        request.setValue("Bearer " + secretKey, forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue(language, forHTTPHeaderField: "Accept-Language")
        request.setValue(
            "roki-connect-swift/" + Self.sdkVersion + "+build." + Self.build,
            forHTTPHeaderField: "User-Agent")
        if let idempotencyKey {
            request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
        }
        if let body {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            let payload = try Self.jsonSafe(body, at: "", operation: operation)
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: payload)
            } catch {
                throw RokiError.unencodableBody(
                    operation: operation, field: "body", reason: error.localizedDescription
                )
            }
        }

        do {
            let (data, response) = try await Self.send(request, using: session)
            guard let http = response as? HTTPURLResponse else {
                throw RokiError.unexpectedResponseBody(
                    method: method, path: path, status: 0, contentType: nil, data: data
                )
            }
            return (data, http)
        } catch let error as RokiError {
            throw error
        } catch {
            // A timeout is not a failed payment: the payment may exist. Retry with the SAME
            // Idempotency-Key, or look it up by external_reference before creating another.
            // A cancelled Task arrives here too - Task.isCancelled tells the two apart.
            throw RokiError.transport(method: method, path: path, underlying: error)
        }
    }

    /// Sends the request, on Apple platforms and on Linux alike.
    ///
    /// URLSession.data(for:) is an Apple API. swift-corelibs-foundation, which is the Foundation a
    /// Vapor service links against, does not have it: on Linux the file compiled all the way to
    /// here and then failed with "value of type 'URLSession' has no member 'data'". The
    /// continuation below is the portable form, and dataTask is documented to call its completion
    /// exactly once, which is what makes it safe to resume a checked continuation from it.
    private static func send(
        _ request: URLRequest, using session: URLSession
    ) async throws -> (Data, URLResponse) {
        #if canImport(FoundationNetworking)
        return try await withCheckedThrowingContinuation { continuation in
            let task = session.dataTask(with: request) { data, response, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let data, let response {
                    continuation.resume(returning: (data, response))
                } else {
                    continuation.resume(throwing: URLError(.badServerResponse))
                }
            }
            task.resume()
        }
        #else
        return try await session.data(for: request)
        #endif
    }

    private func failure(data: Data, http: HTTPURLResponse, method: String, path: String) -> RokiError {
        if let json = try? JSONDecoder().decode(RokiJSON.self, from: data) {
            return Self.apiError(status: http.statusCode, json: json, method: method, path: path)
        }
        return RokiError.unexpectedResponseBody(
            method: method, path: path, status: http.statusCode,
            contentType: http.value(forHTTPHeaderField: "Content-Type"), data: data
        )
    }

    private static func apiError(status: Int, json: RokiJSON, method: String, path: String) -> RokiError {
        var message = json["message"]?.string ?? json["error"]?.string ?? "request failed"

        if status == 422, let errors = json["errors"]?.object {
            // Sorted for the same reason the guard sorts: an unordered Dictionary would reorder
            // the sentence between runs and make two identical failures look different.
            let detail = errors.keys.sorted().map { field -> String in
                let value = errors[field]
                let text = value?.array?.compactMap { $0.string }.joined(separator: " ")
                    ?? value?.string
                    ?? ""
                return "\(field): \(text)"
            }.joined(separator: "; ")
            message += " (\(detail))"
        }
        if status == 404 {
            message += ". A 404 here usually means the identifier is of the wrong kind: void, "
                     + "refund and receipts take the transaction_id UUID, not the numeric payment id."
        }
        if status == 401 {
            message += ". Check the key prefix: sk_test_ only works against sandbox data, "
                     + "sk_live_ only against production."
        }
        return .api(status: status, message: message, method: method, path: path, response: json)
    }

    /// Turns the caller's dictionary into something JSONSerialization accepts, and refuses - by
    /// key path - the values a Swift developer actually gets wrong here.
    private static func jsonSafe(_ value: Any, at keyPath: String, operation: String) throws -> Any {
        switch value {
        case let text as String:
            return text
        case let number as Decimal:
            return number as NSDecimalNumber
        case let number as NSNumber:
            return number                 // Bool, Int and Double all bridge through here
        case let flag as Bool:
            return flag
        case let number as Int:
            return number
        case let number as Double:
            return number
        case is NSNull:
            return value
        case let dictionary as [String: Any]:
            var out: [String: Any] = [:]
            out.reserveCapacity(dictionary.count)
            for (key, inner) in dictionary {
                let innerPath = keyPath.isEmpty ? key : "\(keyPath).\(key)"
                out[key] = try jsonSafe(inner, at: innerPath, operation: operation)
            }
            return out
        case let list as [Any]:
            return try list.enumerated().map {
                try jsonSafe($0.element, at: "\(keyPath)[\($0.offset)]", operation: operation)
            }
        case is Date:
            throw RokiError.unencodableBody(
                operation: operation, field: keyPath,
                reason: "a Date has no JSON form here. ROKI reads expires_at as Honduras local "
                      + "time (UTC-6) with no offset marker, so format it yourself: a DateFormatter "
                      + "with dateFormat \"yyyy-MM-dd HH:mm:ss\", timeZone "
                      + "TimeZone(secondsFromGMT: -6 * 3600) and locale en_US_POSIX. Sending a "
                      + "Unix timestamp, or letting the device's own zone decide, shifts every "
                      + "value by hours."
            )
        case let url as URL:
            return url.absoluteString     // success_url given as a URL is unambiguous
        default:
            // An Optional boxed into Any: ["reason": maybeReason] where maybeReason is nil compiles
            // happily and then dies inside JSONSerialization naming nothing.
            let mirror = Mirror(reflecting: value)
            if mirror.displayStyle == .optional {
                if let wrapped = mirror.children.first?.value {
                    return try jsonSafe(wrapped, at: keyPath, operation: operation)
                }
                throw RokiError.unencodableBody(
                    operation: operation, field: keyPath,
                    reason: "the value is a nil Optional. Leave the key out entirely, or send "
                          + "NSNull() if you really mean a JSON null."
                )
            }
            throw RokiError.unencodableBody(
                operation: operation, field: keyPath,
                reason: "\(type(of: value)) has no JSON representation. Send strings, numbers, "
                      + "booleans, arrays and dictionaries of those, or NSNull()."
            )
        }
    }

    /// Percent-encodes one path segment. A UUID or a pm_ reference never needs it, but a URL built
    /// by concatenation is how an identifier invents a new route.
    private static func escape(_ value: String) -> String {
        value.addingPercentEncoding(withAllowedCharacters: .rokiPathSegment) ?? value
    }

    private static let logWarnings: @Sendable ([String], String, String) -> Void = { warnings, method, path in
        let text = "ROKI \(method) \(path) ignored fields: \(warnings.joined(separator: " | "))"
        #if canImport(OSLog)
        Logger(subsystem: "la.roki.connect", category: "warnings").warning("\(text, privacy: .public)")
        #else
        FileHandle.standardError.write(Data((text + "\n").utf8))
        #endif
    }
}

// MARK: - Helpers

private extension CharacterSet {
    /// The RFC 3986 unreserved set. Deliberately narrower than `urlPathAllowed`, which
    /// lets "/" through and would let an identifier reach a route of its own choosing.
    static let rokiPathSegment = CharacterSet(
        charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
    )
}

private extension Data {
    /// Hex to bytes, rejecting anything that is not an even run of hex digits.
    init?(rokiHex text: String) {
        let characters = Array(text.utf8)
        guard characters.count % 2 == 0 else { return nil }
        var bytes = [UInt8]()
        bytes.reserveCapacity(characters.count / 2)
        var index = 0
        while index < characters.count {
            guard let high = rokiHexDigit(characters[index]),
                  let low = rokiHexDigit(characters[index + 1]) else { return nil }
            bytes.append(high << 4 | low)
            index += 2
        }
        self.init(bytes)
    }
}

private func rokiHexDigit(_ byte: UInt8) -> UInt8? {
    switch byte {
    case 0x30...0x39: return byte - 0x30            // 0-9
    case 0x61...0x66: return byte - 0x61 + 10       // a-f
    case 0x41...0x46: return byte - 0x41 + 10       // A-F
    default: return nil
    }
}

/// The closest real field name.
///
/// Edit distance alone is not enough here: service_fee is 8 edits away from service_fee_enabled
/// and is by far the most common mistake against this API. A shared prefix outranks distance.
private func rokiClosest(_ needle: String, in candidates: [String]) -> String? {
    if let prefix = candidates.first(where: { $0.hasPrefix(needle) || needle.hasPrefix($0) }) {
        return prefix
    }
    var best: String?
    var bestScore = Int.max
    for candidate in candidates {
        let distance = rokiLevenshtein(needle, candidate)
        if distance < bestScore {
            bestScore = distance
            best = candidate
        }
    }
    return bestScore <= max(2, needle.count / 3) ? best : nil
}

private func rokiLevenshtein(_ a: String, _ b: String) -> Int {
    let left = Array(a.utf8)
    let right = Array(b.utf8)
    if left.isEmpty { return right.count }
    if right.isEmpty { return left.count }

    var previous = Array(0...right.count)
    for i in 1...left.count {
        var last = previous[0]
        previous[0] = i
        for j in 1...right.count {
            let current = previous[j]
            previous[j] = Swift.min(
                previous[j] + 1,
                previous[j - 1] + 1,
                last + (left[i - 1] == right[j - 1] ? 0 : 1)
            )
            last = current
        }
    }
    return previous[right.count]
}
