# frozen_string_literal: true

# ROKI Connect - Ruby client.
#
# Generated from openapi.yaml v2.0.0. Do not edit by hand: run sdk/generate.mjs.
# Standard library only - net/http, json, openssl, securerandom - so it drops into a Rails app,
# a Sinatra service or a bare script without touching the Gemfile.
#
#   roki = RokiConnect::Client.new(ENV.fetch("ROKI_SECRET_KEY"))
#
#   payment = roki.create_payment(
#     amount: 150.00,                       # decimal units - L 150.00, never cents
#     external_reference: order.id.to_s,
#     name: "Order #{order.id}"
#   )
#   redirect_to payment[:checkout_url], allow_other_host: true
#
# Responses are RokiConnect::Response: a Hash that reads with a String or a Symbol key, and that
# pattern-matches like a Ruby 3 value.
#
#   case roki.get_payment(order.roki_payment_id)
#   in { status: "paid", transaction_id: String => txn } then fulfil!(txn)
#   in { status: "pending" }                             then wait
#   end
#
# The two reversal paths are chosen by rescuing, not by reading codes:
#
#   begin
#     roki.void_transaction(txn)                  # full amount, before settlement
#   rescue RokiConnect::ValidationError           # not voidable any more
#     roki.refund_transaction(txn, amount: order.total)
#   end
#
# The environment is the key: sk_test_ is sandbox, sk_live_ is production. Same routes.

require "json"
require "net/http"
require "openssl"
require "securerandom"
require "uri"

module RokiConnect
  # The version of this client, which is what VERSION means to a Ruby reader. The version of the
  # contract it was generated from is API_VERSION.
  #
  # Plain semver, deliberately: Gem::Version does not accept semver build metadata, and the PHP
  # twin of this file proved what happens when a fingerprint is glued to a version - a plugin
  # comparing it against a minimum decided a current SDK was out of date. The fingerprint is BUILD.
  VERSION = "2.0.0"
  API_VERSION = "2.0.0"

  # Fingerprint of the contract, the alias map and the generator templates this file came out of.
  # Not a version and never compared as one: it only tells two copies apart.
  BUILD = "12e0e82"

  DEFAULT_BASE = "https://aura.roki.systems/api/connect/v1"
  USER_AGENT = "roki-connect-ruby/#{VERSION}+build.#{BUILD}"

  SIGNATURE_HEADER = "ROKI-Signature"
  SIGNATURE_FORMAT = /t=(\d+)\s*,\s*v1=([a-f0-9]{64})/i

  # Everything raised here descends from Error, so a single rescue is enough to be safe. The
  # subclasses exist because this API's reversal path is chosen by rescuing them: a void that
  # comes back ValidationError is the signal to refund instead.
  class Error < StandardError
    attr_reader :status, :response

    def initialize(message, status: nil, response: nil)
      super(message)
      @status = status
      @response = response
    end

    # 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 never on the wording.
    def validation_errors
      return {} unless response.is_a?(Hash) && response["errors"].is_a?(Hash)

      response["errors"]
    end
  end

  # Raised while building the client: no key, or the wrong kind of key.
  class ConfigurationError < Error; end

  # Raised before anything leaves the process: a missing required field, or a field this API
  # does not have.
  class InvalidRequestError < Error; end

  # The request never came back. This is NOT proof that the payment did not happen.
  class ConnectionError < Error; end

  # Any non-2xx answer. #status carries the HTTP code and #response the decoded body.
  class APIError < Error; end

  class AuthenticationError < APIError; end   # 401 - wrong key, or the other environment's key
  class NotFoundError < APIError; end         # 404 - usually the wrong kind of identifier
  class ValidationError < APIError; end       # 422 - validation or a business rule

  # Field names from other gateways, mapped to what ROKI actually calls them.
  FIELD_ALIASES = {
    "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,..."
  }.freeze

  # A response body. It is a Hash - JSON.generate, each, to_h and merge all behave - that reads
  # with either a String or a Symbol key, so the payment["checkout_url"] / payment[:checkout_url]
  # coin flip stops mattering, and that deconstructs for case/in.
  #
  # Deliberately not a struct with one attribute per documented field: this API adds fields
  # without notice - +warnings+ itself arrived that way - and a fixed shape drops what it does
  # not know about.
  class Response < Hash
    # Nested objects are wrapped too. Indifferent access at the top level while
    # payment[:customer][:email] blew up would be worse than none at all.
    def self.wrap(value)
      case value
      when Hash then value.each_with_object(new) { |(key, nested), out| out[key] = wrap(nested) }
      when Array then value.map { |nested| wrap(nested) }
      else value
      end
    end

    def [](key)
      super(key.to_s)
    end

    def []=(key, value)
      super(key.to_s, value)
    end

    def fetch(key, *rest, &block)
      super(key.to_s, *rest, &block)
    end

    def key?(key)
      super(key.to_s)
    end
    alias has_key? key?
    alias include? key?
    alias member? key?

    def dig(key, *rest)
      value = self[key]
      rest.empty? ? value : value&.dig(*rest)
    end

    # Pattern matching wants Symbols, and it reads the result of this with plain Hash semantics -
    # so this hands back a plain Hash, never another Response.
    def deconstruct_keys(_keys)
      to_h { |key, value| [key.to_sym, value] }
    end
  end

  class << self
    # Verify a webhook signature.
    #
    # raw_body must be the exact bytes received. In Rails that is request.raw_post, in Sinatra
    # request.body.read - never the parsed params re-encoded: key order, spacing and number
    # formatting all change, and the HMAC will never match.
    #
    #   head :bad_request unless RokiConnect.verify_webhook(
    #     request.raw_post,
    #     request.headers[RokiConnect::SIGNATURE_HEADER],
    #     ENV.fetch("ROKI_WEBHOOK_SECRET")
    #   )
    #
    # Header: ROKI-Signature: t={timestamp},v1={hex}
    #
    # tolerance rejects the replay of an old but genuinely signed event; pass 0 to ignore the
    # clock, which is what a fixture-driven test wants.
    def verify_webhook(raw_body, signature_header, signing_secret, tolerance: 300, now: Time.now)
      match = SIGNATURE_FORMAT.match(signature_header.to_s)
      return false if match.nil?

      timestamp = match[1]
      received = match[2].downcase
      return false if tolerance.to_i.positive? && (now.to_i - timestamp.to_i).abs > tolerance.to_i

      # Binary on both sides: a UTF-8 prefix meeting a body with raw bytes in it is an
      # Encoding::CompatibilityError, and the signature is over bytes anyway.
      signed = "#{timestamp}.".b + raw_body.to_s.b
      expected = OpenSSL::HMAC.hexdigest("SHA256", signing_secret.to_s, signed)

      secure_compare(expected, received)
    end
    alias valid_webhook_signature? verify_webhook

    # A key stable enough that a retry is the same payment, unique enough that two are not.
    def new_idempotency_key
      SecureRandom.hex(16)
    end

    private

    # Constant time, because == returns on the first byte that differs and that difference is
    # measurable: an attacker who can time the endpoint recovers the signature one byte at a
    # time and forges events.
    #
    # OpenSSL.fixed_length_secure_compare is the native primitive and is what runs on any Ruby 3.
    # The hand-written branch is not dead code: it covers a Ruby whose bundled openssl predates
    # it, where the alternative would be a plain ==. Both sides here are 64 hex characters by
    # construction - SIGNATURE_FORMAT will not match anything else - so the length check leaks
    # nothing about the secret.
    def secure_compare(expected, received)
      return false unless expected.bytesize == received.bytesize

      if OpenSSL.respond_to?(:fixed_length_secure_compare)
        OpenSSL.fixed_length_secure_compare(expected, received)
      else
        difference = expected.unpack("C*").zip(received.unpack("C*"))
                             .reduce(0) { |acc, (a, b)| acc | (a ^ b) }
        difference.zero?
      end
    end
  end

  class Client
    # Every field openapi.yaml declares, per operation. They live in one table instead of inline
    # in each method so that the methods stay short enough to read in one screen, and so that a
    # diff of the next regeneration shows what the contract changed and nothing else.
    FIELDS = {
      create_payment: {
        known: %w[
          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
        ].freeze,
        required: %w[amount external_reference name].freeze
      }.freeze,
      void_transaction: {
        known: %w[reason].freeze,
        required: [].freeze
      }.freeze,
      refund_transaction: {
        known: %w[amount reason].freeze,
        required: %w[amount].freeze
      }.freeze,
      charge_saved_card: {
        known: %w[amount currency_code external_reference metadata].freeze,
        required: %w[amount].freeze
      }.freeze,
      token_charge: {
        known: %w[payment_token amount currency_code external_reference metadata].freeze,
        required: %w[payment_token amount].freeze
      }.freeze,
      confirm_embedded_payment: {
        known: %w[
          amount currency_code payment_token publishable_key external_reference
          success_redirect_url failed_redirect_url description metadata
        ].freeze,
        required: %w[amount currency_code payment_token publishable_key].freeze
      }.freeze
    }.freeze

    # A library has no business choosing where warnings go. Kernel#warn writes to stderr, which
    # is where Rails, Puma and systemd are all already looking. Pass on_warnings: to redirect it.
    DEFAULT_WARNING_REPORTER = lambda do |warnings, verb, path|
      warn("[roki] #{verb} #{path} ignored fields: #{warnings.join(' | ')}")
    end

    attr_reader :base_url, :language

    # secret_key defaults to ENV["ROKI_SECRET_KEY"] because that is where it belongs. The literal
    # you pass here instead is the one that ends up in the repository.
    #
    # strict rejects unknown fields before the request leaves. Leave it on: the API answers 201
    # to a misspelled field and ignores it, so a typo becomes a silent production bug instead of
    # an error on your machine.
    #
    # on_warnings receives (warnings, verb, path) whenever the API reports it ignored a field.
    # With strict on, it firing at all means the spec this client was generated from is behind
    # the live API - regenerate.
    def initialize(secret_key = ENV["ROKI_SECRET_KEY"],
                   base_url: DEFAULT_BASE,
                   timeout: 30,
                   open_timeout: 10,
                   strict: true,
                   language: "en",
                   on_warnings: nil)
      if secret_key.nil? || secret_key.to_s.empty?
        raise ConfigurationError, "Missing secret key. Read it from the environment, never hard-code it."
      end
      if secret_key.to_s.start_with?("pk_")
        raise ConfigurationError, "That is a publishable key. Server calls need the sk_ secret key."
      end

      @secret_key = secret_key
      @base_url = base_url.to_s.chomp("/")
      @timeout = timeout
      @open_timeout = open_timeout
      @strict = strict
      @language = language
      @on_warnings = on_warnings || DEFAULT_WARNING_REPORTER

      # Checked here rather than at the call site: otherwise a bad reporter first blows up the
      # day the API returns a warning, which is in production, inside a payment.
      raise ConfigurationError, "on_warnings must respond to #call" unless @on_warnings.respond_to?(:call)
    end

    # True when this client is talking to the sandbox.
    def sandbox?
      @secret_key.start_with?("sk_test_")
    end

    # Rails error pages, Kernel#p and most exception reporters all call inspect, and the default
    # one prints every instance variable - the secret key included - into a log or a bug report.
    def inspect
      "#<#{self.class.name} #{sandbox? ? 'sandbox' : 'live'} base_url=#{@base_url}>"
    end
    alias to_s inspect

    # See RokiConnect.new_idempotency_key.
    def new_idempotency_key
      RokiConnect.new_idempotency_key
    end

    # Which merchant does this key belong to
    def get_merchant()
      request("GET", "/me")
    end

    # List payments
    #
    # Filters: per_page, page, status, external_reference, from, to.
    def list_payments(query = {}, **filters)
      query = normalize_hash(query, filters)
      request("GET", "/payments", query: query)
    end

    # Create a payment
    #
    # Amounts are in decimal units: 150.00 is L 150.00, and 0.01 is the minimum. They are
    # NOT cents. A Stripe integer of 15000 pasted here charges fifteen thousand lempiras, and
    # the API takes it without a word.
    #
    # Required: amount, external_reference, name.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    #
    # Idempotency-Key is generated when you do not pass one, so a retried POST cannot create
    # a second payment. Reuse the same key when you retry.
    def create_payment(body = {}, idempotency_key: nil, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :create_payment)
      request(
        "POST",
        "/payments",
        body: body,
        idempotency_key: idempotency_key || RokiConnect.new_idempotency_key
      )
    end

    # Retrieve a payment
    #
    # id is the numeric payment id returned at creation.
    def get_payment(id)
      request("GET", "/payments/#{escape(id)}")
    end

    # Void a transaction
    #
    # transaction_id is the UUID from the payment response - or the id a saved-card charge
    # returns - never the numeric payment id. The numeric id comes back as 404 with
    # code="transaction_id_expected": the endpoint exists and rejected the identifier.
    # Branch on code, not on the message, which is localized by Accept-Language.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    def void_transaction(transaction_id, body = {}, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :void_transaction)
      request("POST", "/payments/#{escape(transaction_id)}/void", body: body)
    end

    # Refund a transaction
    #
    # transaction_id is the UUID from the payment response - or the id a saved-card charge
    # returns - never the numeric payment id. The numeric id comes back as 404 with
    # code="transaction_id_expected": the endpoint exists and rejected the identifier.
    # Branch on code, not on the message, which is localized by Accept-Language.
    #
    # Amounts are in decimal units: 150.00 is L 150.00, and 0.01 is the minimum. They are
    # NOT cents. A Stripe integer of 15000 pasted here charges fifteen thousand lempiras, and
    # the API takes it without a word.
    #
    # Required: amount.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    def refund_transaction(transaction_id, body = {}, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :refund_transaction)
      request("POST", "/payments/#{escape(transaction_id)}/refund", body: body)
    end

    # Get receipt metadata
    #
    # transaction_id is the UUID from the payment response - or the id a saved-card charge
    # returns - never the numeric payment id. The numeric id comes back as 404 with
    # code="transaction_id_expected": the endpoint exists and rejected the identifier.
    # Branch on code, not on the message, which is localized by Accept-Language.
    def get_receipt(transaction_id)
      request("GET", "/payments/#{escape(transaction_id)}/receipt")
    end

    # Download the receipt PDF
    #
    # transaction_id is the UUID from the payment response - or the id a saved-card charge
    # returns - never the numeric payment id. The numeric id comes back as 404 with
    # code="transaction_id_expected": the endpoint exists and rejected the identifier.
    # Branch on code, not on the message, which is localized by Accept-Language.
    #
    # Answers application/pdf: this returns the raw bytes as a String, not a Response.
    def download_receipt(transaction_id)
      request("GET", "/payments/#{escape(transaction_id)}/receipt/download")
    end

    # List a customer's saved cards
    #
    # Filters: customer[identity_number], customer[email].
    def list_payment_methods(query = {}, **filters)
      query = normalize_hash(query, filters)
      request("GET", "/payment-methods", query: query)
    end

    # Revoke a saved card
    #
    # payment_method_id is the opaque pm_ reference delivered by the payment_method.saved
    # webhook.
    def revoke_payment_method(payment_method_id)
      request("DELETE", "/payment-methods/#{escape(payment_method_id)}")
    end

    # Charge a saved card
    #
    # payment_method_id is the opaque pm_ reference delivered by the payment_method.saved
    # webhook.
    #
    # Amounts are in decimal units: 150.00 is L 150.00, and 0.01 is the minimum. They are
    # NOT cents. A Stripe integer of 15000 pasted here charges fifteen thousand lempiras, and
    # the API takes it without a word.
    #
    # Required: amount.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    #
    # Idempotency-Key is mandatory here - without it the API answers 422. One is generated
    # when you do not pass one; reuse the same key to retry the same charge instead of making
    # a second one.
    def charge_saved_card(payment_method_id, body = {}, idempotency_key: nil, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :charge_saved_card)
      request(
        "POST",
        "/payment-methods/#{escape(payment_method_id)}/charge",
        body: body,
        idempotency_key: idempotency_key || RokiConnect.new_idempotency_key
      )
    end

    # Charge a saved card (alias)
    #
    # Amounts are in decimal units: 150.00 is L 150.00, and 0.01 is the minimum. They are
    # NOT cents. A Stripe integer of 15000 pasted here charges fifteen thousand lempiras, and
    # the API takes it without a word.
    #
    # Required: payment_token, amount.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    #
    # Idempotency-Key is mandatory here - without it the API answers 422. One is generated
    # when you do not pass one; reuse the same key to retry the same charge instead of making
    # a second one.
    def token_charge(body = {}, idempotency_key: nil, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :token_charge)
      request(
        "POST",
        "/payments/token-charge",
        body: body,
        idempotency_key: idempotency_key || RokiConnect.new_idempotency_key
      )
    end

    # Confirm an embedded-components payment
    #
    # Amounts are in decimal units: 150.00 is L 150.00, and 0.01 is the minimum. They are
    # NOT cents. A Stripe integer of 15000 pasted here charges fifteen thousand lempiras, and
    # the API takes it without a word.
    #
    # Required: amount, currency_code, payment_token, publishable_key.
    #
    # Fields go in as a Hash or as keyword arguments; Symbol keys are fine either way.
    def confirm_embedded_payment(body = {}, **fields)
      body = normalize_hash(body, fields)
      guard!(body, :confirm_embedded_payment)
      request("POST", "/confirm", body: body, base: "https://aura.roki.systems/api/connect/embed")
    end

    private

    # The API's field names are Strings on the wire and Symbols in Ruby source. Normalising both
    # into Strings here is what lets the guard, FIELD_ALIASES and JSON all look at the same key.
    def normalize_hash(given, extra)
      raise InvalidRequestError, "expected a Hash of fields, got #{given.class}" unless given.is_a?(Hash)

      out = {}
      given.each { |key, value| out[key.to_s] = value }
      extra.each { |key, value| out[key.to_s] = value }
      out
    end

    # Trap 1: this API answers 201 to a field it does not know, names it in +warnings+ and
    # carries on without the feature. Caught here, before the request leaves.
    def guard!(body, operation)
      fields = FIELDS.fetch(operation)

      missing = fields[:required].reject { |name| body.key?(name) }
      unless missing.empty?
        raise InvalidRequestError, "#{operation}: missing required field(s): #{missing.join(', ')}"
      end
      return if !@strict || fields[:known].empty?

      body.each_key do |field|
        next if fields[:known].include?(field)

        message = "#{operation}: #{field.inspect} is not a field of this API, " +
                  "and ROKI would accept the request and ignore it."
        if (advice = FIELD_ALIASES[field])
          message << " Coming from another gateway? #{advice}"
        elsif (hint = closest(field, fields[:known]))
          message << " Did you mean #{hint.inspect}?"
        end
        raise InvalidRequestError, message
      end
    end

    def request(verb, path, body: nil, query: {}, idempotency_key: nil, base: nil)
      uri = URI.parse((base || @base_url) + path)
      filtered = query.reject { |_key, value| value.nil? || value.to_s.empty? }
      uri.query = URI.encode_www_form(filtered) unless filtered.empty?

      # Net::HTTP::Get, ::Post, ::Delete - resolved by name so that a verb the contract adds
      # later needs no change here.
      message = Net::HTTP.const_get(verb.capitalize, false).new(uri)
      message["Authorization"] = "Bearer #{@secret_key}"
      message["Accept"] = "application/json"
      message["Accept-Language"] = @language
      message["User-Agent"] = USER_AGENT
      message["Idempotency-Key"] = idempotency_key if idempotency_key
      if body
        message["Content-Type"] = "application/json"
        message.body = JSON.generate(body)
      end

      handle(perform(uri, message, verb, path), verb, path)
    end

    # One connection per call. A Client is meant to be built once and shared - a Rails
    # initializer, a constant - and a Net::HTTP session cannot be shared between threads.
    def perform(uri, message, verb, path)
      Net::HTTP.start(uri.hostname, uri.port,
                      use_ssl: uri.scheme == "https",
                      open_timeout: @open_timeout,
                      read_timeout: @timeout) do |http|
        http.request(message)
      end
    rescue Timeout::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, IOError => e
      # 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.
      raise ConnectionError, "Network error calling #{verb} #{path}: #{e.message}"
    end

    def handle(response, verb, path)
      status = response.code.to_i
      raw = response.body.to_s
      content_type = response["content-type"].to_s
      claims_json = content_type.empty? || content_type.include?("json")
      data = claims_json ? parse_json(raw) : nil

      raise error_for(status, data, verb, path, raw) if status >= 400

      if data.nil? && claims_json && !raw.empty?
        # It announced JSON and it is not JSON. That is nothing like the two legitimate cases
        # below, so it stays loud instead of handing back bytes nobody expects.
        raise APIError.new("Non-JSON response from #{verb} #{path} (HTTP #{status}): #{snippet(raw)}",
                           status: status)
      end
      if data.nil?
        # Not every 2xx is JSON: the receipt download answers a PDF, and revoking a saved card
        # answers an empty body. Hand back the bytes, or an empty Response - refusing them would
        # make those two methods unusable.
        return raw.empty? ? Response.new : raw.b
      end

      report_warnings(data, verb, path)
      data
    end

    def parse_json(raw)
      return nil if raw.empty?

      Response.wrap(JSON.parse(raw))
    rescue JSON::ParserError, EncodingError
      nil
    end

    def report_warnings(data, verb, path)
      return unless data.is_a?(Hash)

      warnings = data["warnings"]
      return unless warnings.is_a?(Array) && !warnings.empty?

      # 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.
      @on_warnings.call(warnings.map(&:to_s), verb, path)
    end

    def error_for(status, data, verb, path, raw)
      body = data.is_a?(Hash) ? data : nil
      # .dup, not +@: whatever the API put in "message" is not guaranteed to be a String.
      message = ((body && (body["message"] || body["error"])) || "request failed").to_s.dup

      if status == 422 && body && body["errors"].is_a?(Hash)
        detail = body["errors"].map { |field, messages| "#{field}: #{Array(messages).join(' ')}" }
        message << " (#{detail.join('; ')})"
      end
      if body.nil? && !raw.empty?
        message << ": #{snippet(raw)}"   # a proxy's HTML page, most likely - show it
      end
      # Trap 3: void, refund and receipts take the transaction_id UUID. The numeric payment id is
      # rejected with a 404 carrying code="transaction_id_expected" - the endpoint is there, the
      # identifier is not the right kind.
      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."
      end
      if status == 401
        message << ". Check the key prefix: sk_test_ only works against sandbox data, " +
                   "sk_live_ only against production."
      end

      klass = case status
              when 401 then AuthenticationError
              when 404 then NotFoundError
              when 422 then ValidationError
              else APIError
              end
      klass.new("ROKI #{verb} #{path} failed with #{status}: #{message}", status: status, response: body)
    end

    # The body of a proxy's error page can be any bytes at all, and interpolating those straight
    # into a UTF-8 message raises Encoding::CompatibilityError - burying the failure you were
    # trying to read under an encoding error.
    def snippet(raw)
      raw[0, 200].dup.force_encoding(Encoding::UTF_8).scrub("?")
    end

    # encode_www_form_component turns a space into "+", which is right in a query string and
    # wrong in a path segment.
    def escape(value)
      URI.encode_www_form_component(value.to_s).gsub("+", "%20")
    end

    # 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.
    def closest(needle, candidates)
      prefix = candidates.find { |c| c.start_with?(needle) || needle.start_with?(c) }
      return prefix if prefix

      best = candidates.min_by { |c| levenshtein(needle, c) }
      return nil if best.nil?

      levenshtein(needle, best) <= [2, needle.length / 3].max ? best : nil
    end

    def levenshtein(a, b)
      previous = (0..b.length).to_a
      a.each_char.with_index(1) do |ca, i|
        current = [i]
        b.each_char.with_index(1) do |cb, j|
          current << [previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (ca == cb ? 0 : 1)].min
        end
        previous = current
      end
      previous[b.length]
    end
  end
end
