package order

import (
	"context"
	"fmt"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	modelorder "github.com/perfect-panel/server/internal/model/order"
	"github.com/perfect-panel/server/internal/svc"
	"github.com/perfect-panel/server/pkg/logger"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

// newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher,
// matching the convention used elsewhere in this package.
func newCloseOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
	t.Helper()

	sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
		if strings.Contains(actualSQL, expectedSQL) {
			return nil
		}
		return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
	})))
	if err != nil {
		t.Fatalf("create sqlmock: %v", err)
	}

	db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		_ = sqlDB.Close()
		t.Fatalf("open gorm db: %v", err)
	}

	return db, mock, func() { _ = sqlDB.Close() }
}

func newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic {
	ctx := context.Background()
	return &CloseOrderLogic{
		Logger: logger.WithContext(ctx),
		ctx:    ctx,
		svcCtx: &svc.ServiceContext{DB: db},
	}
}

// TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付":
// gateway said the order was paid → we must flip status 1→2, persist
// trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the
// most important regression to keep — silently dropping the enqueue here would
// re-create the bug we are fixing.
func TestRecoverPaidOrder_HappyPath(t *testing.T) {
	db, mock, cleanup := newCloseOrderTestDB(t)
	defer cleanup()

	const (
		orderNo = "ORD-PAID-1"
		tradeNo = "ALIPAY-TRADE-9999"
	)

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE `order`").
		WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	var (
		enqueueCalls    int32
		capturedPayload []byte
	)
	logic := newCloseOrderLogicForTest(db)
	logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) {
		atomic.AddInt32(&enqueueCalls, 1)
		capturedPayload = append([]byte(nil), payload...)
		return "task-123", nil
	}

	err := logic.recoverPaidOrder(&modelorder.Order{
		OrderNo: orderNo,
		Method:  AlipayF2f,
		TradeNo: tradeNo,
		Status:  1,
	})
	if err != nil {
		t.Fatalf("recoverPaidOrder error: %v", err)
	}
	if got := atomic.LoadInt32(&enqueueCalls); got != 1 {
		t.Fatalf("expected exactly one enqueue, got %d", got)
	}
	if !strings.Contains(string(capturedPayload), orderNo) {
		t.Fatalf("activation payload missing order_no: %q", capturedPayload)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving
// gateway-notify already flipped the order past status=1 by the time the
// deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue.
func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) {
	db, mock, cleanup := newCloseOrderTestDB(t)
	defer cleanup()

	const orderNo = "ORD-RACE"

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE `order`").
		WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectCommit()

	var enqueueCalls int32
	logic := newCloseOrderLogicForTest(db)
	logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
		atomic.AddInt32(&enqueueCalls, 1)
		return "should-not-fire", nil
	}

	err := logic.recoverPaidOrder(&modelorder.Order{
		OrderNo: orderNo,
		Method:  Epay,
		Status:  1,
	})
	if err != nil {
		t.Fatalf("recoverPaidOrder error: %v", err)
	}
	if got := atomic.LoadInt32(&enqueueCalls); got != 0 {
		t.Fatalf("expected no enqueue when 0 rows updated, got %d", got)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB
// move succeeded but the activation enqueue failed (Redis blip, etc). The
// order is already at status=2, so the stuck-order sweeper will pick it up —
// recoverPaidOrder must not return an error or revert the status.
func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) {
	db, mock, cleanup := newCloseOrderTestDB(t)
	defer cleanup()

	const orderNo = "ORD-ENQ-FAIL"

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE `order`").
		WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	logic := newCloseOrderLogicForTest(db)
	logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
		return "", fmt.Errorf("simulated redis outage")
	}

	if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil {
		t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through"
// branches: methods that don't talk to an external gateway (Balance, empty
// string, anything unrecognised) must report Unpaid so the legacy close path
// is preserved. This is the regression hook for the issue's acceptance
// criterion #5 ("user-initiated cancellation can still close normally").
func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) {
	logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches

	cases := []struct {
		method string
		want   GatewayPaymentStatus
	}{
		{Balance, GatewayStatusUnpaid},
		{"", GatewayStatusUnpaid},
		{"future-method-we-dont-know", GatewayStatusUnpaid},
	}
	for _, tc := range cases {
		got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method})
		if got != tc.want {
			t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want)
		}
	}
}

// TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates
// the trade lazily — if we never recorded a trade_no, the user never scanned
// the QR code. We must report Unpaid (not Unknown) so the close proceeds.
// Without this short-circuit, every legitimately abandoned QR-code order
// would be "kept open" forever by the inconclusive-query branch.
func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) {
	logic := newCloseOrderLogicForTest(nil)
	got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""})
	if got != GatewayStatusUnpaid {
		t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got)
	}
}

// TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as
// the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's
// missing the intent was never persisted.
func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) {
	logic := newCloseOrderLogicForTest(nil)
	for _, method := range []string{StripeAlipay, StripeWeChatPay} {
		got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""})
		if got != GatewayStatusUnpaid {
			t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got)
		}
	}
}

// TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the
// default gatewayQuery is wired to the real implementation (not nil) by
// NewCloseOrderLogic. Without this guard a future refactor could silently
// drop the wiring and re-create HIF-135.
func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) {
	svcCtx := &svc.ServiceContext{}
	l := NewCloseOrderLogic(context.Background(), svcCtx)
	if l.gatewayQuery == nil {
		t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil")
	}
	if l.enqueueActivate == nil {
		t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil")
	}
}
