package notify

import (
	"context"
	"fmt"
	"strings"
	"testing"

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

// TestEvaluateEPayNotify_RejectsInvalidSign covers HIF-136 P01: sign invalid +
// debug bypass off must return rejectSign so the handler responds non-200 and
// EPay retries (was previously a silent `return nil` → 200 → no retry).
func TestEvaluateEPayNotify_RejectsInvalidSign(t *testing.T) {
	got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 1)
	if got != epayNotifyDecisionRejectSign {
		t.Fatalf("evaluateEPayNotify(invalid sign): got %v want %v", got, epayNotifyDecisionRejectSign)
	}
}

// TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign documents the debug-mode
// escape hatch the issue calls out: production runs with Debug=false so this
// branch is never taken in prod, but local/dev should still flow through.
func TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign(t *testing.T) {
	got := evaluateEPayNotify(false, true, "TRADE_SUCCESS", 1)
	if got != epayNotifyDecisionProcess {
		t.Fatalf("evaluateEPayNotify(invalid sign + debug): got %v want %v", got, epayNotifyDecisionProcess)
	}
}

// TestEvaluateEPayNotify_TradeNotSuccess covers the user-cancelled / gateway-failure
// branch: behaviour unchanged (return nil → 200), but caller must now emit the
// `epay_notify_trade_not_success` metric-named log instead of an Error-level one.
func TestEvaluateEPayNotify_TradeNotSuccess(t *testing.T) {
	tests := []string{"TRADE_FAILED", "WAIT_BUYER_PAY", ""}
	for _, ts := range tests {
		t.Run(ts, func(t *testing.T) {
			got := evaluateEPayNotify(true, false, ts, 1)
			if got != epayNotifyDecisionAckTradeNotSuccess {
				t.Fatalf("evaluateEPayNotify(trade_status=%q): got %v want %v",
					ts, got, epayNotifyDecisionAckTradeNotSuccess)
			}
		})
	}
}

// TestEvaluateEPayNotify_IdempotentForFinishedOrder covers the only legitimate
// `return nil` short-circuit retained by HIF-136: duplicate callback for an
// already Finished (status=5) order is acknowledged with 200 instead of being
// re-processed.
func TestEvaluateEPayNotify_IdempotentForFinishedOrder(t *testing.T) {
	got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", 5)
	if got != epayNotifyDecisionAckIdempotent {
		t.Fatalf("evaluateEPayNotify(status=5): got %v want %v", got, epayNotifyDecisionAckIdempotent)
	}
}

// TestEvaluateEPayNotify_HappyPath covers the normal success branch: valid sign,
// trade_status=TRADE_SUCCESS, order not yet Finished → proceed to write trade_no
// + flip status + enqueue activation.
func TestEvaluateEPayNotify_HappyPath(t *testing.T) {
	statuses := []uint8{1, 2, 3, 4} // anything but Finished(5)
	for _, s := range statuses {
		got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", s)
		if got != epayNotifyDecisionProcess {
			t.Fatalf("evaluateEPayNotify(status=%d): got %v want %v", s, got, epayNotifyDecisionProcess)
		}
	}
}

// TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency guards against a
// regression where a duplicate callback with a forged signature gets quietly
// acked because status==5 was checked before sign.
func TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency(t *testing.T) {
	got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 5)
	if got != epayNotifyDecisionRejectSign {
		t.Fatalf("evaluateEPayNotify(invalid sign + status=5): got %v want %v",
			got, epayNotifyDecisionRejectSign)
	}
}

// TestUrlParamsToMap verifies the helper used to feed VerifySign — collapses
// each query parameter to its first value and treats absent params as missing.
func TestUrlParamsToMap(t *testing.T) {
	got := urlParamsToMap("pid=1001&out_trade_no=ORD-1&trade_no=EPAY-9&sign=abc&trade_status=TRADE_SUCCESS")
	want := map[string]string{
		"pid":          "1001",
		"out_trade_no": "ORD-1",
		"trade_no":     "EPAY-9",
		"sign":         "abc",
		"trade_status": "TRADE_SUCCESS",
	}
	if len(got) != len(want) {
		t.Fatalf("urlParamsToMap len = %d want %d (got=%v)", len(got), len(want), got)
	}
	for k, v := range want {
		if got[k] != v {
			t.Fatalf("urlParamsToMap[%q] = %q want %q", k, got[k], v)
		}
	}
}

// TestEPayNotify_TradeNoRawWrite verifies HIF-136 P03 at the SQL boundary: the
// raw gorm write hits the `order` table with the exact `trade_no` from the
// EPay request, scoped by `order_no`, and surfaces driver errors back to the
// caller (so they propagate as non-200 and EPay retries).
func TestEPayNotify_TradeNoRawWrite(t *testing.T) {
	db, mock, cleanup := newEPayNotifyTestDB(t)
	defer cleanup()

	const (
		orderNo = "202606010001"
		tradeNo = "EPAY-202606010001"
	)

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

	err := db.WithContext(context.Background()).
		Model(&order.Order{}).
		Where("order_no = ?", orderNo).
		Update("trade_no", tradeNo).Error
	if err != nil {
		t.Fatalf("raw trade_no update: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestEPayNotify_TradeNoRawWritePropagatesError ensures a DB-side failure during
// the trade_no backfill is not swallowed — caller returns the error so EPay
// retries instead of silently flipping status without trade_no being written.
func TestEPayNotify_TradeNoRawWritePropagatesError(t *testing.T) {
	db, mock, cleanup := newEPayNotifyTestDB(t)
	defer cleanup()

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE `order` SET `trade_no`").
		WillReturnError(fmt.Errorf("deadlock detected"))
	mock.ExpectRollback()

	err := db.WithContext(context.Background()).
		Model(&order.Order{}).
		Where("order_no = ?", "ORD-2").
		Update("trade_no", "EPAY-2").Error
	if err == nil {
		t.Fatalf("expected error from raw trade_no update, got nil")
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

func newEPayNotifyTestDB(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()
	}
}
