package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// newTestWebhookHandler builds a WebhookHandler backed by an isolated
// in-memory SettingsManager. It is the preferred factory for all webhook tests.
func newTestWebhookHandler(t *testing.T) (*WebhookHandler, *SettingsManager) {
	t.Helper()
	settings, _ := newTestSettings()
	h := NewWebhookHandler(settings, slog.Default())
	return h, settings
}

// computeSig returns the HMAC-SHA256 hex digest for the given body and secret.
func computeSig(body []byte, secret string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	return hex.EncodeToString(mac.Sum(nil))
}

// bookingEventBody builds a minimal, valid Cal.com webhook JSON payload.
func bookingEventBody(trigger, uid, attendeeName, eventTitle, startTime string) []byte {
	return fmt.Appendf(nil,
		`{"triggerEvent":%q,"createdAt":"2026-05-27T10:00:00Z","payload":{"uid":%q,"startTime":%q,"eventType":{"title":%q},"attendees":[{"name":%q}]}}`,
		trigger, uid, startTime, eventTitle, attendeeName,
	)
}

// sendSigned posts body to h with a correct "sha256=<hex>" signature header
// and returns the response recorder.
func sendSigned(t *testing.T, h *WebhookHandler, body []byte, secret string) *httptest.ResponseRecorder {
	t.Helper()
	sig := "sha256=" + computeSig(body, secret)
	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)
	return rec
}

// -------------------------------------------------------------------------
// WO-047 — signature validation
// -------------------------------------------------------------------------

// TestWebhook_ValidSignatureSha256Hex verifies that a request with the
// canonical "sha256=<hex>" signature format is accepted with 200 OK.
func TestWebhook_ValidSignatureSha256Hex(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}

	var called int
	SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ })
	t.Cleanup(func() { SetNotifyFn(nil) })

	body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z")
	sig := "sha256=" + computeSig(body, "supersecret")

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	if called != 1 {
		t.Errorf("expected notifier called once, got %d", called)
	}
}

// TestWebhook_ValidSignatureBareHex verifies that the bare "<hex>" format
// (without the "sha256=" prefix) is also accepted.
func TestWebhook_ValidSignatureBareHex(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}

	var called int
	SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ })
	t.Cleanup(func() { SetNotifyFn(nil) })

	body := bookingEventBody("BOOKING_CREATED", "u2", "Alice", "Demo", "2026-05-27T11:00:00Z")
	// Bare hex — no "sha256=" prefix.
	sig := computeSig(body, "supersecret")

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	if called != 1 {
		t.Errorf("expected notifier called once, got %d", called)
	}
}

// TestWebhook_TamperedBody401 verifies that modifying the body after
// computing the signature triggers a 401 Unauthorized.
func TestWebhook_TamperedBody401(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}

	original := bookingEventBody("BOOKING_CREATED", "u3", "Eve", "Workshop", "2026-05-27T12:00:00Z")
	sig := "sha256=" + computeSig(original, "supersecret")

	// Tamper: change "Eve" to "Eve2" in the body after signing.
	tampered := bytes.Replace(original, []byte("Eve"), []byte("Eve2"), 1)

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(tampered))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

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

// TestWebhook_WrongSecret401 verifies that signing with a different secret
// than the one configured in settings returns 401 Unauthorized.
func TestWebhook_WrongSecret401(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "right-secret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}

	body := bookingEventBody("BOOKING_CREATED", "u4", "Mallory", "Hack", "2026-05-27T13:00:00Z")
	// Signed with the wrong secret.
	sig := "sha256=" + computeSig(body, "wrong-secret")

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

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

// TestWebhook_MissingHeader401 verifies that omitting X-Cal-Signature-256
// entirely returns 401 Unauthorized.
func TestWebhook_MissingHeader401(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}

	body := bookingEventBody("BOOKING_CREATED", "u5", "Anon", "Meeting", "2026-05-27T14:00:00Z")
	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	// Intentionally no X-Cal-Signature-256 header.
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

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

// TestWebhook_MissingSecret503 verifies that when no webhook secret has been
// configured, the handler returns 503 Service Unavailable.
func TestWebhook_MissingSecret503(t *testing.T) {
	h, _ := newTestWebhookHandler(t)
	// Deliberately do NOT call SetWebhookSecret — secret is absent.

	body := bookingEventBody("BOOKING_CREATED", "u6", "Nobody", "Call", "2026-05-27T15:00:00Z")
	sig := "sha256=" + computeSig(body, "anything")

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

	if rec.Code != http.StatusServiceUnavailable {
		t.Errorf("expected 503, got %d", rec.Code)
	}
}

// -------------------------------------------------------------------------
// WO-048 — dispatch + unique key + last-event
// -------------------------------------------------------------------------

// notifyCall captures a single invocation of the notifier.
type notifyCall struct {
	severity, title, message, uniqueKey string
}

// captureNotifier installs a capturing notifier and returns a pointer to the
// slice of calls. The caller MUST register t.Cleanup(func() { SetNotifyFn(nil) }).
func captureNotifier(t *testing.T) *[]notifyCall {
	t.Helper()
	calls := &[]notifyCall{}
	SetNotifyFn(func(_ context.Context, sev, title, msg, key string) {
		*calls = append(*calls, notifyCall{sev, title, msg, key})
	})
	t.Cleanup(func() { SetNotifyFn(nil) })
	return calls
}

// TestWebhook_BookingCreatedNotification verifies that a BOOKING_CREATED event
// fires a notification with the correct title, attendee name, event title, and
// unique key.
func TestWebhook_BookingCreatedNotification(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	if len(*calls) != 1 {
		t.Fatalf("expected 1 notifier call, got %d", len(*calls))
	}
	c := (*calls)[0]
	if c.severity != "info" {
		t.Errorf("expected severity 'info', got %q", c.severity)
	}
	if c.title != "New booking" {
		t.Errorf("expected title 'New booking', got %q", c.title)
	}
	if !strings.Contains(c.message, "Bob") {
		t.Errorf("expected message to contain 'Bob', got %q", c.message)
	}
	if !strings.Contains(c.message, "Quick chat") {
		t.Errorf("expected message to contain 'Quick chat', got %q", c.message)
	}
	if c.uniqueKey != "calcom:BOOKING_CREATED:u1" {
		t.Errorf("expected uniqueKey 'calcom:BOOKING_CREATED:u1', got %q", c.uniqueKey)
	}
}

// TestWebhook_BookingRescheduledNotification verifies that BOOKING_RESCHEDULED
// produces the correct notification title and unique key.
func TestWebhook_BookingRescheduledNotification(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	body := bookingEventBody("BOOKING_RESCHEDULED", "u2", "Carol", "Check-in", "2026-05-28T09:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	if len(*calls) != 1 {
		t.Fatalf("expected 1 notifier call, got %d", len(*calls))
	}
	c := (*calls)[0]
	if c.severity != "info" {
		t.Errorf("expected severity 'info', got %q", c.severity)
	}
	if c.title != "Booking rescheduled" {
		t.Errorf("expected title 'Booking rescheduled', got %q", c.title)
	}
	if c.uniqueKey != "calcom:BOOKING_RESCHEDULED:u2" {
		t.Errorf("expected uniqueKey 'calcom:BOOKING_RESCHEDULED:u2', got %q", c.uniqueKey)
	}
}

// TestWebhook_BookingCancelledNotification verifies that BOOKING_CANCELLED
// produces the correct notification title and unique key.
func TestWebhook_BookingCancelledNotification(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	body := bookingEventBody("BOOKING_CANCELLED", "u3", "Dave", "Strategy", "2026-05-29T14:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	if len(*calls) != 1 {
		t.Fatalf("expected 1 notifier call, got %d", len(*calls))
	}
	c := (*calls)[0]
	if c.severity != "info" {
		t.Errorf("expected severity 'info', got %q", c.severity)
	}
	if c.title != "Booking cancelled" {
		t.Errorf("expected title 'Booking cancelled', got %q", c.title)
	}
	if c.uniqueKey != "calcom:BOOKING_CANCELLED:u3" {
		t.Errorf("expected uniqueKey 'calcom:BOOKING_CANCELLED:u3', got %q", c.uniqueKey)
	}
}

// TestWebhook_UnknownEventIgnored verifies that an unrecognised triggerEvent
// returns 200 but does NOT invoke the notifier — unknown events are silently
// dropped so future Cal.com additions don't cause noisy notifications.
func TestWebhook_UnknownEventIgnored(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	body := bookingEventBody("SOMETHING_ELSE", "u4", "Nobody", "Nothing", "2026-05-27T10:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rec.Code)
	}
	if len(*calls) != 0 {
		t.Errorf("expected notifier NOT called for unknown event, got %d calls", len(*calls))
	}
}

// TestWebhook_NilNotifierSafe verifies that a nil notifier does not panic —
// the handler must guard against an unregistered notifier gracefully.
func TestWebhook_NilNotifierSafe(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	SetNotifyFn(nil)
	t.Cleanup(func() { SetNotifyFn(nil) })

	body := bookingEventBody("BOOKING_CREATED", "u5", "Safe", "Call", "2026-05-27T10:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rec.Code)
	}
}

// TestWebhook_MalformedJson200 verifies that a correctly signed payload that
// is not valid JSON returns 200 (Stripe pattern: ack the delivery so Cal.com
// does not flood retries with junk). The notifier must NOT be called.
func TestWebhook_MalformedJson200(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	body := []byte(`{this is not valid JSON`)
	sig := "sha256=" + computeSig(body, "s")

	req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
	req.Header.Set("X-Cal-Signature-256", sig)
	rec := httptest.NewRecorder()
	h.Handle(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 for malformed JSON, got %d", rec.Code)
	}
	if len(*calls) != 0 {
		t.Errorf("expected notifier NOT called for malformed JSON, got %d calls", len(*calls))
	}
}

// TestWebhook_UniqueKeyFormat verifies that the uniqueKey passed to the
// notifier follows the "calcom:{triggerEvent}:{uid}" format. This is the
// key that the downstream NotificationService uses for deduplication, so
// the format must be exact. (Replay dedup itself occurs inside
// NotificationService.Emit via the database — not within this handler.)
func TestWebhook_UniqueKeyFormat(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	// Send the same payload twice; both should carry the same uniqueKey.
	body := bookingEventBody("BOOKING_CREATED", "replay-uid", "Tester", "Dedup Test", "2026-05-27T10:00:00Z")
	sendSigned(t, h, body, "s")
	sendSigned(t, h, body, "s")

	if len(*calls) != 2 {
		t.Fatalf("expected 2 notifier calls (handler does not dedup), got %d", len(*calls))
	}
	expected := "calcom:BOOKING_CREATED:replay-uid"
	for i, c := range *calls {
		if c.uniqueKey != expected {
			t.Errorf("call[%d]: expected uniqueKey %q, got %q", i, expected, c.uniqueKey)
		}
	}
}

// TestWebhookHandler_RecoversFromDispatchPanic asserts the handler swallows
// notifier panics and still returns 200 to Cal.com — Stripe-pattern: a panic
// IS an app-level error and must not surface as a 5xx that triggers Cal.com's
// retry storm.
func TestWebhookHandler_RecoversFromDispatchPanic(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	SetNotifyFn(func(_ context.Context, _, _, _, _ string) {
		panic("boom")
	})
	t.Cleanup(func() { SetNotifyFn(nil) })

	body := bookingEventBody("BOOKING_CREATED", "u-panic", "Boom", "Crash Test", "2026-05-27T10:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 even when notifier panics, got %d", rec.Code)
	}
}

// TestWebhookHandler_ParsesRealCalcomPayloadShape locks down the JSON shape the
// implementer's WebhookPayload struct assumes Cal.com sends. The payload below
// is the implementer's CURRENT assumption — embedded inline rather than read
// from a fixture file because WO-051 (capture a real Cal.com webhook in
// staging) has not been run yet.
//
// After WO-051 runs, replace the assumedPayload literal with the real captured
// JSON (load it from testdata/calcom_webhook_real.json) to confirm the shape
// has not drifted.
func TestWebhookHandler_ParsesRealCalcomPayloadShape(t *testing.T) {
	// IMPORTANT: this payload is the implementer's assumption. Replace with a
	// real Cal.com capture after WO-051.
	assumedPayload := []byte(`{
		"triggerEvent": "BOOKING_CREATED",
		"createdAt": "2026-05-27T10:00:00Z",
		"payload": {
			"uid": "real-uid-abc123",
			"startTime": "2026-06-01T14:00:00Z",
			"endTime": "2026-06-01T14:30:00Z",
			"eventType": {
				"title": "Strategy Call",
				"slug": "strategy-call"
			},
			"attendees": [
				{
					"name": "Real Attendee",
					"email": "attendee@example.com",
					"timeZone": "America/Toronto"
				}
			]
		}
	}`)

	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "real-secret"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	calls := captureNotifier(t)

	rec := sendSigned(t, h, assumedPayload, "real-secret")
	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	if len(*calls) != 1 {
		t.Fatalf("expected 1 notifier call, got %d", len(*calls))
	}

	c := (*calls)[0]
	// uid → uniqueKey
	if c.uniqueKey != "calcom:BOOKING_CREATED:real-uid-abc123" {
		t.Errorf("uniqueKey: got %q, want calcom:BOOKING_CREATED:real-uid-abc123", c.uniqueKey)
	}
	// eventType.title → message
	if !strings.Contains(c.message, "Strategy Call") {
		t.Errorf("expected message to contain eventType.title 'Strategy Call', got %q", c.message)
	}
	// attendees[0].name → message
	if !strings.Contains(c.message, "Real Attendee") {
		t.Errorf("expected message to contain attendees[0].name 'Real Attendee', got %q", c.message)
	}
	// startTime → message (the implementer formats this verbatim)
	if !strings.Contains(c.message, "2026-06-01T14:00:00Z") {
		t.Errorf("expected message to contain startTime '2026-06-01T14:00:00Z', got %q", c.message)
	}
	// title (the notification title, not eventType.title) is "New booking"
	if c.title != "New booking" {
		t.Errorf("notification title: got %q, want 'New booking'", c.title)
	}

	// Also confirm last_event_at picked up the createdAt timestamp.
	s, err := settings.GetSettings(context.Background())
	if err != nil {
		t.Fatalf("GetSettings: %v", err)
	}
	if got, _ := s["last_event_at"].(string); got != "2026-05-27T10:00:00Z" {
		t.Errorf("last_event_at: got %q, want '2026-05-27T10:00:00Z'", got)
	}
}

// TestWebhook_UpdatesLastEventInSettings verifies that after a successful
// BOOKING_CREATED event, the settings store records last_event_type and
// last_event_at so the admin panel can surface the last webhook activity.
func TestWebhook_UpdatesLastEventInSettings(t *testing.T) {
	h, settings := newTestWebhookHandler(t)
	if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
		t.Fatalf("SetWebhookSecret: %v", err)
	}
	// Notifier must be set so dispatch() proceeds; discard the call.
	SetNotifyFn(func(_ context.Context, _, _, _, _ string) {})
	t.Cleanup(func() { SetNotifyFn(nil) })

	body := bookingEventBody("BOOKING_CREATED", "u7", "Last", "Event Test", "2026-06-01T08:00:00Z")
	rec := sendSigned(t, h, body, "s")

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}

	s, err := settings.GetSettings(context.Background())
	if err != nil {
		t.Fatalf("GetSettings: %v", err)
	}

	lastType, _ := s["last_event_type"].(string)
	if lastType != "BOOKING_CREATED" {
		t.Errorf("expected last_event_type 'BOOKING_CREATED', got %q", lastType)
	}
	lastAt, _ := s["last_event_at"].(string)
	if lastAt == "" {
		t.Errorf("expected last_event_at to be populated, got empty string")
	}
}
