package tests

import (
	"errors"
	"testing"
	"time"

	"github.com/pingcap/dbaas/billing2/billing_errors"
	"github.com/pingcap/dbaas/billing2/db"
	"github.com/pingcap/dbaas/billing2/db/entity"
	"github.com/pingcap/dbaas/billing2/forms"
	"github.com/pingcap/dbaas/billing2/model/payment"
	"github.com/pingcap/dbaas/billing2/model/payment/paymentsub"
	"github.com/pingcap/dbaas/billing2/utils/tests"
	"github.com/shopspring/decimal"
	"github.com/stretchr/testify/suite"
)

// stripePayingScanInvoiceModel is shared across all tests in this file.
var stripePayingScanInvoiceModel = &payment.InvoiceModel{}

// StripePayingScanSuite covers the charge_mode enum validation added to
// BatchUpdateInvoicesToSENT / BatchUpdateInvoicesToSENTWithDB in M4. The
// validation must run before any transaction opens so bogus inputs fail
// fast without touching the DB.
//
// Phase 2 of the invoice/PM decoupling also layers in paymentMethod
// validation on both wrappers — the caller MUST supply the active payment
// method used to settle the invoices, otherwise the SQL ends up with NULL
// payment_method_id + payment_type and the operations dashboard silently
// hides the row from /invoices.
type StripePayingScanSuite struct {
	tests.BillingTestSuite
}

func TestStripePayingScanSuite(t *testing.T) {
	suite.Run(t, new(StripePayingScanSuite))
}

func validPaymentMethod() *forms.PaymentMethod {
	return &forms.PaymentMethod{
		TenantID:        1,
		PaymentMethodID: 42,
		PaymentType:     "stripe",
		PaymentCode:     "stripe_pm",
		Status:          "active",
	}
}

// TestBatchUpdateInvoicesToSENTChargeModeValidation exercises the enum
// guard. We do not need to seed any invoice rows for the rejection paths
// because the validator runs before the IN clause and before any tx opens.
func (s *StripePayingScanSuite) TestBatchUpdateInvoicesToSENTChargeModeValidation() {
	assert := s.Assert()
	pm := validPaymentMethod()

	// Empty string is invalid.
	err := stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENT([]uint{1, 2, 3}, "in_abc", "", pm)
	assert.Error(err)
	assert.True(isInvalidInput(err), "empty charge_mode must return InvalidInputError, got %v", err)

	// Bogus value with trailing whitespace is invalid (no trimming).
	err = stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENT([]uint{1, 2, 3}, "in_abc", "auto ", pm)
	assert.Error(err)
	assert.True(isInvalidInput(err), "padded charge_mode must return InvalidInputError, got %v", err)

	// Unrelated enum value is invalid.
	err = stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENT([]uint{1, 2, 3}, "in_abc", "manual_force", pm)
	assert.Error(err)
	assert.True(isInvalidInput(err), "unknown charge_mode must return InvalidInputError, got %v", err)
}

// TestBatchUpdateInvoicesToSENTChargeModeAccepted verifies that each of the
// three allow-list values clears the enum guard. We use id 0 (no rows match)
// so the underlying UPDATE is a no-op but the validator still runs to
// completion and we only assert that no InvalidInputError is returned.
func (s *StripePayingScanSuite) TestBatchUpdateInvoicesToSENTChargeModeAccepted() {
	assert := s.Assert()
	pm := validPaymentMethod()

	for _, mode := range []string{"auto", "manual", "recurring"} {
		err := stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENT([]uint{0}, "in_abc", mode, pm)
		if err != nil {
			assert.False(isInvalidInput(err),
				"charge_mode %q must pass the validator; got %v", mode, err)
		}
	}
}

// TestBatchUpdateInvoicesToSENTPaymentMethodRequired covers the Phase 2
// requirement that paymentMethod is non-nil. A nil paymentMethod would
// leave payment_method_id NULL on the SENT row, silently dropping the row
// from the operations dashboard (subquery 2's pm_id NOT IN (set) returns
// NULL/false for NULL pm_id rows). The wrapper must refuse upfront.
func (s *StripePayingScanSuite) TestBatchUpdateInvoicesToSENTPaymentMethodRequired() {
	assert := s.Assert()

	err := stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENT([]uint{1, 2, 3}, "in_abc", "auto", nil)
	assert.Error(err)
	assert.True(isInvalidInput(err),
		"nil paymentMethod must return InvalidInputError so callers don't sneak past the validator, got %v", err)
}

// TestBatchUpdateInvoicesToSENTWithDBChargeModeValidation covers the
// validation path on the *gorm.DB-taking variant. Same expectations as
// the public wrapper, but checks the validation runs in isolation when
// the caller owns the transaction.
func (s *StripePayingScanSuite) TestBatchUpdateInvoicesToSENTWithDBChargeModeValidation() {
	assert := s.Assert()
	pm := validPaymentMethod()

	err := stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENTWithDB(nil, []uint{1, 2, 3}, "in_abc", "subscription", pm)
	assert.Error(err)
	assert.True(isInvalidInput(err), "unknown charge_mode must return InvalidInputError, got %v", err)

	err = stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENTWithDB(nil, []uint{1, 2, 3}, "in_abc", "", pm)
	assert.Error(err)
	assert.True(isInvalidInput(err), "empty charge_mode must return InvalidInputError, got %v", err)
}

// TestBatchUpdateInvoicesToSENTWithDBPaymentMethodRequired mirrors the
// Phase 2 requirement on the *gorm.DB-taking variant.
func (s *StripePayingScanSuite) TestBatchUpdateInvoicesToSENTWithDBPaymentMethodRequired() {
	assert := s.Assert()

	err := stripePayingScanInvoiceModel.BatchUpdateInvoicesToSENTWithDB(nil, []uint{1, 2, 3}, "in_abc", "auto", nil)
	assert.Error(err)
	assert.True(isInvalidInput(err),
		"nil paymentMethod must return InvalidInputError on the WithDB variant too, got %v", err)
}

// TestListStripeEligibleInvoicesPopulatesCategoryUnitsCosts guards the
// all-zero-amount regression. The Stripe handler iterates
// invoice.CategoryUnitsCosts to add line items to the Stripe invoice; if
// that map is empty, every line item gets amount=0 and the resulting
// Stripe invoice shows $0 on the dashboard regardless of the local
// cost_price column. ListStripeEligibleInvoices must materialize each
// row through invoiceFromEntityWithCosts (not the bare invoiceFromEntity)
// so the cost breakdown is loaded and the map is non-nil.
//
// We can't easily seed real cost data in a unit test, so the assertion
// here is structural: the returned invoice's CategoryUnitsCosts field
// must be a non-nil map. invoiceFromEntity leaves the field unset; the
// cost path always initializes it (potentially to an empty map, but never
// nil). When real cost data exists, the same call populates entries; the
// Stripe handler then iterates and produces non-zero amounts.
func (s *StripePayingScanSuite) TestListStripeEligibleInvoicesPopulatesCategoryUnitsCosts() {
	tests.MockPaymentHandler()
	tests.MockCentralHandler()
	assert := s.Assert()
	const tenantID uint = 1
	billingConfigID := uint(1)

	paymentModel := &paymentsub.PaymentModel{}
	_, err := paymentModel.CreatePaymentMethodAndActive(
		tenantID,
		&forms.PaymentMethodCreateQuery{
			PaymentType:           "stripe",
			BindedBillingConfigID: &billingConfigID,
		},
	)
	assert.NoError(err)

	invoiceEntity := entity.Invoice{
		TenantID:              tenantID,
		Status:                entity.InvoiceStatus_GENERATED,
		Type:                  entity.InvoiceType_MONTHLY,
		InvoiceDate:           time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
		CostPrice:             decimal.RequireFromString("29.97"),
		RawPrice:              decimal.RequireFromString("29.97"),
		BindedBillingConfigID: billingConfigID,
	}
	assert.NoError(invoiceEntity.Create(db.GetDB()))

	out, err := stripePayingScanInvoiceModel.ListStripeEligibleInvoices(
		time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
		time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
	)
	assert.NoError(err)
	assert.NotEmpty(out, "ListStripeEligibleInvoices should return the seed invoice")

	found := false
	for _, inv := range out {
		if uint(inv.InvoiceID) == invoiceEntity.ID {
			found = true
			// Critical structural check: with invoiceFromEntity, this
			// field is nil; with invoiceFromEntityWithCosts, it's a
			// (possibly empty) but non-nil map. The Stripe handler
			// treats nil and empty differently at the SQL level
			// (range over nil does nothing, so no line items), so this
			// assertion catches the all-zero-amount regression.
			assert.NotNil(inv.Costs.CategoryUnitsCosts,
				"CategoryUnitsCosts must be initialized (non-nil) so the Stripe handler can build non-zero line items")
		}
	}
	assert.True(found, "the seed invoice must appear in the eligible scan")
}

// isInvalidInput mirrors the API-layer errors.Is check so the test does not
// depend on billing_errors.BaseError.Is semantics.
func isInvalidInput(err error) bool {
	if err == nil {
		return false
	}
	return errors.Is(err, billing_errors.InvalidInputError)
}
