package yookassa

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// capture records what the fake API received, so a test can assert on the request the client built.
type capture struct {
	method         string
	path           string
	authUser       string
	authPass       string
	idempotenceKey string
	body           map[string]any
}

// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
// it, which is how every API-shaped test drives the client without touching the network.
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
	t.Helper()
	got := &capture{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got.method = r.Method
		got.path = r.URL.Path
		got.authUser, got.authPass, _ = r.BasicAuth()
		got.idempotenceKey = r.Header.Get("Idempotence-Key")
		if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
			_ = json.Unmarshal(raw, &got.body)
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_, _ = io.WriteString(w, response)
	}))
	t.Cleanup(srv.Close)
	return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
}

func TestCreatePaymentBuildsTheRequest(t *testing.T) {
	cfg, got := fakeAPI(t, http.StatusOK, `{
		"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
		"amount":{"value":"149.00","currency":"RUB"},
		"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
		"metadata":{"order_id":"0197-order"}}`)

	amount := Amount{Value: "149.00", Currency: "RUB"}
	p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
		Amount:       amount,
		Capture:      true,
		Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
		Description:  "10 chips",
		Metadata:     map[string]string{MetadataOrderID: "0197-order"},
		Receipt:      SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
	}, "0197-order")
	if err != nil {
		t.Fatalf("create payment: %v", err)
	}

	if got.method != http.MethodPost || got.path != "/payments" {
		t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
	}
	if got.authUser != "100500" || got.authPass != "secret" {
		t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
	}
	// The idempotence key is what makes a retried create return the original payment instead of
	// minting a second one for the same order.
	if got.idempotenceKey != "0197-order" {
		t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
	}
	if got.body["capture"] != true {
		t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
	}
	conf, _ := got.body["confirmation"].(map[string]any)
	if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
		t.Errorf("confirmation = %v, want a redirect with the return url", conf)
	}
	meta, _ := got.body["metadata"].(map[string]any)
	if meta[MetadataOrderID] != "0197-order" {
		t.Errorf("metadata = %v, want the order id threaded through", meta)
	}
	receipt, _ := got.body["receipt"].(map[string]any)
	customer, _ := receipt["customer"].(map[string]any)
	if customer["email"] != "buyer@example.test" {
		t.Errorf("receipt customer = %v, want the buyer email", customer)
	}
	items, _ := receipt["items"].([]any)
	if len(items) != 1 {
		t.Fatalf("receipt items = %d, want 1", len(items))
	}
	item, _ := items[0].(map[string]any)
	if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
		t.Errorf("receipt item fiscal attributes = %v", item)
	}

	if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
		t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
	}
	if p.OrderID() != "0197-order" {
		t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
	}
}

func TestGetPaymentReadsTheObject(t *testing.T) {
	cfg, got := fakeAPI(t, http.StatusOK, `{
		"id":"pay-1","status":"succeeded","paid":true,"test":true,
		"amount":{"value":"149.00","currency":"RUB"},
		"recipient":{"account_id":"100500","gateway_id":"100700"},
		"metadata":{"order_id":"0197-order"}}`)

	p, err := cfg.GetPayment(context.Background(), "pay-1")
	if err != nil {
		t.Fatalf("get payment: %v", err)
	}
	if got.method != http.MethodGet || got.path != "/payments/pay-1" {
		t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
	}
	// A read is idempotent by nature, so no key is sent.
	if got.idempotenceKey != "" {
		t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
	}
	if p.Status != StatusSucceeded || !p.Paid {
		t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
	}
	if !p.Test {
		t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
	}
	if p.Recipient.AccountID != "100500" {
		t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
	}
}

func TestCreateRefundBuildsTheRequest(t *testing.T) {
	cfg, got := fakeAPI(t, http.StatusOK, `{
		"id":"refund-1","status":"succeeded","payment_id":"pay-1",
		"amount":{"value":"149.00","currency":"RUB"}}`)

	amount := Amount{Value: "149.00", Currency: "RUB"}
	r, err := cfg.CreateRefund(context.Background(), RefundRequest{
		PaymentID: "pay-1",
		Amount:    amount,
		Receipt:   SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
	}, "0197-order")
	if err != nil {
		t.Fatalf("create refund: %v", err)
	}
	if got.method != http.MethodPost || got.path != "/refunds" {
		t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
	}
	if got.idempotenceKey != "0197-order" {
		t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
	}
	if got.body["payment_id"] != "pay-1" {
		t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
	}
	if _, ok := got.body["receipt"]; !ok {
		t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
	}
	// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
	// fund and refund rows can coexist under the same partial-unique index.
	if r.ID != "refund-1" || r.PaymentID != "pay-1" {
		t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
	}
}

func TestAPIErrorClassifiesRetryability(t *testing.T) {
	cfg, _ := fakeAPI(t, http.StatusBadRequest,
		`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)

	_, err := cfg.GetPayment(context.Background(), "pay-1")
	var apiErr *APIError
	if !errors.As(err, &apiErr) {
		t.Fatalf("error = %v, want an *APIError", err)
	}
	if apiErr.StatusCode != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", apiErr.StatusCode)
	}
	// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
	if !strings.Contains(apiErr.Error(), "receipt.items") {
		t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
	}
	if apiErr.Retryable() {
		t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
	}

	for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
		cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
		_, err := cfg.GetPayment(context.Background(), "pay-1")
		var e *APIError
		if !errors.As(err, &e) || !e.Retryable() {
			t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
		}
	}
}

func TestUnconfiguredShopMakesNoCall(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		t.Error("an unconfigured shop reached the API")
	}))
	t.Cleanup(srv.Close)

	for name, cfg := range map[string]Config{
		"no credentials": {BaseURL: srv.URL},
		"no secret key":  {ShopID: "100500", BaseURL: srv.URL},
		"no shop id":     {SecretKey: "secret", BaseURL: srv.URL},
	} {
		if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
			t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
		}
	}
}

func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
	if got := (Config{}).endpoint(); got != DefaultBaseURL {
		t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
	}
	if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
		t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
	}
}

func TestGetRefundReadsTheObject(t *testing.T) {
	cfg, got := fakeAPI(t, http.StatusOK, `{
		"id":"refund-1","status":"succeeded","payment_id":"pay-1",
		"amount":{"value":"149.00","currency":"RUB"}}`)

	r, err := cfg.GetRefund(context.Background(), "refund-1")
	if err != nil {
		t.Fatalf("get refund: %v", err)
	}
	if got.method != http.MethodGet || got.path != "/refunds/refund-1" {
		t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path)
	}
	if r.Status != StatusSucceeded || r.PaymentID != "pay-1" {
		t.Errorf("refund = %+v, want a succeeded refund of pay-1", r)
	}
}

func TestCreateRefundReportsANonFinalStatus(t *testing.T) {
	// A refund can be accepted and still be canceled later, so the caller must be able to see that it
	// is not settled rather than treat any 200 as done.
	cfg, _ := fakeAPI(t, http.StatusOK, `{
		"id":"refund-1","status":"pending","payment_id":"pay-1",
		"amount":{"value":"149.00","currency":"RUB"}}`)

	r, err := cfg.CreateRefund(context.Background(), RefundRequest{
		PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"},
	}, "0197-order")
	if err != nil {
		t.Fatalf("create refund: %v", err)
	}
	if r.Status != StatusPending {
		t.Errorf("status = %q, want pending surfaced to the caller", r.Status)
	}
}
