// ROKI Connect - Go client.
//
// Generated from openapi.yaml v2.0.0. Do not edit by hand: run sdk/generate.mjs.
// Standard library only - net/http, encoding/json, crypto/hmac - so there is nothing to go get.
// Requires Go 1.21 (slices, log/slog, the min and max builtins).
//
//	client, err := rokiconnect.New(os.Getenv("ROKI_SECRET_KEY"))
//	if err != nil {
//		return err
//	}
//	res, err := client.CreatePayment(ctx, rokiconnect.Fields{
//		"amount":             150.00,
//		"external_reference": strconv.FormatInt(order.ID, 10),
//		"name":               fmt.Sprintf("Order #%d", order.ID),
//	})
//	if err != nil {
//		return err
//	}
//	http.Redirect(w, r, res.Str("checkout_url"), http.StatusSeeOther)
//
// The environment is the key: sk_test_ is sandbox, sk_live_ is production. Same routes.
package rokiconnect

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"time"
)

const (
	// Version is the openapi.yaml revision this file was generated from.
	Version = "2.0.0"

	// SDKVersion is this client's own version. Plain semver, so a module or tag comparison
	// orders it. The build fingerprint is kept out of it: glued on, it reads as an unknown
	// prerelease to a semver comparator - lower than the bare version - which is exactly how
	// the PHP twin of this file started reporting itself as out of date while it was current.
	SDKVersion = "2.0.0"

	// Build fingerprints the contract, the alias map and the generator templates this file came
	// out of. It is not a version and orders nothing: it answers whether two copies are the same
	// file. It rides along in the User-Agent, which is free text nobody sorts.
	Build = "12e0e82"

	// DefaultBaseURL is the main API. The sandbox is the same host with an sk_test_ key.
	DefaultBaseURL = "https://aura.roki.systems/api/connect/v1"

	// SignatureHeader carries the webhook HMAC.
	SignatureHeader = "ROKI-Signature"

	// DefaultTolerance is how old a signed webhook may be before it is treated as a replay.
	DefaultTolerance = 300 * time.Second

	userAgent = "roki-connect-go/" + SDKVersion + "+build." + Build
)

// Fields is a request body.
//
// A map and not a generated struct per endpoint, deliberately: this API adds fields without
// warning - warnings itself arrived that way - and a fixed struct silently drops what it does not
// know. The known-field check in guard gives back the safety a struct would have provided, and it
// runs before the request leaves.
//
// For money, prefer json.Number("150.00") over a float64 when the exact decimal matters.
type Fields map[string]any

// Sentinel causes, for errors.Is. Every error this package returns is a *RokiError wrapping one
// of these, so a caller can tell a typo in a field name from a card that was declined.
var (
	ErrMissingSecretKey = errors.New("missing secret key: read it from the environment, never hard-code it")
	ErrPublishableKey   = errors.New("that is a publishable key: server calls need the sk_ secret key")
	ErrMissingField     = errors.New("missing required field")
	ErrUnknownField     = errors.New("unknown field")
	ErrTransport        = errors.New("transport failure")
	ErrDecode           = errors.New("malformed response")
	ErrAPI              = errors.New("api returned a non-2xx status")
	ErrInvalidSignature = errors.New("webhook signature does not verify")
)

// RokiError is every failure this client reports: the local checks that run before a request
// leaves, and the responses the API refused.
//
//	var rerr *rokiconnect.RokiError
//	if errors.As(err, &rerr) && rerr.Status == 422 {
//		for field, msgs := range rerr.ValidationErrors() {
//			log.Printf("%s: %s", field, strings.Join(msgs, " "))
//		}
//	}
type RokiError struct {
	Op       string // operationId, e.g. createPayment
	Method   string // HTTP method, empty when the request never left
	Path     string
	Status   int // HTTP status, 0 when the request never left
	Message  string
	Response map[string]any // decoded error body, when there was one
	Err      error          // one of the sentinels above
}

func (e *RokiError) Error() string {
	switch {
	case e.Status > 0:
		return fmt.Sprintf("roki: %s %s failed with %d: %s", e.Method, e.Path, e.Status, e.Message)
	case e.Method != "":
		return fmt.Sprintf("roki: %s %s: %s", e.Method, e.Path, e.Message)
	default:
		return "roki: " + e.Message
	}
}

// Unwrap exposes the sentinel cause to errors.Is.
func (e *RokiError) Unwrap() error { return e.Err }

// ValidationErrors returns the field-level errors, when the API sent them.
func (e *RokiError) ValidationErrors() map[string][]string {
	out := map[string][]string{}
	fields, ok := e.Response["errors"].(map[string]any)
	if !ok {
		return out
	}
	for field, raw := range fields {
		switch msgs := raw.(type) {
		case []any:
			for _, m := range msgs {
				out[field] = append(out[field], fmt.Sprint(m))
			}
		default:
			out[field] = append(out[field], fmt.Sprint(msgs))
		}
	}
	return out
}

// Response is one reply from the API, kept whole.
//
// Data holds the decoded JSON object, and is nil when the reply carries no JSON body: the receipt
// PDF, or a revoke that answers with an empty 200. The exact bytes are always in Body, which is
// also what you hand to a PDF writer.
//
// Numbers inside Data are json.Number, never float64. Decoding money through a float64 is how a
// total comes back a cent short; json.Number keeps the digits the API actually sent.
type Response struct {
	Status      int
	ContentType string
	Body        []byte
	Data        map[string]any

	// Warnings are the fields the API says it ignored. Empty is the normal case and the proof
	// that your field names are right. The text is localised by Accept-Language, so show it,
	// log it, alert on it - but never branch on its wording.
	Warnings []string
}

// Str returns a top-level string field, or "" when it is absent or not a string.
func (r *Response) Str(key string) string {
	s, _ := r.Data[key].(string)
	return s
}

// Number returns a top-level numeric field exactly as the API wrote it. Use its String method for
// display and its Float64 or Int64 methods only when you are done with the arithmetic.
func (r *Response) Number(key string) json.Number {
	n, _ := r.Data[key].(json.Number)
	return n
}

// FieldAliases maps field names from other gateways to what ROKI actually calls them.
var FieldAliases = 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,...",
}

// WarningFunc handles the fields the API reports it ignored.
type WarningFunc func(warnings []string, method, path string)

// Option configures a Client at construction.
type Option func(*Client)

// WithBaseURL points the client at another host. The environment is chosen by the key prefix, not
// by the URL, so this is for proxies and tests.
func WithBaseURL(u string) Option { return func(c *Client) { c.baseURL = u } }

// WithHTTPClient supplies your own *http.Client: your transport, your connection pool, your
// tracing. The per-call timeout still applies, through the request context.
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.httpClient = h } }

// WithTimeout bounds every call. Zero removes the bound and leaves the deadline entirely to the
// context you pass in.
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }

// WithLanguage sets Accept-Language, "es" or "en". It changes the wording of API messages only.
func WithLanguage(lang string) Option { return func(c *Client) { c.language = lang } }

// WithStrict(false) lets unknown body fields through to the API. 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 the first run.
func WithStrict(strict bool) Option { return func(c *Client) { c.strict = strict } }

// WithWarningHandler replaces the default handler, which logs through log/slog at warn level.
// With strict on, this firing means the spec this SDK was generated from is behind the live API -
// regenerate.
func WithWarningHandler(fn WarningFunc) Option { return func(c *Client) { c.onWarnings = fn } }

// CallOption tunes a single request.
type CallOption func(*callConfig)

type callConfig struct {
	idempotencyKey string
}

// WithIdempotencyKey reuses a key you chose yourself. Derive it from the order's contents, not
// only from its id: on this API a replayed key with a different body returns the original payment
// and the new body is ignored without an error.
func WithIdempotencyKey(key string) CallOption {
	return func(cfg *callConfig) { cfg.idempotencyKey = key }
}

// Client is a ROKI Connect API client. Build one per process with New and share it: it holds no
// mutable state and is safe for concurrent use by any number of goroutines.
type Client struct {
	secretKey  string
	baseURL    string
	httpClient *http.Client
	timeout    time.Duration
	strict     bool
	language   string
	onWarnings WarningFunc
}

// New returns a client for the given secret key.
//
// The key decides the environment: sk_test_ is sandbox, sk_live_ is production, over identical
// routes. Read it from the environment or your secret store - never from a literal in the source.
func New(secretKey string, opts ...Option) (*Client, error) {
	if secretKey == "" {
		return nil, &RokiError{Message: ErrMissingSecretKey.Error(), Err: ErrMissingSecretKey}
	}
	if strings.HasPrefix(secretKey, "pk_") {
		return nil, &RokiError{Message: ErrPublishableKey.Error(), Err: ErrPublishableKey}
	}

	c := &Client{
		secretKey:  secretKey,
		baseURL:    DefaultBaseURL,
		httpClient: http.DefaultClient,
		timeout:    30 * time.Second,
		strict:     true,
		language:   "en",
		onWarnings: defaultWarningHandler,
	}
	for _, opt := range opts {
		opt(c)
	}
	c.baseURL = strings.TrimRight(c.baseURL, "/")
	if c.httpClient == nil {
		c.httpClient = http.DefaultClient
	}
	if c.onWarnings == nil {
		c.onWarnings = defaultWarningHandler
	}
	return c, nil
}

// IsSandbox reports whether this client is talking to sandbox data.
func (c *Client) IsSandbox() bool { return strings.HasPrefix(c.secretKey, "sk_test_") }

// NewIdempotencyKey returns a key stable enough that a retry is the same payment, unique enough
// that two payments are never merged into one.
func NewIdempotencyKey() (string, error) {
	var raw [16]byte
	if _, err := rand.Read(raw[:]); err != nil {
		return "", &RokiError{Message: "could not read random bytes for an idempotency key: " + err.Error(), Err: err}
	}
	return hex.EncodeToString(raw[:]), nil
}

// ---------------------------------------------------------------- operations

// GetMerchant calls GET /me (operationId getMerchant).
//
// Which merchant does this key belong to.
func (c *Client) GetMerchant(ctx context.Context) (*Response, error) {
	return c.do(ctx, call{
		op:     "getMerchant",
		method: "GET",
		path:   "/me",
	})
}

// ListPaymentsQuery are the filters for GET /payments.
//
// Every field is optional: one left at its zero value is not sent at all, which is not the same
// as sending it empty - an empty filter value is a 422, not "no filter".
type ListPaymentsQuery struct {
	// PerPage fills per_page.
	// Page size. Defaults to 20. Note limit is ignored - only per_page changes the page size.
	PerPage int

	// Page fills page.
	// 1-based page number. Use meta.last_page to know when to stop.
	Page int

	// Status fills status.
	// Filter by payment status. An unrecognized value returns 422 rather than being ignored.
	Status string

	// ExternalReference fills external_reference.
	// Filter by your own order id. Since external_reference is not unique, this can return more
	// than one payment.
	ExternalReference string

	// From fills from.
	// Start of a creation-date range, YYYY-MM-DD, Honduras time.
	From string

	// To fills to.
	// End of the creation-date range, inclusive.
	To string
}

func (q ListPaymentsQuery) values() url.Values {
	v := url.Values{}
	if q.PerPage != 0 {
		v.Set("per_page", strconv.Itoa(q.PerPage))
	}
	if q.Page != 0 {
		v.Set("page", strconv.Itoa(q.Page))
	}
	if q.Status != "" {
		v.Set("status", q.Status)
	}
	if q.ExternalReference != "" {
		v.Set("external_reference", q.ExternalReference)
	}
	if q.From != "" {
		v.Set("from", q.From)
	}
	if q.To != "" {
		v.Set("to", q.To)
	}
	return v
}

// ListPayments calls GET /payments (operationId listPayments).
//
// List payments.
//
// Filters live in ListPaymentsQuery; the zero value sends none of them.
func (c *Client) ListPayments(ctx context.Context, query ListPaymentsQuery) (*Response, error) {
	return c.do(ctx, call{
		op:     "listPayments",
		method: "GET",
		path:   "/payments",
		query:  query.values(),
	})
}

// CreatePayment calls POST /payments (operationId createPayment).
//
// Create a payment.
//
// amount is in decimal units, never cents: 150.00 means L 150.00, and the minimum is 0.01.
// Coming from Stripe, divide its integer by 100 before sending - otherwise you charge a
// hundredth of the order and nobody notices until reconciliation.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason.
//
// An Idempotency-Key travels with this call. Pass your own with WithIdempotencyKey so that a
// retry of the same order is the same payment; with no key one is generated per call, and a
// retry after a timeout would charge twice.
func (c *Client) CreatePayment(ctx context.Context, body Fields, opts ...CallOption) (*Response, error) {
	if err := c.guard("createPayment", body,
		[]string{
			"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",
		},
		[]string{"amount", "external_reference", "name"}); err != nil {
		return nil, err
	}

	key, err := idempotencyKeyFor(opts)
	if err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:      "createPayment",
		method:  "POST",
		path:    "/payments",
		body:    body,
		idemKey: key,
	})
}

// GetPayment calls GET /payments/{id} (operationId getPayment).
//
// Retrieve a payment.
func (c *Client) GetPayment(ctx context.Context, id int64) (*Response, error) {
	return c.do(ctx, call{
		op:     "getPayment",
		method: "GET",
		path:   "/payments/" + strconv.FormatInt(id, 10),
	})
}

// VoidTransaction calls POST /payments/{transaction_id}/void (operationId voidTransaction).
//
// Void a transaction.
//
// This route is indexed by the transaction_id UUID from the payment, not by the numeric payment
// id. That is why the parameter is a string: the numeric id comes back as 404 with
// code="transaction_id_expected", which means the endpoint exists and rejected the identifier.
// Branch on code, not on the message, which is localized.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason. The body is optional on
// this route: pass nil to send none.
func (c *Client) VoidTransaction(ctx context.Context, transactionID string, body Fields) (*Response, error) {
	if err := c.guard("voidTransaction", body,
		[]string{"reason"},
		nil); err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:     "voidTransaction",
		method: "POST",
		path:   "/payments/" + url.PathEscape(transactionID) + "/void",
		body:   body,
	})
}

// RefundTransaction calls POST /payments/{transaction_id}/refund (operationId refundTransaction).
//
// Refund a transaction.
//
// This route is indexed by the transaction_id UUID from the payment, not by the numeric payment
// id. That is why the parameter is a string: the numeric id comes back as 404 with
// code="transaction_id_expected", which means the endpoint exists and rejected the identifier.
// Branch on code, not on the message, which is localized.
//
// amount is in decimal units, never cents: 150.00 means L 150.00, and the minimum is 0.01.
// Coming from Stripe, divide its integer by 100 before sending - otherwise you charge a
// hundredth of the order and nobody notices until reconciliation.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason.
func (c *Client) RefundTransaction(ctx context.Context, transactionID string, body Fields) (*Response, error) {
	if err := c.guard("refundTransaction", body,
		[]string{"amount", "reason"},
		[]string{"amount"}); err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:     "refundTransaction",
		method: "POST",
		path:   "/payments/" + url.PathEscape(transactionID) + "/refund",
		body:   body,
	})
}

// GetReceipt calls GET /payments/{transaction_id}/receipt (operationId getReceipt).
//
// Get receipt metadata.
//
// This route is indexed by the transaction_id UUID from the payment, not by the numeric payment
// id. That is why the parameter is a string: the numeric id comes back as 404 with
// code="transaction_id_expected", which means the endpoint exists and rejected the identifier.
// Branch on code, not on the message, which is localized.
func (c *Client) GetReceipt(ctx context.Context, transactionID string) (*Response, error) {
	return c.do(ctx, call{
		op:     "getReceipt",
		method: "GET",
		path:   "/payments/" + url.PathEscape(transactionID) + "/receipt",
	})
}

// DownloadReceipt calls GET /payments/{transaction_id}/receipt/download (operationId downloadReceipt).
//
// Download the receipt PDF.
//
// This route is indexed by the transaction_id UUID from the payment, not by the numeric payment
// id. That is why the parameter is a string: the numeric id comes back as 404 with
// code="transaction_id_expected", which means the endpoint exists and rejected the identifier.
// Branch on code, not on the message, which is localized.
//
// The reply is application/pdf, not JSON: the bytes are in Response.Body and Response.Data is
// nil. Write Body straight to the file or to the HTTP response.
func (c *Client) DownloadReceipt(ctx context.Context, transactionID string) (*Response, error) {
	return c.do(ctx, call{
		op:     "downloadReceipt",
		method: "GET",
		path:   "/payments/" + url.PathEscape(transactionID) + "/receipt/download",
	})
}

// ListPaymentMethodsQuery are the filters for GET /payment-methods.
//
// Every field is optional: one left at its zero value is not sent at all, which is not the same
// as sending it empty - an empty filter value is a 422, not "no filter".
type ListPaymentMethodsQuery struct {
	// CustomerIdentityNumber fills customer[identity_number].
	// National identity number of the customer. Preferred identifier.
	CustomerIdentityNumber string

	// CustomerEmail fills customer[email].
	// Customer email, when the identity number is unknown.
	CustomerEmail string
}

func (q ListPaymentMethodsQuery) values() url.Values {
	v := url.Values{}
	if q.CustomerIdentityNumber != "" {
		v.Set("customer[identity_number]", q.CustomerIdentityNumber)
	}
	if q.CustomerEmail != "" {
		v.Set("customer[email]", q.CustomerEmail)
	}
	return v
}

// ListPaymentMethods calls GET /payment-methods (operationId listPaymentMethods).
//
// List a customer's saved cards.
//
// Filters live in ListPaymentMethodsQuery; the zero value sends none of them.
func (c *Client) ListPaymentMethods(ctx context.Context, query ListPaymentMethodsQuery) (*Response, error) {
	return c.do(ctx, call{
		op:     "listPaymentMethods",
		method: "GET",
		path:   "/payment-methods",
		query:  query.values(),
	})
}

// RevokePaymentMethod calls DELETE /payment-methods/{payment_method_id} (operationId revokePaymentMethod).
//
// Revoke a saved card.
func (c *Client) RevokePaymentMethod(ctx context.Context, paymentMethodID string) (*Response, error) {
	return c.do(ctx, call{
		op:     "revokePaymentMethod",
		method: "DELETE",
		path:   "/payment-methods/" + url.PathEscape(paymentMethodID),
	})
}

// ChargeSavedCard calls POST /payment-methods/{payment_method_id}/charge (operationId chargeSavedCard).
//
// Charge a saved card.
//
// amount is in decimal units, never cents: 150.00 means L 150.00, and the minimum is 0.01.
// Coming from Stripe, divide its integer by 100 before sending - otherwise you charge a
// hundredth of the order and nobody notices until reconciliation.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason.
//
// An Idempotency-Key travels with this call. Pass your own with WithIdempotencyKey so that a
// retry of the same order is the same payment; with no key one is generated per call, and a
// retry after a timeout would charge twice.
func (c *Client) ChargeSavedCard(ctx context.Context, paymentMethodID string, body Fields, opts ...CallOption) (*Response, error) {
	if err := c.guard("chargeSavedCard", body,
		[]string{"amount", "currency_code", "external_reference", "metadata"},
		[]string{"amount"}); err != nil {
		return nil, err
	}

	key, err := idempotencyKeyFor(opts)
	if err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:      "chargeSavedCard",
		method:  "POST",
		path:    "/payment-methods/" + url.PathEscape(paymentMethodID) + "/charge",
		body:    body,
		idemKey: key,
	})
}

// TokenCharge calls POST /payments/token-charge (operationId tokenCharge).
//
// Charge a saved card (alias).
//
// amount is in decimal units, never cents: 150.00 means L 150.00, and the minimum is 0.01.
// Coming from Stripe, divide its integer by 100 before sending - otherwise you charge a
// hundredth of the order and nobody notices until reconciliation.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason.
//
// An Idempotency-Key travels with this call. Pass your own with WithIdempotencyKey so that a
// retry of the same order is the same payment; with no key one is generated per call, and a
// retry after a timeout would charge twice.
func (c *Client) TokenCharge(ctx context.Context, body Fields, opts ...CallOption) (*Response, error) {
	if err := c.guard("tokenCharge", body,
		[]string{"payment_token", "amount", "currency_code", "external_reference", "metadata"},
		[]string{"payment_token", "amount"}); err != nil {
		return nil, err
	}

	key, err := idempotencyKeyFor(opts)
	if err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:      "tokenCharge",
		method:  "POST",
		path:    "/payments/token-charge",
		body:    body,
		idemKey: key,
	})
}

// ConfirmEmbeddedPayment calls POST /confirm (operationId confirmEmbeddedPayment).
//
// Confirm an embedded-components payment.
//
// amount is in decimal units, never cents: 150.00 means L 150.00, and the minimum is 0.01.
// Coming from Stripe, divide its integer by 100 before sending - otherwise you charge a
// hundredth of the order and nobody notices until reconciliation.
//
// Unknown keys in body are rejected here, before the request leaves: ROKI would answer 201 and
// ignore them. Pass WithStrict(false) to New only if you have a reason.
//
// Runs against https://aura.roki.systems/api/connect/embed, not the default base URL. The SDK
// already points it there.
func (c *Client) ConfirmEmbeddedPayment(ctx context.Context, body Fields) (*Response, error) {
	if err := c.guard("confirmEmbeddedPayment", body,
		[]string{
			"amount", "currency_code", "payment_token", "publishable_key", "external_reference",
			"success_redirect_url", "failed_redirect_url", "description", "metadata",
		},
		[]string{"amount", "currency_code", "payment_token", "publishable_key"}); err != nil {
		return nil, err
	}

	return c.do(ctx, call{
		op:     "confirmEmbeddedPayment",
		method: "POST",
		path:   "/confirm",
		body:   body,
		base:   "https://aura.roki.systems/api/connect/embed",
	})
}

// ---------------------------------------------------------------- webhooks

// VerifyWebhook reports whether signatureHeader signs rawBody, within the default 300s tolerance.
//
// rawBody must be the exact bytes received. Re-encoding the parsed JSON changes key order, spacing
// and number formatting, and the HMAC will never match. See VerifyWebhookRequest for the net/http
// version that gets this right for you.
//
// Header: ROKI-Signature: t={unix},v1={hex}
func VerifyWebhook(rawBody []byte, signatureHeader, signingSecret string) bool {
	return VerifyWebhookAt(rawBody, signatureHeader, signingSecret, time.Now(), DefaultTolerance)
}

// VerifyWebhookAt is VerifyWebhook with the clock and the tolerance supplied, which is what makes
// the check testable. A tolerance of zero skips the age check entirely.
func VerifyWebhookAt(rawBody []byte, signatureHeader, signingSecret string, now time.Time, tolerance time.Duration) bool {
	if signingSecret == "" {
		return false
	}
	timestamp, received, ok := parseSignature(signatureHeader)
	if !ok {
		return false
	}

	if tolerance > 0 {
		seconds, err := strconv.ParseInt(timestamp, 10, 64)
		if err != nil {
			return false
		}
		age := now.Sub(time.Unix(seconds, 0))
		if age < 0 {
			age = -age
		}
		if age > tolerance {
			return false // replay of an old, genuinely signed event
		}
	}

	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write([]byte(timestamp))
	mac.Write([]byte("."))
	mac.Write(rawBody)

	// hmac.Equal, not bytes.Equal and not ==: a comparison that stops at the first wrong byte
	// tells an attacker how much of the signature was right, one request at a time.
	return hmac.Equal(mac.Sum(nil), received)
}

// VerifyWebhookRequest verifies an incoming webhook and returns the raw body it verified.
//
// It reads the body and puts it back, so the handler can still decode r.Body afterwards:
//
//	raw, err := rokiconnect.VerifyWebhookRequest(r, secret)
//	if err != nil {
//		http.Error(w, "bad signature", http.StatusBadRequest)
//		return
//	}
//	var event map[string]any
//	json.Unmarshal(raw, &event)
//
// Verify before you act, and answer 2xx quickly - ROKI retries on anything else.
func VerifyWebhookRequest(r *http.Request, signingSecret string) ([]byte, error) {
	raw, err := io.ReadAll(r.Body)
	r.Body.Close()
	if err != nil {
		return nil, &RokiError{Message: "could not read the webhook body: " + err.Error(), Err: err}
	}
	r.Body = io.NopCloser(bytes.NewReader(raw))

	if !VerifyWebhook(raw, r.Header.Get(SignatureHeader), signingSecret) {
		return nil, &RokiError{Message: ErrInvalidSignature.Error(), Err: ErrInvalidSignature}
	}
	return raw, nil
}

// parseSignature pulls t and v1 out of "t=1699999999,v1=<64 hex>", tolerating spaces, a different
// order and elements this version does not know.
func parseSignature(header string) (timestamp string, signature []byte, ok bool) {
	for _, element := range strings.Split(header, ",") {
		name, value, found := strings.Cut(strings.TrimSpace(element), "=")
		if !found {
			continue
		}
		switch strings.TrimSpace(name) {
		case "t":
			timestamp = strings.TrimSpace(value)
		case "v1":
			decoded, err := hex.DecodeString(strings.ToLower(strings.TrimSpace(value)))
			if err != nil || len(decoded) != sha256.Size {
				return "", nil, false
			}
			signature = decoded
		}
	}
	if timestamp == "" || signature == nil {
		return "", nil, false
	}
	return timestamp, signature, true
}

// ---------------------------------------------------------------- internals

type call struct {
	op      string
	method  string
	path    string
	body    Fields
	query   url.Values
	idemKey string
	base    string
}

func idempotencyKeyFor(opts []CallOption) (string, error) {
	var cfg callConfig
	for _, opt := range opts {
		opt(&cfg)
	}
	if cfg.idempotencyKey != "" {
		return cfg.idempotencyKey, nil
	}
	return NewIdempotencyKey()
}

func defaultWarningHandler(warnings []string, method, path string) {
	slog.Warn("roki: the API ignored fields it does not recognise",
		"method", method, "path", path, "warnings", warnings)
}

func (c *Client) guard(op string, body Fields, known, required []string) error {
	var missing []string
	for _, name := range required {
		if _, ok := body[name]; !ok {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		return &RokiError{
			Op:      op,
			Message: fmt.Sprintf("%s: missing required field(s): %s", op, strings.Join(missing, ", ")),
			Err:     ErrMissingField,
		}
	}
	if !c.strict || len(known) == 0 {
		return nil
	}

	// Map iteration order is random in Go. Sorting first means the same wrong body always
	// produces the same error, instead of a test that fails one run in three.
	sent := make([]string, 0, len(body))
	for name := range body {
		sent = append(sent, name)
	}
	slices.Sort(sent)

	for _, field := range sent {
		if slices.Contains(known, field) {
			continue
		}
		msg := fmt.Sprintf("%s: %q is not a field of this API, and ROKI would accept the request and ignore it.", op, field)
		if alias, ok := FieldAliases[field]; ok {
			msg += " Coming from another gateway? " + alias
		} else if hint := closest(field, known); hint != "" {
			msg += fmt.Sprintf(" Did you mean %q?", hint)
		}
		return &RokiError{Op: op, Message: msg, Err: ErrUnknownField}
	}
	return nil
}

func (c *Client) do(ctx context.Context, r call) (*Response, error) {
	base := c.baseURL
	if r.base != "" {
		base = strings.TrimRight(r.base, "/")
	}
	target := base + r.path
	if len(r.query) > 0 {
		target += "?" + r.query.Encode()
	}

	var payload io.Reader
	if r.body != nil {
		encoded, err := json.Marshal(r.body)
		if err != nil {
			return nil, &RokiError{
				Op: r.op, Method: r.method, Path: r.path,
				Message: "could not encode the request body: " + err.Error(),
				Err:     err,
			}
		}
		payload = bytes.NewReader(encoded)
	}

	if c.timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, c.timeout)
		defer cancel()
	}

	req, err := http.NewRequestWithContext(ctx, r.method, target, payload)
	if err != nil {
		return nil, &RokiError{
			Op: r.op, Method: r.method, Path: r.path,
			Message: "could not build the request: " + err.Error(),
			Err:     err,
		}
	}
	req.Header.Set("Authorization", "Bearer "+c.secretKey)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Accept-Language", c.language)
	req.Header.Set("User-Agent", userAgent)
	if r.idemKey != "" {
		req.Header.Set("Idempotency-Key", r.idemKey)
	}
	if r.body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	res, err := c.httpClient.Do(req)
	if err != nil {
		// A timeout is not a failed payment: the payment may well exist. Retry with the SAME
		// Idempotency-Key, or look it up by external_reference before creating another.
		return nil, &RokiError{
			Op: r.op, Method: r.method, Path: r.path,
			Message: "network error: " + err.Error(),
			Err:     fmt.Errorf("%w: %w", ErrTransport, err),
		}
	}
	defer res.Body.Close()

	raw, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, &RokiError{
			Op: r.op, Method: r.method, Path: r.path, Status: res.StatusCode,
			Message: "could not read the response body: " + err.Error(),
			Err:     fmt.Errorf("%w: %w", ErrTransport, err),
		}
	}

	out := &Response{
		Status:      res.StatusCode,
		ContentType: res.Header.Get("Content-Type"),
		Body:        raw,
	}

	// Only a JSON object is decoded. A PDF receipt and an empty 200 both arrive here legitimately,
	// and neither is an error: their bytes stay in Body and Data stays nil.
	if trimmed := bytes.TrimSpace(raw); len(trimmed) > 0 && trimmed[0] == '{' {
		dec := json.NewDecoder(bytes.NewReader(trimmed))
		dec.UseNumber()
		if err := dec.Decode(&out.Data); err != nil {
			return nil, &RokiError{
				Op: r.op, Method: r.method, Path: r.path, Status: res.StatusCode,
				Message: "malformed JSON in the response: " + err.Error(),
				Err:     fmt.Errorf("%w: %w", ErrDecode, err),
			}
		}
	}

	if res.StatusCode >= 400 {
		return nil, errorFor(r, res.StatusCode, out.Data)
	}

	// 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 reported, ok := out.Data["warnings"].([]any); ok && len(reported) > 0 {
		for _, w := range reported {
			out.Warnings = append(out.Warnings, fmt.Sprint(w))
		}
		c.onWarnings(out.Warnings, r.method, r.path)
	}

	return out, nil
}

func errorFor(r call, status int, data map[string]any) *RokiError {
	message := "request failed"
	if m, ok := data["message"].(string); ok && m != "" {
		message = m
	} else if m, ok := data["error"].(string); ok && m != "" {
		message = m
	}

	e := &RokiError{
		Op: r.op, Method: r.method, Path: r.path, Status: status,
		Message:  message,
		Response: data,
		Err:      ErrAPI,
	}

	if status == http.StatusUnprocessableEntity {
		if detail := e.ValidationErrors(); len(detail) > 0 {
			fields := make([]string, 0, len(detail))
			for field := range detail {
				fields = append(fields, field)
			}
			slices.Sort(fields)
			parts := make([]string, 0, len(fields))
			for _, field := range fields {
				parts = append(parts, field+": "+strings.Join(detail[field], " "))
			}
			e.Message += " (" + strings.Join(parts, "; ") + ")"
		}
	}
	if status == http.StatusNotFound {
		e.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 == http.StatusUnauthorized {
		e.Message += ". Check the key prefix: sk_test_ only works against sandbox data, " +
			"sk_live_ only against production."
	}
	return e
}

// closest returns the real field name nearest to needle, or "" when nothing is near enough.
//
// Edit distance alone is not enough here: service_fee is 8 edits from service_fee_enabled and is
// by far the most common mistake against this API. A shared prefix outranks distance.
func closest(needle string, candidates []string) string {
	for _, c := range candidates {
		if strings.HasPrefix(c, needle) || strings.HasPrefix(needle, c) {
			return c
		}
	}
	best, bestScore := "", -1
	for _, c := range candidates {
		if d := levenshtein(needle, c); bestScore < 0 || d < bestScore {
			best, bestScore = c, d
		}
	}
	if bestScore >= 0 && bestScore <= max(2, len(needle)/3) {
		return best
	}
	return ""
}

func levenshtein(a, b string) int {
	prev := make([]int, len(b)+1)
	for i := range prev {
		prev[i] = i
	}
	for i := 1; i <= len(a); i++ {
		last := prev[0]
		prev[0] = i
		for jj := 1; jj <= len(b); jj++ {
			tmp := prev[jj]
			cost := 1
			if a[i-1] == b[jj-1] {
				cost = 0
			}
			prev[jj] = min(prev[jj]+1, prev[jj-1]+1, last+cost)
			last = tmp
		}
	}
	return prev[len(b)]
}
