package tests

import (
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/pingcap/dbaas/billing2/api"
	"github.com/pingcap/dbaas/billing2/db"
	"github.com/pingcap/dbaas/billing2/db/entity"
	"github.com/pingcap/dbaas/billing2/model/payment"
	"github.com/pingcap/dbaas/billing2/utils/ginutils"
	"github.com/pingcap/dbaas/billing2/utils/panicIf"
	"github.com/pingcap/dbaas/billing2/utils/request"
	"github.com/pingcap/dbaas/billing2/utils/tests"
	"github.com/shopspring/decimal"
	"github.com/stretchr/testify/suite"
)

type StripePayingTestSuite struct {
	tests.BillingTestSuite
	router *gin.Engine
}

func (suite *StripePayingTestSuite) SetupTest() {
	suite.BillingTestSuite.SetupTest()
	suite.router = gin.New()
	suite.router.Use(ginutils.ErrorMiddleWare)
	api.InitializeStripeInvoicePayingRouter(ginutils.MyRoute{Route: suite.router})
}

func TestStripePayingTestSuite(t *testing.T) {
	suite.Run(t, new(StripePayingTestSuite))
}

func (suite *StripePayingTestSuite) createPaying(month string) *entity.StripeInvoicePaying {
	paying, err := (&payment.PayingModel{}).CreatePaying(month, "user:test", false)
	suite.Require().NoError(err)
	return paying
}

func (suite *StripePayingTestSuite) TestCreatePaying_HappyPath() {
	w := tests.PerformRequest(suite.router, http.MethodPost, "/paying", map[string]interface{}{
		"month": "2026-06",
	})

	suite.Require().Equal(http.StatusCreated, w.Code, w.Body.String())
	response := map[string]interface{}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.NotZero(response["paying_id"])
	suite.Equal("2026-06", response["month"])
	suite.Equal("pending", response["status"])
}

func (suite *StripePayingTestSuite) TestCreatePaying_409AlreadyActive() {
	created := suite.createPaying("2026-06")

	w := tests.PerformRequest(suite.router, http.MethodPost, "/paying", map[string]interface{}{
		"month": "2026-06",
	})

	suite.Require().Equal(http.StatusConflict, w.Code, w.Body.String())
	response := struct {
		Error          string                   `json:"error"`
		ExistingPaying api.StripePayingResponse `json:"existing_paying"`
	}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Equal("paying_already_active", response.Error)
	suite.Equal(created.ID, response.ExistingPaying.PayingID)
}

func (suite *StripePayingTestSuite) TestCreatePaying_BadMonth() {
	for _, month := range []string{"2026-6", "2026-13", "not-a-month"} {
		w := tests.PerformRequest(suite.router, http.MethodPost, "/paying", map[string]interface{}{
			"month": month,
		})
		suite.Equal(http.StatusBadRequest, w.Code, month+": "+w.Body.String())
	}
}

// TestCreatePaying_BoundaryMonths exercises the yyyy-mm regex with extra
// edge cases that should all be rejected with HTTP 400.
func (suite *StripePayingTestSuite) TestCreatePaying_BoundaryMonths() {
	cases := []struct {
		name  string
		month string
	}{
		{"empty string", ""},
		{"single-digit month", "2026-1"},
		{"slash separator", "2026/06"},
		{"invalid month 13", "2026-13"},
		{"invalid month 00", "2026-00"},
		{"two-digit year", "26-06"},
		{"full date", "2026-06-01"},
		{"gibberish", "abcdef"},
		{"leading whitespace", " 2026-06"},
	}
	for _, tc := range cases {
		suite.Run(tc.name, func() {
			w := tests.PerformRequest(suite.router, http.MethodPost, "/paying", map[string]interface{}{
				"month": tc.month,
			})
			suite.Equal(http.StatusBadRequest, w.Code, tc.name+": "+w.Body.String())
		})
	}
}

func (suite *StripePayingTestSuite) TestCreatePaying_ForceFlag() {
	suite.createPaying("2026-06")

	w := tests.PerformRequestWithHeaders(suite.router, http.MethodPost, "/paying", map[string]interface{}{
		"month": "2026-06",
		"force": true,
	}, map[string]string{request.SourceHeaderKey: request.SourceOps})

	suite.Require().Equal(http.StatusCreated, w.Code, w.Body.String())
	response := map[string]interface{}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.NotZero(response["paying_id"])
}

// TestCreatePaying_NonOpsForceRejected verifies the Ops-only RBAC gate on
// force=true. Per the design, force bypasses the same-month uniqueness and
// can hard-delete an active paying, so non-Ops callers must be rejected
// with HTTP 403 Forbidden.
func (suite *StripePayingTestSuite) TestCreatePaying_NonOpsForceRejected() {
	suite.createPaying("2026-06")

	cases := []struct {
		name   string
		source string
	}{
		{"empty source header", ""},
		{"console source", request.SourceConsole},
	}
	for _, tc := range cases {
		suite.Run(tc.name, func() {
			headers := map[string]string{}
			if tc.source != "" {
				headers[request.SourceHeaderKey] = tc.source
			}
			w := tests.PerformRequestWithHeaders(suite.router, http.MethodPost, "/paying", map[string]interface{}{
				"month": "2026-06",
				"force": true,
			}, headers)
			suite.Equal(http.StatusForbidden, w.Code, tc.name+": "+w.Body.String())
		})
	}
}

func (suite *StripePayingTestSuite) TestListPaying_Dashboard() {
	suite.createPaying("2026-06")
	carryover := &entity.StripeInvoicePushCarryover{
		TenantID:         100,
		BillingConfigID:  200,
		Amount:           decimal.RequireFromString("12.30"),
		SourceInvoiceIDs: "[]",
		FirstCarryMonth:  time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC),
	}
	panicIf.Error(db.GetDB().Create(carryover).Error)

	w := tests.PerformRequest(suite.router, http.MethodGet, "/paying", nil)

	suite.Require().Equal(http.StatusOK, w.Code, w.Body.String())
	response := struct {
		Payings          []api.StripePayingResponse `json:"payings"`
		CarryoverSummary struct {
			TotalCount            int64  `json:"total_count"`
			TotalAmount           string `json:"total_amount"`
			OldestFirstCarryMonth string `json:"oldest_first_carry_month"`
		} `json:"carryover_summary"`
	}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Len(response.Payings, 1)
	suite.Equal(int64(1), response.CarryoverSummary.TotalCount)
	suite.Equal("12.30", response.CarryoverSummary.TotalAmount)
	suite.Equal("2025-11", response.CarryoverSummary.OldestFirstCarryMonth)
}

// TestListPaying_LimitBoundaries exercises the paying_limit query parameter:
// zero / negative / non-numeric values are rejected, omitted uses the default
// (20), and out-of-range values are clamped to the configured max (100).
func (suite *StripePayingTestSuite) TestListPaying_LimitBoundaries() {
	// Seed enough payings (with unique months) to exercise both the default
	// limit (20) and the clamp (100).
	for i := 0; i < 150; i++ {
		month := fmt.Sprintf("%04d-%02d", 2000+i/12, (i%12)+1)
		suite.createPaying(month)
	}

	response := struct {
		Payings []api.StripePayingResponse `json:"payings"`
	}{}

	// limit=0 is rejected: the handler requires a positive integer.
	w := tests.PerformRequest(suite.router, http.MethodGet, "/paying?paying_limit=0", nil)
	suite.Equal(http.StatusBadRequest, w.Code, "limit=0: "+w.Body.String())

	// limit=-1 is rejected for the same reason.
	w = tests.PerformRequest(suite.router, http.MethodGet, "/paying?paying_limit=-1", nil)
	suite.Equal(http.StatusBadRequest, w.Code, "limit=-1: "+w.Body.String())

	// limit=abc fails to parse as an integer.
	w = tests.PerformRequest(suite.router, http.MethodGet, "/paying?paying_limit=abc", nil)
	suite.Equal(http.StatusBadRequest, w.Code, "limit=abc: "+w.Body.String())

	// limit omitted falls back to the default of 20.
	w = tests.PerformRequest(suite.router, http.MethodGet, "/paying", nil)
	suite.Require().Equal(http.StatusOK, w.Code, w.Body.String())
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Len(response.Payings, 20)

	// limit=99999 is clamped down to the configured max of 100.
	w = tests.PerformRequest(suite.router, http.MethodGet, "/paying?paying_limit=99999", nil)
	suite.Require().Equal(http.StatusOK, w.Code, w.Body.String())
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Len(response.Payings, 100)
}

func (suite *StripePayingTestSuite) TestGetPaying_Detail() {
	paying := suite.createPaying("2026-06")
	pickedUpAt := time.Date(2026, 6, 3, 1, 0, 0, 0, time.UTC)
	completedAt := pickedUpAt.Add(15 * time.Second)
	paying.Status = "completed"
	paying.PickedUpAt = &pickedUpAt
	paying.CompletedAt = &completedAt
	result := `{"confirmed":[],"carryover_updated":[],"expired":[],"skipped":[]}`
	paying.Result = &result
	panicIf.Error(db.GetDB().Save(paying).Error)

	w := tests.PerformRequest(suite.router, http.MethodGet, fmt.Sprintf("/paying/%d", paying.ID), nil)

	suite.Require().Equal(http.StatusOK, w.Code, w.Body.String())
	response := struct {
		Paying api.StripePayingDetailResponse `json:"paying"`
	}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Equal(paying.ID, response.Paying.PayingID)
	suite.Equal("completed", response.Paying.Status)
	suite.Equal(int64(15), response.Paying.DurationSeconds)
	suite.NotNil(response.Paying.Result)
}

func (suite *StripePayingTestSuite) TestGetPaying_NotFound() {
	w := tests.PerformRequest(suite.router, http.MethodGet, "/paying/999", nil)
	suite.Equal(http.StatusNotFound, w.Code, w.Body.String())
}

// TestGetPaying_BoundaryIds exercises the {id} path parameter with values
// that should each be rejected before hitting the DB lookup. gorm.Model IDs
// start at 1, so id=0 is invalid; negatives / decimals / overflow values
// fail strconv.ParseUint; non-numeric input fails parsing outright.
func (suite *StripePayingTestSuite) TestGetPaying_BoundaryIds() {
	cases := []struct {
		name string
		id   string
		want int
	}{
		{"non-numeric", "abc", http.StatusBadRequest},
		{"zero", "0", http.StatusBadRequest},
		{"negative", "-1", http.StatusBadRequest},
		{"overflow", "99999999999999999999", http.StatusBadRequest},
		{"decimal", "1.5", http.StatusBadRequest},
	}
	for _, tc := range cases {
		suite.Run(tc.name, func() {
			w := tests.PerformRequest(suite.router, http.MethodGet, "/paying/"+tc.id, nil)
			suite.Equal(tc.want, w.Code, tc.name+": "+w.Body.String())
		})
	}

	// Empty path: with trailing-slash redirect disabled, gin cannot match
	// /paying/ against /paying/:id and falls through to 404.
	originalRedirect := suite.router.RedirectTrailingSlash
	suite.router.RedirectTrailingSlash = false
	defer func() { suite.router.RedirectTrailingSlash = originalRedirect }()
	w := tests.PerformRequest(suite.router, http.MethodGet, "/paying/", nil)
	suite.Equal(http.StatusNotFound, w.Code, w.Body.String())
}

// TestGetPaying_FailedHasErrorMessage inserts a paying row directly with
// status=failed and a populated error_message, then verifies the detail
// endpoint surfaces the error message and reports result=null (the failed
// run never produced a structured result).
func (suite *StripePayingTestSuite) TestGetPaying_FailedHasErrorMessage() {
	completedAt := time.Date(2026, 6, 3, 1, 0, 0, 0, time.UTC)
	errorMsg := "stripe api call failed: insufficient funds"
	paying := &entity.StripeInvoicePaying{
		Month:        "2026-06",
		Status:       "failed",
		CreatedBy:    "user:test",
		CompletedAt:  &completedAt,
		ErrorMessage: errorMsg,
	}
	panicIf.Error(db.GetDB().Create(paying).Error)

	w := tests.PerformRequest(suite.router, http.MethodGet, fmt.Sprintf("/paying/%d", paying.ID), nil)
	suite.Require().Equal(http.StatusOK, w.Code, w.Body.String())

	response := struct {
		Paying api.StripePayingDetailResponse `json:"paying"`
	}{}
	suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &response))
	suite.Equal(paying.ID, response.Paying.PayingID)
	suite.Equal("failed", response.Paying.Status)
	suite.Equal(errorMsg, response.Paying.ErrorMessage)
	suite.Nil(response.Paying.Result)
}
