package codes_test

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

	"github.com/wangjia/pangolin/server/internal/codes"
)

// --- HMAC helper used across tests ---

func signBody(secret string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

// newWebhookHandlerNoRedis creates a WebhookHandler with a nil Redis client.
// Safe only for tests where the handler returns before the nonce check.
func newWebhookHandlerNoRedis(secret string) *codes.WebhookHandler {
	return codes.NewWebhookHandler(nil, nil, secret, 5*time.Minute, 15*time.Minute)
}

// --- Signature validation tests (no Redis required) ---

// TestWebhookSignatureRejected verifies that a bad HMAC causes a 401.
// The signature check fires before any Redis or DB access.
func TestWebhookSignatureRejected(t *testing.T) {
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	// Wrong secret.
	r.Header.Set("X-Pangolin-Signature", signBody("wrong-secret", body))
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
	r.Header.Set("X-Pangolin-Nonce", "test-nonce-1")

	h := newWebhookHandlerNoRedis("correct-secret")
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
	}
	assertErrorCode(t, w, "WEBHOOK_INVALID_SIGNATURE")
}

// TestWebhookMissingSignatureHeader verifies that a missing signature causes a 401.
func TestWebhookMissingSignatureHeader(t *testing.T) {
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	// No X-Pangolin-Signature.
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
	r.Header.Set("X-Pangolin-Nonce", "test-nonce-nosig")

	h := newWebhookHandlerNoRedis("any-secret")
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", w.Code)
	}
}

// TestWebhookTimestampRejected verifies that a stale timestamp causes a 401.
// The timestamp check fires after signature but before Redis.
func TestWebhookTimestampRejected(t *testing.T) {
	secret := "test-secret"
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
	// Timestamp is 10 minutes in the past – outside ±5 min window.
	staleTs := time.Now().Add(-10 * time.Minute).Unix()
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(staleTs, 10))
	r.Header.Set("X-Pangolin-Nonce", "test-nonce-stale")

	h := newWebhookHandlerNoRedis(secret)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
	}
	assertErrorCode(t, w, "WEBHOOK_TIMESTAMP_EXPIRED")
}

// TestWebhookFutureTimestampRejected verifies that a far-future timestamp is also rejected.
func TestWebhookFutureTimestampRejected(t *testing.T) {
	secret := "test-secret"
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
	futureTs := time.Now().Add(10 * time.Minute).Unix()
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(futureTs, 10))
	r.Header.Set("X-Pangolin-Nonce", "test-nonce-future")

	h := newWebhookHandlerNoRedis(secret)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", w.Code)
	}
}

// TestWebhookMissingNonce verifies that a missing nonce causes a 400.
// Missing nonce is checked before Redis access (nil nonce → BadRequest).
func TestWebhookMissingNonce(t *testing.T) {
	secret := "test-secret"
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
	// No X-Pangolin-Nonce header.

	h := newWebhookHandlerNoRedis(secret)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusBadRequest {
		t.Errorf("expected 400, got %d; body=%s", w.Code, w.Body.String())
	}
}

// TestWebhookHMACConstantTime verifies that a correct-length HMAC with wrong
// bytes is still rejected – i.e., hmac.Equal (constant-time) is used, not ==.
func TestWebhookHMACConstantTime(t *testing.T) {
	secret := "test-secret"
	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	// Produce a MAC of the correct length but with the last byte flipped.
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	correctMAC := mac.Sum(nil)
	correctMAC[len(correctMAC)-1] ^= 0xFF
	wrongSig := "sha256=" + hex.EncodeToString(correctMAC)

	r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Pangolin-Signature", wrongSig)
	r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
	r.Header.Set("X-Pangolin-Nonce", "test-nonce-ct")

	h := newWebhookHandlerNoRedis(secret)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", w.Code)
	}
}

// --------------------------------------------------------------------------
// Helper
// --------------------------------------------------------------------------

// assertErrorCode decodes the response body and checks the "code" field.
func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) {
	t.Helper()
	var body struct {
		Code string `json:"code"`
	}
	if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
		t.Errorf("decode error body: %v", err)
		return
	}
	if body.Code != wantCode {
		t.Errorf("error code = %q, want %q", body.Code, wantCode)
	}
}
