package paymenthandler

import (
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/pingcap/dbaas/billing2/config"
	"github.com/pingcap/dbaas/billing2/forms"
	"github.com/pingcap/dbaas/billing2/utils/panicIf"
	"github.com/shopspring/decimal"
	"github.com/stretchr/testify/assert"
	"github.com/stripe/stripe-go/v76"
	scclient "github.com/stripe/stripe-go/v76/client"
)

// stubStripeServer is a lightweight in-process Stripe mock used by PushInvoice
// tests. It captures every request the handler makes so assertions can verify
// the right calls were issued in the right order with the right payloads.
type stubStripeServer struct {
	mu        sync.Mutex
	server    *httptest.Server
	requests  []stubRequest
	nextID    int
	invoices  map[string]bool // ids that have been "created" — finalize is only valid on these
	invoiceID string          // returned for create-draft calls
	itemID    string          // returned for create-invoice-item calls
}

type stubRequest struct {
	Method string
	Path   string
	Form   map[string]string
}

func newStubStripeServer() *stubStripeServer {
	s := &stubStripeServer{
		invoices: map[string]bool{},
		nextID:   1,
	}
	s.server = httptest.NewServer(http.HandlerFunc(s.handle))
	return s
}

func (s *stubStripeServer) URL() string {
	return s.server.URL
}

func (s *stubStripeServer) Close() {
	s.server.Close()
}

func (s *stubStripeServer) handle(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	form, _ := parseFormBody(string(body))

	s.mu.Lock()
	s.requests = append(s.requests, stubRequest{
		Method: r.Method,
		Path:   r.URL.Path,
		Form:   form,
	})

	method := r.Method
	path := r.URL.Path

	// Generate a fresh id each time we are asked to create a new invoice item or
	// invoice, so callers can match the finalizer input.
	var resp string
	switch {
	case method == http.MethodPost && path == "/v1/invoices":
		s.invoiceID = "in_" + strconv.Itoa(s.nextID)
		s.nextID++
		s.invoices[s.invoiceID] = true
		resp = `{"id":"` + s.invoiceID + `","object":"invoice","status":"draft"}`
	case method == http.MethodPost && strings.HasSuffix(path, "/finalize"):
		// path looks like /v1/invoices/in_123/finalize
		parts := strings.Split(path, "/")
		id := parts[len(parts)-2]
		resp = `{"id":"` + id + `","object":"invoice","status":"open","auto_advance":` + form["auto_advance"] + `}`
	case method == http.MethodPost && path == "/v1/invoiceitems":
		s.itemID = "ii_" + strconv.Itoa(s.nextID)
		s.nextID++
		resp = `{"id":"` + s.itemID + `","object":"invoiceitem"}`
	default:
		resp = `{}`
	}
	s.mu.Unlock()

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(resp))
}

// parseFormBody parses application/x-www-form-urlencoded request bodies using
// the standard net/url package so percent-encoded keys (e.g. metadata[k2]) are
// decoded correctly. We only decode — we do not validate schema.
func parseFormBody(body string) (map[string]string, error) {
	out := map[string]string{}
	if body == "" {
		return out, nil
	}
	for _, kv := range strings.Split(body, "&") {
		parts := strings.SplitN(kv, "=", 2)
		if len(parts) != 2 {
			continue
		}
		key, err := urlDecode(parts[0])
		if err != nil {
			return nil, err
		}
		val, err := urlDecode(parts[1])
		if err != nil {
			return nil, err
		}
		out[key] = val
	}
	return out, nil
}

func urlDecode(s string) (string, error) {
	// net/url's QueryUnescape handles both "+"->" " and percent-encoded forms,
	// which is exactly what application/x-www-form-urlencoded needs.
	u, err := url.QueryUnescape(s)
	if err != nil {
		return "", err
	}
	return u, nil
}

// findRequest returns the first recorded request matching the given suffix or
// nil if none was captured.
func (s *stubStripeServer) findRequest(pathSuffix string) *stubRequest {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.requests {
		if strings.HasSuffix(s.requests[i].Path, pathSuffix) {
			return &s.requests[i]
		}
	}
	return nil
}

func (s *stubStripeServer) allRequests() []stubRequest {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]stubRequest, len(s.requests))
	copy(out, s.requests)
	return out
}

// installStubClient temporarily swaps the package-level getStripeClient helper
// so that PushInvoice talks to the supplied stub server instead of the real
// Stripe API. The returned cleanup restores the original helper.
func installStubClient(t *testing.T, stubURL string) func() {
	t.Helper()
	orig := getStripeClient
	getStripeClient = func() *scclient.API {
		// Use a custom HTTPClient that rewrites the request URL to the stub
		// server. This keeps the SDK's real API paths so the stub can match on
		// "/v1/invoices", "/v1/invoiceitems", and "/finalize" exactly.
		httpClient := &http.Client{
			Transport: &rewriteTransport{target: stubURL},
		}
		sc := &scclient.API{}
		sc.Init("sk_test_stub", &stripe.Backends{
			API: stripe.GetBackendWithConfig(stripe.APIBackend, &stripe.BackendConfig{
				HTTPClient: httpClient,
			}),
		})
		return sc
	}
	return func() {
		getStripeClient = orig
	}
}

// rewriteTransport rewrites every request URL so that it points at the stub
// server regardless of the original host the SDK tries to talk to.
type rewriteTransport struct {
	target string
}

func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	// The Stripe SDK signs requests with absolute URLs; rewrite scheme+host.
	u, err := req.URL.Parse(t.target)
	if err != nil {
		return nil, err
	}
	req.URL.Scheme = u.Scheme
	req.URL.Host = u.Host
	req.Host = u.Host
	return http.DefaultTransport.RoundTrip(req)
}

func initStripeTestConfig(t *testing.T) {
	t.Helper()
	_, codeFile, _, _ := runtime.Caller(0)
	config.Get().AddConfigPath(path.Join(path.Dir(codeFile), "../../config/"))
	panicIf.Error(config.Init())
}

func newTestInvoice(id uint, amount string, start time.Time) forms.Invoice {
	return forms.Invoice{
		InvoiceID: id,
		TenantID:  100,
		Status:    entityInvoiceStatusGenerated(),
		Type:      "test",
		StartDate: start,
		EndDate:   start.AddDate(0, 0, 1),
		Costs: forms.Costs{
			CategoryCosts: forms.CategoryCosts{
				CategoryUnitsCosts: map[string]forms.Price{
					"compute": {
						CostPrice: decimal.RequireFromString(amount),
						RawPrice:  decimal.RequireFromString(amount),
					},
				},
			},
		},
		PaymentType: "stripe",
		PaymentCode: stringPtr("cus_test"),
	}
}

func entityInvoiceStatusGenerated() string { return "generated" }

func stringPtr(s string) *string { return &s }

func newTestPaymentMethod() *forms.PaymentMethod {
	return &forms.PaymentMethod{
		TenantID:    100,
		PaymentType: "stripe",
		PaymentCode: "cus_test",
		Payable:     true,
	}
}

func newTestBillingCompany() *forms.BillingCompany {
	return &forms.BillingCompany{
		CompanyKey: "test",
		Name:       "Test Co",
	}
}

func TestPushInvoiceNotEnabledReturnsInvoice(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, false)

	s := &StripePaymentHandlerImpl{}
	invoice := newTestInvoice(1, "3.00", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))

	got, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{invoice},
		ChargeModeAuto,
	)

	a.NoError(err)
	a.NotNil(got)
	a.Equal(invoice.InvoiceID, got.InvoiceID, "when disabled the invoice should be returned unchanged")
	a.Empty(got.StripeInvoiceID)
	a.Empty(got.ChargeMode)
}

func TestPushInvoiceSingleMonthAutoCharge(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	monthStart := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	inv := newTestInvoice(101, "3.00", monthStart)

	s := &StripePaymentHandlerImpl{}
	got, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv},
		ChargeModeAuto,
	)
	a.NoError(err)
	a.NotNil(got)

	finalizeReq := stub.findRequest("/finalize")
	a.NotNil(finalizeReq, "expected a finalize request to be issued")
	a.Equal("true", finalizeReq.Form["auto_advance"], "single-month auto push must request auto_advance=true")
}

func TestPushInvoiceAccumulatedManualCharge(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)
	config.Get().Set(config.StripePushKeepDraftOnChargeModeManual, false)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	month1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
	month2 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	inv1 := newTestInvoice(201, "0.50", month1)
	inv2 := newTestInvoice(202, "3.00", month2)

	s := &StripePaymentHandlerImpl{}
	got, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv1, inv2},
		ChargeModeManual,
	)
	a.NoError(err)
	a.NotNil(got)

	finalizeReq := stub.findRequest("/finalize")
	a.NotNil(finalizeReq, "expected a finalize request to be issued")
	a.Equal("false", finalizeReq.Form["auto_advance"], "accumulated manual push must request auto_advance=false")
}

func TestPushInvoiceFinalizeInvoiceCalled(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	inv := newTestInvoice(301, "5.00", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
	s := &StripePaymentHandlerImpl{}
	got, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv},
		ChargeModeAuto,
	)
	a.NoError(err)
	a.NotNil(got)

	finalizeReq := stub.findRequest("/finalize")
	a.NotNil(finalizeReq, "FinalizeInvoice must be called to actually push the invoice")
	a.Equal(http.MethodPost, finalizeReq.Method)

	// The finalize call must come AFTER the invoice create and invoice item
	// create — otherwise we'd be finalizing a draft that has no line items.
	var sawCreate, sawItems, sawFinalize bool
	for _, req := range stub.allRequests() {
		switch {
		case req.Path == "/v1/invoices":
			sawCreate = true
		case req.Path == "/v1/invoiceitems":
			if sawCreate {
				sawItems = true
			}
		case strings.HasSuffix(req.Path, "/finalize"):
			if sawCreate && sawItems {
				sawFinalize = true
			}
		}
	}
	a.True(sawFinalize, "finalize must be issued after invoice create and invoice item add")
}

func TestPushInvoiceMetadataWritten(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	month1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
	month2 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	inv1 := newTestInvoice(401, "0.50", month1)
	inv2 := newTestInvoice(402, "3.00", month2)

	s := &StripePaymentHandlerImpl{}
	_, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv1, inv2},
		ChargeModeManual,
	)
	a.NoError(err)

	createReq := stub.findRequest("/v1/invoices")
	a.NotNil(createReq, "expected an invoice create call")
	a.Equal("401,402", createReq.Form["metadata[merged_from_invoice_ids]"])
	a.Equal("2026-05,2026-06", createReq.Form["metadata[merged_months]"])
	a.Equal(string(ChargeModeManual), createReq.Form["metadata[charge_mode]"])
	// The canonical set also carries the covered billing period (min start /
	// max end across the batch) — the AR table month attribution reads
	// invoice_start, so every push path must write it.
	a.Equal(strconv.FormatInt(month1.Unix(), 10), createReq.Form["metadata[invoice_start]"])
	a.Equal(strconv.FormatInt(inv2.EndDate.Unix(), 10), createReq.Form["metadata[invoice_end]"])
}

func TestPushInvoiceSingleMonthWritesFullMetadata(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	month := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
	inv := newTestInvoice(601, "3.00", month)

	s := &StripePaymentHandlerImpl{}
	_, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv},
		ChargeModeAuto,
	)
	a.NoError(err)

	createReq := stub.findRequest("/v1/invoices")
	a.NotNil(createReq, "expected an invoice create call")
	// A single-invoice auto push is not a merge, but must carry the same
	// canonical keys as a merged batch (merged_from_invoice_ids with a
	// single id, merged_months with its own month).
	a.Equal("601", createReq.Form["metadata[merged_from_invoice_ids]"])
	a.Equal("2026-08", createReq.Form["metadata[merged_months]"])
	a.Equal(string(ChargeModeAuto), createReq.Form["metadata[charge_mode]"])
	a.Equal(strconv.FormatInt(month.Unix(), 10), createReq.Form["metadata[invoice_start]"])
	a.Equal(strconv.FormatInt(inv.EndDate.Unix(), 10), createReq.Form["metadata[invoice_end]"])
}

func TestPayWritesFullMetadata(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	month := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
	inv := newTestInvoice(701, "3.00", month)

	s := &StripePaymentHandlerImpl{}
	got, err := s.Pay(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		&inv,
	)
	a.NoError(err)
	a.NotNil(got)

	createReq := stub.findRequest("/v1/invoices")
	a.NotNil(createReq, "expected an invoice create call")
	// Pay must write the same canonical set as PushInvoice: its historical
	// invoice_start / invoice_end plus the merged_* trace keys.
	a.Equal("701", createReq.Form["metadata[merged_from_invoice_ids]"])
	a.Equal("2026-08", createReq.Form["metadata[merged_months]"])
	a.Equal(string(ChargeModeAuto), createReq.Form["metadata[charge_mode]"])
	a.Equal(strconv.FormatInt(month.Unix(), 10), createReq.Form["metadata[invoice_start]"])
	a.Equal(strconv.FormatInt(inv.EndDate.Unix(), 10), createReq.Form["metadata[invoice_end]"])
}

func TestPushInvoiceReturnsStripeInvoiceID(t *testing.T) {
	a := assert.New(t)
	initStripeTestConfig(t)
	config.Get().Set(config.StripePaymentEnable, true)

	stub := newStubStripeServer()
	defer stub.Close()
	cleanup := installStubClient(t, stub.URL())
	defer cleanup()

	inv := newTestInvoice(501, "5.00", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
	s := &StripePaymentHandlerImpl{}
	got, err := s.PushInvoice(
		newTestPaymentMethod(),
		newTestBillingCompany(),
		[]forms.Invoice{inv},
		ChargeModeAuto,
	)
	a.NoError(err)
	a.NotNil(got)
	a.NotEmpty(got.StripeInvoiceID, "stripe invoice id must be propagated back to the caller")
	a.Equal(string(ChargeModeAuto), got.ChargeMode)
	// The id should be the one the stub returned (starts with "in_").
	a.True(strings.HasPrefix(got.StripeInvoiceID, "in_"), "unexpected stripe invoice id format: %s", got.StripeInvoiceID)
}

// Ensure the stub's parseFormBody helper behaves like the standard url.ParseQuery
// for the cases we care about. This is a defensive test so the assertions above
// remain meaningful if the implementation ever changes.
func TestStubParseFormBody(t *testing.T) {
	a := assert.New(t)
	got, err := parseFormBody("metadata[k]=v&auto_advance=true&metadata%5Bk2%5D=v2")
	a.NoError(err)
	a.Equal("v", got["metadata[k]"])
	a.Equal("true", got["auto_advance"])
	a.Equal("v2", got["metadata[k2]"])
}
