// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a
// hosted payment the client is redirected to, re-reads a payment to confirm what really happened,
// and issues a refund. It is pure provider glue — no database, no payments-domain coupling — so the
// payments domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature:
// authenticity is established by re-reading the object with GetPayment (the notification body is a
// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every
// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund
// returns the original object instead of charging twice; callers pass the order id as that key.
//
// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment,
// so a notification resolves to an order without trusting anything else in the body.
package yookassa

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
)

// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an
// httptest server).
const DefaultBaseURL = "https://api.yookassa.ru/v3"

// MetadataOrderID is the metadata key carrying our order id through the provider and back on the
// payment object — the only link a notification gives us to an order.
const MetadataOrderID = "order_id"

// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled;
// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use.
const (
	StatusPending           = "pending"
	StatusWaitingForCapture = "waiting_for_capture"
	StatusSucceeded         = "succeeded"
	StatusCanceled          = "canceled"
)

// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the
// payment form and returns the customer to the return URL afterwards.
const ConfirmationRedirect = "redirect"

// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is
// the ceiling for a provider that has stopped answering.
const requestTimeout = 20 * time.Second

// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint
// cannot exhaust memory.
const maxResponseBytes = 1 << 20

// apiClient is shared by every shop so connections pool across them. It carries only a ceiling
// timeout; per-request cancellation rides the context.
var apiClient = &http.Client{Timeout: requestTimeout}

// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as
// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the
// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides
// DefaultBaseURL when set.
type Config struct {
	ShopID    string
	SecretKey string
	IsTest    bool
	BaseURL   string
}

// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an
// ISO-4217 currency code. payments.Money.Major() already renders Value in this form.
type Amount struct {
	Value    string `json:"value"`
	Currency string `json:"currency"`
}

// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer
// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page
// the customer must be sent to.
type Confirmation struct {
	Type            string `json:"type"`
	ReturnURL       string `json:"return_url,omitempty"`
	ConfirmationURL string `json:"confirmation_url,omitempty"`
}

// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an
// incoming notification is attributed to one of several configured shops.
type Recipient struct {
	AccountID string `json:"account_id"`
	GatewayID string `json:"gateway_id"`
}

// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network,
// merchant) and why. It is reported to the operator, not to the payer.
type Cancellation struct {
	Party  string `json:"party"`
	Reason string `json:"reason"`
}

// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture
// true settles in one stage: a paid payment goes straight to succeeded with no capture call.
type PaymentRequest struct {
	Amount       Amount            `json:"amount"`
	Capture      bool              `json:"capture"`
	Confirmation Confirmation      `json:"confirmation"`
	Description  string            `json:"description,omitempty"`
	Metadata     map[string]string `json:"metadata,omitempty"`
	Receipt      *Receipt          `json:"receipt,omitempty"`
}

// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true
// for a payment made against a test shop. Metadata echoes what the create call sent, so
// Metadata[MetadataOrderID] identifies the order.
type Payment struct {
	ID                  string            `json:"id"`
	Status              string            `json:"status"`
	Paid                bool              `json:"paid"`
	Test                bool              `json:"test"`
	Amount              Amount            `json:"amount"`
	Description         string            `json:"description"`
	Confirmation        Confirmation      `json:"confirmation"`
	Metadata            map[string]string `json:"metadata"`
	Recipient           Recipient         `json:"recipient"`
	CancellationDetails *Cancellation     `json:"cancellation_details,omitempty"`
}

// OrderID returns the order the payment funds, taken from the metadata the create call threaded
// through. It is empty for a payment we did not originate.
func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] }

// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it;
// Receipt carries the refund receipt required when the shop registers receipts through YooKassa.
type RefundRequest struct {
	PaymentID   string   `json:"payment_id"`
	Amount      Amount   `json:"amount"`
	Description string   `json:"description,omitempty"`
	Receipt     *Receipt `json:"receipt,omitempty"`
}

// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what
// the ledger records as the refund's idempotency key.
type Refund struct {
	ID        string `json:"id"`
	Status    string `json:"status"`
	PaymentID string `json:"payment_id"`
	Amount    Amount `json:"amount"`
}

// ErrUnconfigured is returned when a call is attempted on a shop with no credentials.
var ErrUnconfigured = errors.New("yookassa: shop is not configured")

// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error
// object and name the offending field, which is what makes a malformed receipt diagnosable.
type APIError struct {
	StatusCode  int    `json:"-"`
	Type        string `json:"type"`
	ID          string `json:"id"`
	Code        string `json:"code"`
	Description string `json:"description"`
	Parameter   string `json:"parameter"`
	RetryAfter  int    `json:"retry_after"`
}

// Error renders the status together with YooKassa's own description, including the offending
// parameter when the API named one.
func (e *APIError) Error() string {
	msg := e.Description
	if msg == "" {
		msg = e.Code
	}
	if e.Parameter != "" {
		return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter)
	}
	return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg)
}

// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is
// a permanent rejection of this request. Callers use it to decide whether to ask the provider to
// redeliver a notification or to accept it as durably handled.
func (e *APIError) Retryable() bool {
	return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
}

// Configured reports whether the shop has credentials to call the API with.
func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" }

// CreatePayment opens a payment for the order and returns the created object, whose
// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the
// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order
// id and never mint a second payment for the same order.
func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) {
	var out Payment
	err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out)
	return out, err
}

// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa
// notifications are unsigned, so only the object returned here may be acted on.
func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) {
	var out Payment
	err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out)
	return out, err
}

// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey
// guards against a double refund on a retry; callers pass the order id. The returned refund is not
// necessarily final — check Status before acting on it.
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
	var out Refund
	err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
	return out, err
}

// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund
// notification is unsigned, so only the object returned here may be acted on.
func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) {
	var out Refund
	err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &out)
	return out, err
}

// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
	if !c.Configured() {
		return ErrUnconfigured
	}
	var payload io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err)
		}
		payload = bytes.NewReader(buf)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload)
	if err != nil {
		return fmt.Errorf("yookassa: build %s %s: %w", method, path, err)
	}
	req.SetBasicAuth(c.ShopID, c.SecretKey)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if idempotenceKey != "" {
		req.Header.Set("Idempotence-Key", idempotenceKey)
	}
	resp, err := apiClient.Do(req)
	if err != nil {
		return fmt.Errorf("yookassa: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
	if err != nil {
		return fmt.Errorf("yookassa: read %s %s: %w", method, path, err)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		apiErr := &APIError{StatusCode: resp.StatusCode}
		// The body is YooKassa's error object on a handled fault and something else entirely on an
		// edge failure; either way the status alone is enough to classify the error.
		_ = json.Unmarshal(raw, apiErr)
		return apiErr
	}
	if err := json.Unmarshal(raw, out); err != nil {
		return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err)
	}
	return nil
}

// endpoint returns the API root for this shop, without a trailing slash.
func (c Config) endpoint() string {
	if c.BaseURL == "" {
		return DefaultBaseURL
	}
	return strings.TrimSuffix(c.BaseURL, "/")
}
