package main

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"git.dev.alexdunmow.com/block/pluginsdk/auth"
	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
)

// newTestRouter builds a chi.Router wired exactly like NewCalcomRouter but with
// an in-memory querier and isolated rate limiter — no DB pool required. Tests
// drive HTTP requests through this to exercise the middleware stack
// (requireAdmin in particular).
func newTestRouter(t *testing.T) http.Handler {
	t.Helper()
	settings, _ := newTestSettings()
	limiter := NewRateLimiter(bookingsPerHourPerIP)
	h := NewCalcomHandler(settings, limiter, "https://test.blockninja.dev")

	r := chi.NewRouter()
	r.Get("/slots", h.HandleGetSlots)
	r.Get("/form", h.HandleGetForm)
	r.Post("/book", h.HandleCreateBooking)
	r.Post("/cancel", h.HandleCancelBooking)
	r.Get("/reset", h.HandleReset)
	r.Get("/date-grid", h.HandleGetDateGrid)

	wh := NewWebhookHandler(settings, slog.Default())
	r.Post("/webhook", wh.Handle)

	r.Group(func(admin chi.Router) {
		admin.Use(requireAdmin)
		admin.Get("/settings", h.HandleGetSettings)
		admin.Post("/settings", h.HandleSaveSettings)
		admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
		admin.Post("/test", h.HandleTestConnection)
		admin.Get("/event-types", h.HandleListEventTypes)
	})
	return r
}

// adminCtx returns a context carrying admin-role claims so requireAdmin allows
// the request through.
func adminCtx(ctx context.Context) context.Context {
	return auth.WithUser(ctx, &auth.Claims{
		UserID: uuid.New(),
		Email:  "admin@example.test",
		Role:   "admin",
	})
}

// viewerCtx returns a context carrying viewer-role claims — requireAdmin must
// reject this with 403 (not 401 — the user is authenticated, just not
// privileged enough).
func viewerCtx(ctx context.Context) context.Context {
	return auth.WithUser(ctx, &auth.Claims{
		UserID: uuid.New(),
		Email:  "viewer@example.test",
		Role:   "viewer",
	})
}

// withCalcomBaseURL overrides the package-level Cal.com base URL for the
// duration of one test, restoring it on test cleanup. This is the seam that
// lets the public handlers (HandleGetSlots, HandleCreateBooking, HandleGetForm,
// HandleTestConnection, HandleListEventTypes) be exercised against an
// httptest.NewServer instead of api.cal.com.
//
// The handlers call NewCalcomClient(apiKey) inline, and NewCalcomClient reads
// calcomBaseURL when it constructs the client struct, so swapping the var
// reroutes every public handler at once with no further injection.
//
// Tests calling this helper must NOT run with t.Parallel — the override is a
// package-level var and would race across parallel goroutines.
func withCalcomBaseURL(t *testing.T, url string) {
	t.Helper()
	prev := calcomBaseURL
	calcomBaseURL = url
	// The event-timezone cache is keyed by username|slug, not base URL — wipe
	// it on entry and exit so per-test fake servers can't leak zones into
	// each other.
	resetEventTZCache()
	t.Cleanup(func() {
		calcomBaseURL = prev
		resetEventTZCache()
	})
}

// ---------------------------------------------------------------------------
// Group A — Integration tests: handler → fake Cal.com via calcomBaseURL swap
// ---------------------------------------------------------------------------

// TestHandleGetSlots_HappyPath drives HandleGetSlots end-to-end against a fake
// Cal.com server. Asserts the rendered HTML carries the 12-hour formatted slot
// time, AND verifies the outbound request: path, query string, and the
// per-endpoint cal-api-version header.
func TestHandleGetSlots_HappyPath(t *testing.T) {
	var (
		gotPath    string
		gotQuery   url.Values
		gotHeaders http.Header
	)
	// The event's availability schedule is America/Toronto; the /slots
	// passthrough captures the outbound request for assertion.
	fakeCalcom(t, "America/Toronto", func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotQuery = r.URL.Query()
		gotHeaders = r.Header
		// Real cal-api-version 2024-09-04 response shape: each slot carries a
		// `start` field (NOT the legacy 2024-08-13 `time` field). Millisecond
		// precision mirrors what api.cal.com actually returns on the wire.
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{
				"2026-06-01":[{"start":"2026-06-01T13:00:00.000Z"},{"start":"2026-06-01T15:30:00.000Z"}]
			}
		}`)
	})

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet,
		"/slots?username=alice&eventType=30min&date=2026-06-01&blockId=b1", nil)
	rec := httptest.NewRecorder()
	h.HandleGetSlots(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	// 12-hour formatted times converted into the MEETING's timezone: 13:00Z /
	// 15:30Z on June 1 are 9:00 AM / 11:30 AM in America/Toronto (EDT, UTC-4).
	if !strings.Contains(body, "9:00 AM") {
		t.Errorf("expected '9:00 AM' (13:00Z in America/Toronto) in body, got: %q", body)
	}
	if !strings.Contains(body, "11:30 AM") {
		t.Errorf("expected '11:30 AM' (15:30Z in America/Toronto) in body, got: %q", body)
	}
	// Regression guard: the widget must NOT render the empty-state when Cal.com
	// returned slots. This is the symptom of the 2024-09-04 `start`-vs-`time`
	// field mismatch — every slot silently dropped, empty list, "No available
	// times" despite a full slate from the API.
	if strings.Contains(body, "No available times") {
		t.Errorf("rendered empty-state despite Cal.com returning slots (slot-field shape mismatch); body: %q", body)
	}

	// Outbound-request assertions.
	if gotPath != "/slots" {
		t.Errorf("outbound path: got %q, want /slots", gotPath)
	}
	if got := gotQuery.Get("username"); got != "alice" {
		t.Errorf("outbound username: got %q, want alice", got)
	}
	if got := gotQuery.Get("eventTypeSlug"); got != "30min" {
		t.Errorf("outbound eventTypeSlug: got %q, want 30min", got)
	}
	if got := gotQuery.Get("timeZone"); got != "America/Toronto" {
		t.Errorf("outbound timeZone: got %q, want America/Toronto", got)
	}
	if got := gotHeaders.Get("cal-api-version"); got != versionSlots {
		t.Errorf("cal-api-version header: got %q, want %q", got, versionSlots)
	}
	if got := gotHeaders.Get("Authorization"); got != "Bearer cal_live_test" {
		t.Errorf("Authorization header: got %q, want Bearer cal_live_test", got)
	}
}

// TestHandleCreateBooking_HappyPath drives a successful booking end-to-end:
// POST to /book with a valid form → fake Cal.com returns a booking record →
// handler renders the confirmation panel including the meeting/reschedule/cancel
// links.
func TestHandleCreateBooking_HappyPath(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Real 2026-02-25 bookings response: `location` is the canonical meeting
		// field; there are NO rescheduleUrl/cancelUrl fields (manage links reach
		// the attendee via Cal.com's confirmation email).
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{
				"id":123,
				"uid":"booking-uid-123",
				"start":"2026-06-01T10:00:00Z",
				"end":"2026-06-01T10:30:00Z",
				"location":"https://meet.example.com/booking-uid-123"
			}
		}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
		"phone":     {"+1-555-1212"},
		"timezone":  {"America/Toronto"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Booking Confirmed!") {
		t.Errorf("expected 'Booking Confirmed!' in confirmation HTML, got: %q", body)
	}
	// Meeting link is derived from the booking's `location` field (2026-02-25),
	// NOT the deprecated `meetingUrl`.
	if !strings.Contains(body, "https://meet.example.com/booking-uid-123") {
		t.Errorf("expected meeting link (from `location`) in body, got: %q", body)
	}
	if !strings.Contains(body, "bob@example.com") {
		t.Errorf("expected attendee email in confirmation, got: %q", body)
	}
	// Regression guard (F1): the API returns no rescheduleUrl/cancelUrl, so the
	// panel must not render those management links.
	if strings.Contains(body, "Reschedule") {
		t.Errorf("confirmation must not render a Reschedule link (API returns no rescheduleUrl); body: %q", body)
	}
}

// TestHandleCreateBooking_NormalizesStartToUTC guards F4: Cal.com's bookings
// endpoint requires `start` in UTC. A slot start submitted with a non-UTC
// offset must be converted to UTC before it is sent upstream.
func TestHandleCreateBooking_NormalizesStartToUTC(t *testing.T) {
	var gotBody string
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		gotBody = string(b)
		_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u","location":"https://meet.example.com/u"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-05-27T11:00:00+02:00"}, // 11:00 GMT+2 (Rome) == 09:00 UTC
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
		"timezone":  {"Europe/Rome"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	if !strings.Contains(gotBody, `"start":"2026-05-27T09:00:00Z"`) {
		t.Errorf("outbound `start` not normalized to UTC; got body: %s", gotBody)
	}
}

// TestHandleCreateBooking_HoneypotMakesZeroCalcomCalls verifies the spec-mandated
// property that a tripped honeypot ("bn_message_extra" field) short-circuits the request
// — the fake Cal.com server fails the test if it is ever called.
func TestHandleCreateBooking_HoneypotMakesZeroCalcomCalls(t *testing.T) {
	var hits int64
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt64(&hits, 1)
		t.Errorf("fake Cal.com was hit despite honeypot trip: %s %s", r.Method, r.URL.Path)
		_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"should-not-happen"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":          {"b1"},
		"username":         {"alice"},
		"eventType":        {"30min"},
		"start":            {"2026-06-01T10:00:00Z"},
		"name":             {"Bot"},
		"email":            {"bot@spam.example.com"},
		"bn_message_extra": {"spam"}, // honeypot tripped
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200 (fake confirmation), got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Booking Confirmed") {
		t.Errorf("expected fake confirmation rendered, got: %q", rec.Body.String())
	}
	if got := atomic.LoadInt64(&hits); got != 0 {
		t.Errorf("fake Cal.com received %d requests; honeypot must make ZERO upstream calls", got)
	}
}

// captureBookingRequest inspects the Cal.com /bookings POST body and stores the
// parsed JSON for assertions.
type capturedBooking struct {
	mu   sync.Mutex
	body map[string]any
}

func (c *capturedBooking) handler(t *testing.T) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Non-booking traffic — the event-timezone lookup (/schedules,
		// /event-types) — is served a Perth schedule and not captured, so
		// these tests exercise the same meeting-timezone resolution the
		// production flow uses.
		if !strings.HasPrefix(r.URL.Path, "/bookings") {
			if strings.HasPrefix(r.URL.Path, "/schedules") {
				_, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"Australia/Perth","isDefault":true}]}`)
				return
			}
			_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`)
			return
		}
		raw, err := io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("read body: %v", err)
		}
		c.mu.Lock()
		defer c.mu.Unlock()
		if err := json.Unmarshal(raw, &c.body); err != nil {
			t.Errorf("decode body: %v (raw: %s)", err, string(raw))
		}
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{
				"uid":"u",
				"rescheduleUrl":"https://cal.com/reschedule/u",
				"cancelUrl":"https://cal.com/booking/u?cancel=true"
			}
		}`)
	}
}

// TestHandleCreateBooking_CustomFieldsLandInBookingFieldsResponses asserts the
// full payload shape sent to Cal.com:
//   - Built-in fields go into attendee.{name,email,phoneNumber,timeZone}.
//   - Custom fields (q1, q2) land in bookingFieldsResponses.
//   - Built-ins MUST NOT appear in bookingFieldsResponses (the field-stripping
//     contract documented in WO-014/045).
//   - The honeypot field "bn_message_extra" must not appear anywhere.
func TestHandleCreateBooking_CustomFieldsLandInBookingFieldsResponses(t *testing.T) {
	cap := &capturedBooking{}
	fake := httptest.NewServer(cap.handler(t))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
		"phone":     {"555-1212"},
		// Stale cached pages still post a visitor timezone; it must not
		// reach Cal.com — the attendee carries the meeting's timezone.
		"timezone": {"America/Toronto"},
		"q1":       {"yes"},
		"q2":       {"blue"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

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

	cap.mu.Lock()
	defer cap.mu.Unlock()
	if cap.body == nil {
		t.Fatalf("Cal.com received no parseable body")
	}

	att, ok := cap.body["attendee"].(map[string]any)
	if !ok {
		t.Fatalf("attendee missing/wrong type in body: %v", cap.body)
	}
	if att["name"] != "Bob" {
		t.Errorf("attendee.name: got %v, want Bob", att["name"])
	}
	if att["email"] != "bob@example.com" {
		t.Errorf("attendee.email: got %v, want bob@example.com", att["email"])
	}
	// Phone is E.164-normalized on the way out: separators stripped.
	if att["phoneNumber"] != "5551212" {
		t.Errorf("attendee.phoneNumber: got %v, want 5551212 (normalized)", att["phoneNumber"])
	}
	if att["timeZone"] != "Australia/Perth" {
		t.Errorf("attendee.timeZone: got %v, want Australia/Perth (the meeting's schedule timezone, not the posted visitor field)", att["timeZone"])
	}

	resps, ok := cap.body["bookingFieldsResponses"].(map[string]any)
	if !ok {
		t.Fatalf("bookingFieldsResponses missing/wrong type in body: %v", cap.body)
	}
	if resps["q1"] != "yes" {
		t.Errorf("bookingFieldsResponses.q1: got %v, want 'yes'", resps["q1"])
	}
	if resps["q2"] != "blue" {
		t.Errorf("bookingFieldsResponses.q2: got %v, want 'blue'", resps["q2"])
	}
	// Built-in / honeypot keys must NEVER appear inside bookingFieldsResponses.
	for _, banned := range []string{"name", "email", "phone", "bn_message_extra", "timezone", "blockId", "username", "eventType", "start"} {
		if _, present := resps[banned]; present {
			t.Errorf("bookingFieldsResponses must not contain built-in key %q (got value %v)", banned, resps[banned])
		}
	}
}

// TestHandleCreateBooking_MultiValueCustomField asserts that a multi-valued
// custom field (e.g. checkboxes with the same name) is sent as a JSON array,
// not the empty/comma-joined string that url.Values.Get would return.
func TestHandleCreateBooking_MultiValueCustomField(t *testing.T) {
	cap := &capturedBooking{}
	fake := httptest.NewServer(cap.handler(t))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
		"interests": {"ai", "cms"}, // multi-value custom field
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

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

	cap.mu.Lock()
	defer cap.mu.Unlock()
	resps, ok := cap.body["bookingFieldsResponses"].(map[string]any)
	if !ok {
		t.Fatalf("bookingFieldsResponses missing/wrong type: %v", cap.body)
	}
	got, ok := resps["interests"].([]any)
	if !ok {
		t.Fatalf("bookingFieldsResponses.interests must be a JSON array, got %T: %v", resps["interests"], resps["interests"])
	}
	if len(got) != 2 || got[0] != "ai" || got[1] != "cms" {
		t.Errorf("interests array wrong: %v", got)
	}
}

// TestHandleCreateBooking_CalcomReturns4xxRendersCuratedError verifies that when
// Cal.com responds with a 4xx and a structured error message, the handler
// renders CURATED, visitor-facing copy rather than echoing the Cal.com message
// verbatim. The Cal.com message can carry implementation detail (slot IDs,
// internal references) and must not surface to the public visitor. Here the
// message signals a gone slot ("no longer available"), so the handler maps it to
// the specific slot-unavailable Tier-1 copy WITH a retry control.
func TestHandleCreateBooking_CalcomReturns4xxRendersCuratedError(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		_, _ = io.WriteString(w, `{"message":"Slot no longer available — internal-ref 0xDEADBEEF"}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 with error body, got %d", rec.Code)
	}
	body := rec.Body.String()
	// A gone-slot signal maps to the specific, actionable Tier-1 copy with a
	// retry control — never the old generic "Booking failed".
	if !strings.Contains(body, "isn") || !strings.Contains(body, "choose another slot") {
		t.Errorf("expected curated slot-unavailable message in body, got: %q", body)
	}
	if !strings.Contains(body, "Try again") {
		t.Errorf("expected recoverable slot error to render the retry control, got: %q", body)
	}
	if strings.Contains(body, "Booking failed") {
		t.Errorf("handler still renders the old generic message; body=%q", body)
	}
	// The Cal.com upstream message — including the internal-ref leak — must
	// NEVER reach the visitor.
	if strings.Contains(body, "internal-ref") {
		t.Errorf("response leaks Cal.com upstream detail 'internal-ref' to visitor; body=%q", body)
	}
	if strings.Contains(body, "Slot no longer available") {
		t.Errorf("response echoes Cal.com message verbatim to visitor; body=%q", body)
	}
}

// TestHandleCreateBooking_TierTwoUsesAdminMessage verifies the admin-authored
// Tier-2 path: an unrecognised / our-side Cal.com failure (here a 5xx) renders
// the per-block `unavailableMessage` from the form, never Cal.com's raw text,
// and WITHOUT a retry control (the visitor can't fix it by re-picking a slot).
func TestHandleCreateBooking_TierTwoUsesAdminMessage(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusServiceUnavailable)
		_, _ = io.WriteString(w, `{"error":{"code":"INTERNAL_SERVER_ERROR","message":"upstream exploded — trace 0xCAFE"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	const adminMsg = "Please ring the clinic on 1234 to book."
	form := url.Values{
		"blockId":            {"b1"},
		"username":           {"alice"},
		"eventType":          {"30min"},
		"start":              {"2026-06-01T10:00:00Z"},
		"name":               {"Bob"},
		"email":              {"bob@example.com"},
		"unavailableMessage": {adminMsg},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 with error body, got %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, adminMsg) {
		t.Errorf("expected admin-authored Tier-2 message in body, got: %q", body)
	}
	if strings.Contains(body, "Try again") {
		t.Errorf("non-recoverable Tier-2 error must NOT render a retry control; body=%q", body)
	}
	if strings.Contains(body, "trace") || strings.Contains(body, "upstream exploded") {
		t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
	}
}

// TestHandleCreateBooking_TierTwoDefaultMessage verifies that when the admin
// left `unavailableMessage` blank, an unrecognised failure falls back to
// DefaultUnavailableMessage (not Cal.com's raw text, not the old generic copy).
func TestHandleCreateBooking_TierTwoDefaultMessage(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNotFound)
		_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Event type with slug nope not found"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 with error body, got %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "use our contact form") {
		t.Errorf("expected DefaultUnavailableMessage in body, got: %q", body)
	}
	if strings.Contains(body, "not found") || strings.Contains(body, "Event type with slug") {
		t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
	}
}

// TestHandleGetForm_FetchesBookingFieldsFromCalcom drives HandleGetForm to fetch
// custom bookingFields from Cal.com (via the GetEventType API) and assert the
// rendered form contains both the textarea and the select with all options.
// Also confirms the per-endpoint cal-api-version header is correct.
func TestHandleGetForm_FetchesBookingFieldsFromCalcom(t *testing.T) {
	var gotHeaders http.Header
	var gotQuery url.Values
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotHeaders = r.Header
		gotQuery = r.URL.Query()
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[{
				"id":7,
				"slug":"intake",
				"title":"Intake",
				"lengthInMinutes":15,
				"bookingFields":[
					{"slug":"size","label":"Company size","type":"select","required":true,
					 "options":[{"label":"1-10","value":"small"},{"label":"11-50","value":"medium"}]},
					{"slug":"notes","label":"Notes","type":"textarea","required":false}
				]
			}]
		}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet,
		"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake",
		nil)
	rec := httptest.NewRecorder()
	h.HandleGetForm(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Company size") {
		t.Errorf("expected 'Company size' label in form, got: %q", body)
	}
	if !strings.Contains(body, "Notes") {
		t.Errorf("expected 'Notes' label in form, got: %q", body)
	}
	if !strings.Contains(body, `name="size"`) {
		t.Errorf("expected select input name=\"size\" in form, got: %q", body)
	}
	if !strings.Contains(body, `value="small"`) || !strings.Contains(body, `value="medium"`) {
		t.Errorf("expected both select options in form, got: %q", body)
	}
	if !strings.Contains(body, `name="notes"`) {
		t.Errorf("expected textarea name=\"notes\" in form, got: %q", body)
	}

	if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
		t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
	}
	if got := gotQuery.Get("username"); got != "alice" {
		t.Errorf("outbound username: got %q, want alice", got)
	}
	if got := gotQuery.Get("eventSlug"); got != "intake" {
		t.Errorf("outbound eventSlug: got %q, want intake", got)
	}
}

// TestHandleGetForm_OnFetchErrorFallsBackToDefaults verifies the WO-027
// fallback: if the Cal.com event-type fetch fails (5xx upstream), HandleGetForm
// still renders the form using the default `name` + `email` fields.
func TestHandleGetForm_OnFetchErrorFallsBackToDefaults(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = io.WriteString(w, `{"message":"Cal.com is on fire"}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet,
		"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=30min",
		nil)
	rec := httptest.NewRecorder()
	h.HandleGetForm(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200 (fallback form rendered), got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, `name="name"`) {
		t.Errorf("expected fallback name input in body, got: %q", body)
	}
	if !strings.Contains(body, `name="email"`) {
		t.Errorf("expected fallback email input in body, got: %q", body)
	}
	if strings.Contains(body, "Cal.com is on fire") {
		t.Errorf("response leaks upstream error message to visitor: %q", body)
	}
}

// TestHandleTestConnection_HappyPath drives the admin /test endpoint against a
// fake Cal.com that returns the API-key owner's event types. Asserts the JSON
// success envelope, the rendered event_types list, and the version header.
func TestHandleTestConnection_HappyPath(t *testing.T) {
	var gotHeaders http.Header
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotHeaders = r.Header
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[
				{"id":1,"slug":"30min","title":"30 Minute","lengthInMinutes":30},
				{"id":2,"slug":"60min","title":"60 Minute","lengthInMinutes":60}
			]
		}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/test", nil)
	rec := httptest.NewRecorder()
	h.HandleTestConnection(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	var resp map[string]any
	if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if success, _ := resp["success"].(bool); !success {
		t.Errorf("expected success=true, got: %v", resp)
	}
	ets, ok := resp["event_types"].([]any)
	if !ok {
		t.Fatalf("event_types missing or wrong type: %v", resp)
	}
	if len(ets) != 2 {
		t.Errorf("expected 2 event types in response, got %d", len(ets))
	}
	if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
		t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
	}
}

// TestHandleListEventTypes_HappyPath drives the admin /event-types endpoint
// (the editor dropdown source) against a fake Cal.com. Same shape as
// HandleTestConnection but driven by GET /event-types?username=.
func TestHandleListEventTypes_HappyPath(t *testing.T) {
	var gotQuery url.Values
	var gotHeaders http.Header
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		gotHeaders = r.Header
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[
				{"id":7,"slug":"intake","title":"Intake Call","lengthInMinutes":15}
			]
		}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/event-types?username=alice", nil)
	rec := httptest.NewRecorder()
	h.HandleListEventTypes(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	var resp map[string]any
	if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if success, _ := resp["success"].(bool); !success {
		t.Errorf("expected success=true, got: %v", resp)
	}
	ets, ok := resp["event_types"].([]any)
	if !ok {
		t.Fatalf("event_types missing or wrong type: %v", resp)
	}
	if len(ets) != 1 {
		t.Errorf("expected 1 event type in response, got %d", len(ets))
	}
	if got := gotQuery.Get("username"); got != "alice" {
		t.Errorf("outbound username: got %q, want alice", got)
	}
	if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
		t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
	}
}

// ---------------------------------------------------------------------------
// Auth-hole regression — admin endpoints must 401 without admin context
// ---------------------------------------------------------------------------
//
// The plugin's HTTP routes mount under /api/plugins/calcomblock/* with no
// outer auth wrapper. Admin endpoints (settings, event-types, test, rotate)
// rely on the requireAdmin middleware in handler.go for their entire defence.
// This test drives requests through the full chi.Router via the plugin's
// HTTPHandler entry point and asserts each admin endpoint returns 401 when
// the request carries no admin claims, while public endpoints stay reachable.

// TestPluginRouter_AdminEndpointsRequireAuth confirms each admin endpoint
// returns 401 Unauthorized when an anonymous request reaches it. Public
// endpoints in the same router remain 200 (or whatever their own logic says).
//
// This is the critical regression guard: the adversarial review caught that
// these endpoints were unprotected before the requireAdmin middleware was
// added. If anyone disables the middleware, this test fails immediately.
func TestPluginRouter_AdminEndpointsRequireAuth(t *testing.T) {
	router := newTestRouter(t)

	cases := []struct {
		name   string
		method string
		path   string
	}{
		{"GET /settings", http.MethodGet, "/settings"},
		{"POST /settings", http.MethodPost, "/settings"},
		{"POST /settings/rotate-webhook-secret", http.MethodPost, "/settings/rotate-webhook-secret"},
		{"POST /test", http.MethodPost, "/test"},
		{"GET /event-types", http.MethodGet, "/event-types"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			req := httptest.NewRequest(c.method, c.path, nil)
			rec := httptest.NewRecorder()
			router.ServeHTTP(rec, req)
			if rec.Code != http.StatusUnauthorized {
				t.Errorf("%s without admin claims: expected 401, got %d (body: %q)", c.name, rec.Code, rec.Body.String())
			}
		})
	}
}

// TestPluginRouter_PublicEndpointsStayOpen confirms the public booking routes
// and the HMAC-verified webhook are NOT gated by requireAdmin.
func TestPluginRouter_PublicEndpointsStayOpen(t *testing.T) {
	router := newTestRouter(t)

	cases := []struct {
		name   string
		method string
		path   string
	}{
		// Public booking endpoints — should not return 401 (they may return
		// other statuses depending on params, but never 401 from requireAdmin).
		{"GET /reset", http.MethodGet, "/reset"},
		{"GET /slots", http.MethodGet, "/slots?blockId=b1"},
		{"GET /form", http.MethodGet, "/form?blockId=b1"},
		// /book + /webhook are POST; the auth middleware would intercept GETs
		// too, so test those by sending an empty POST and checking != 401.
		{"POST /book", http.MethodPost, "/book"},
		{"POST /webhook", http.MethodPost, "/webhook"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			req := httptest.NewRequest(c.method, c.path, nil)
			rec := httptest.NewRecorder()
			router.ServeHTTP(rec, req)
			if rec.Code == http.StatusUnauthorized {
				t.Errorf("%s should not be gated by requireAdmin (got 401)", c.name)
			}
		})
	}
}

// TestPluginRouter_AdminEndpointReachableWithAdminClaims verifies a request
// carrying admin claims via auth.WithUser passes the requireAdmin gate.
func TestPluginRouter_AdminEndpointReachableWithAdminClaims(t *testing.T) {
	router := newTestRouter(t)

	req := httptest.NewRequest(http.MethodGet, "/settings", nil)
	req = req.WithContext(adminCtx(req.Context()))
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("admin request to /settings: expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
	}
}

// TestPluginRouter_AdminEndpointForbiddenForViewer verifies a request carrying
// a viewer-role session is rejected with 403 (not 200, not 401).
func TestPluginRouter_AdminEndpointForbiddenForViewer(t *testing.T) {
	router := newTestRouter(t)

	req := httptest.NewRequest(http.MethodGet, "/settings", nil)
	req = req.WithContext(viewerCtx(req.Context()))
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusForbidden {
		t.Errorf("viewer request to /settings: expected 403, got %d (body: %q)", rec.Code, rec.Body.String())
	}
}

// ---------------------------------------------------------------------------
// Phase F — structural regressions for the visitor-widget polish pass
// ---------------------------------------------------------------------------

// TestHandleCreateBooking_ConfirmationEmitsOOBClears guards the structural
// fix introduced in Phase A: after a successful POST /book, the response
// body must include hx-swap-oob directives that empty the date grid, slots,
// and form regions plus advance the step indicator to step 4. Without this
// the visitor sees their populated form below the confirmation panel and
// can re-submit (duplicate booking footgun the adversarial review caught).
func TestHandleCreateBooking_ConfirmationEmitsOOBClears(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u-conf-1","rescheduleUrl":"https://cal.com/r/u-conf-1","cancelUrl":"https://cal.com/c/u-conf-1"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId":   {"b-conf"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-01T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
	}
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Booking Confirmed!") {
		t.Errorf("expected confirmation in body, got: %q", body)
	}
	// The body must include OOB-clear directives for the three upstream regions
	// plus an OOB step-indicator update marking everything complete.
	wantSubstrings := []string{
		`id="calcom-date-grid-b-conf"`,
		`id="calcom-slots-b-conf"`,
		`id="calcom-form-b-conf"`,
		`id="calcom-steps-b-conf"`,
		`hx-swap-oob="innerHTML"`,
		`hx-swap-oob="true"`,
	}
	for _, want := range wantSubstrings {
		if !strings.Contains(body, want) {
			t.Errorf("response missing %q — confirmation must emit hx-swap-oob clears for the upstream regions", want)
		}
	}
}

// TestHandleReset_EmitsOOBFormClearAndStepRewind guards the "Change date"
// fix: GET /reset rewinds the step indicator and clears the form region in
// addition to emptying the slots target.
func TestHandleReset_EmitsOOBFormClearAndStepRewind(t *testing.T) {
	h, _ := newTestHandler()
	req := httptest.NewRequest(http.MethodGet, "/reset?blockId=b-reset", nil)
	rec := httptest.NewRecorder()
	h.HandleReset(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, want := range []string{
		`id="calcom-form-b-reset"`,
		`id="calcom-steps-b-reset"`,
		`hx-swap-oob="innerHTML"`,
		`hx-swap-oob="true"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("reset response missing %q", want)
		}
	}
}

// TestHandleGetDateGrid_NextWindow exercises Phase C month navigation: GET
// /date-grid with a future weekStart returns a re-rendered grid scoped to
// that window, includes the prev button (CanGoBack=true), and the range
// label reflects the window.
func TestHandleGetDateGrid_NextWindow(t *testing.T) {
	router := newTestRouter(t)

	// Pick a weekStart 4 weeks in the future so it's safely after today
	// regardless of when the test runs.
	future := time.Now().AddDate(0, 0, 28).Format("2006-01-02")
	req := httptest.NewRequest(http.MethodGet,
		"/date-grid?blockId=b-nav&username=alice&eventType=30min&weeks=2&weekStart="+future, nil)
	req = req.WithContext(adminCtx(req.Context())) // route doesn't gate, but consistent
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	// Future window must show the prev button (aria-label="Previous dates").
	if !strings.Contains(body, `aria-label="Previous dates"`) {
		t.Errorf("future window response missing prev button: %q", body[:min(400, len(body))])
	}
	// Next button is always present.
	if !strings.Contains(body, `aria-label="Next dates"`) {
		t.Errorf("response missing next button: %q", body[:min(400, len(body))])
	}
}

// TestHandleGetDateGrid_ClampsToToday guards the rule that visitors cannot
// page into the past. A weekStart from 2020 must be clamped to today, and
// the prev button must NOT render (CanGoBack=false).
func TestHandleGetDateGrid_ClampsToToday(t *testing.T) {
	router := newTestRouter(t)
	req := httptest.NewRequest(http.MethodGet,
		"/date-grid?blockId=b-past&username=alice&eventType=30min&weeks=2&weekStart=2020-01-01", nil)
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if strings.Contains(body, `aria-label="Previous dates"`) {
		t.Errorf("clamped-to-today window must NOT render prev button (CanGoBack=false), body: %q", body[:min(400, len(body))])
	}
	if !strings.Contains(body, `aria-label="Next dates"`) {
		t.Errorf("response missing next button: %q", body[:min(400, len(body))])
	}
}

// TestGenerateDateGridFrom_PastDatesFlagged verifies block.go's IsPast
// computation: a window starting 2 days ago flags the first cell as past,
// today as not past.
func TestGenerateDateGridFrom_PastDatesFlagged(t *testing.T) {
	start := time.Now().AddDate(0, 0, -2)
	dates := generateDateGridFrom(start, 1, time.Now().Format("2006-01-02"))
	if len(dates) != 7 {
		t.Fatalf("expected 7 cells, got %d", len(dates))
	}
	if !dates[0].IsPast {
		t.Errorf("first cell (2 days ago) should be IsPast=true: %+v", dates[0])
	}
	if !dates[1].IsPast {
		t.Errorf("second cell (yesterday) should be IsPast=true: %+v", dates[1])
	}
	if dates[2].IsPast {
		t.Errorf("third cell (today) should be IsPast=false: %+v", dates[2])
	}
	if !dates[2].IsToday {
		t.Errorf("third cell should be IsToday=true: %+v", dates[2])
	}
}

// TestCalcomBookingWidget_RendersAccessibilityScaffolding is the Phase F a11y
// regression: the widget root must carry aria-live="polite" on the three swap
// regions plus role="grid" on the date picker. If any of these regress the
// test fails and the safety net is intact.
func TestCalcomBookingWidget_RendersAccessibilityScaffolding(t *testing.T) {
	router := newTestRouter(t)
	// Reach the date grid via /date-grid (which renders calcomDateGrid alone)
	// and confirm role="grid" is present.
	req := httptest.NewRequest(http.MethodGet,
		"/date-grid?blockId=b-a11y&username=alice&eventType=30min&weeks=1&weekStart="+time.Now().Format("2006-01-02"), nil)
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)
	if !strings.Contains(rec.Body.String(), `role="grid"`) {
		t.Errorf(`date-grid partial missing role="grid"`)
	}
	if !strings.Contains(rec.Body.String(), `aria-label="Booking date picker"`) {
		t.Errorf(`date-grid partial missing aria-label="Booking date picker"`)
	}
}

// ---------------------------------------------------------------------------
// HandleCancelBooking — "manage your booking" cancel endpoint
// ---------------------------------------------------------------------------

// TestHandleCancelBooking_HappyPath drives a successful cancel end-to-end:
// POST /cancel with a uid → fake Cal.com accepts the cancel → handler renders
// the "Booking cancelled" partial. Also asserts the outbound request hit the
// confirmed v2 cancel path with the bookings cal-api-version.
func TestHandleCancelBooking_HappyPath(t *testing.T) {
	var gotPath, gotVersion string
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotVersion = r.Header.Get("cal-api-version")
		_, _ = io.WriteString(w, `{"status":"success","data":{"id":1,"uid":"booking-uid-123","status":"cancelled"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{
		"blockId": {"b1"},
		"uid":     {"booking-uid-123"},
	}
	req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCancelBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Booking cancelled") {
		t.Errorf("expected 'Booking cancelled' in cancel HTML, got: %q", body)
	}
	// OOB rewind of the step indicator to step 1 so the rebook flow starts fresh.
	if !strings.Contains(body, `id="calcom-steps-b1"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
		t.Errorf("expected OOB step-indicator rewind in cancel body, got: %q", body)
	}
	if gotPath != "/bookings/booking-uid-123/cancel" {
		t.Errorf("upstream cancel path: got %q, want /bookings/booking-uid-123/cancel", gotPath)
	}
	if gotVersion != versionBookings {
		t.Errorf("upstream cal-api-version: got %q, want %q", gotVersion, versionBookings)
	}
}

// TestHandleCancelBooking_MissingUIDMakesZeroCalcomCalls verifies that a cancel
// request with no uid renders an error and never reaches Cal.com (the fake fails
// the test if it is ever called).
func TestHandleCancelBooking_MissingUIDMakesZeroCalcomCalls(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Errorf("Cal.com must NOT be called when uid is missing; got %s %s", r.Method, r.URL.Path)
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{"blockId": {"b1"}} // no uid
	req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCancelBooking(rec, req)

	body := rec.Body.String()
	if !strings.Contains(body, "Missing booking reference") {
		t.Errorf("expected 'Missing booking reference' error, got: %q", body)
	}
	if strings.Contains(body, "Booking cancelled") {
		t.Errorf("must not render success on a missing uid; body=%q", body)
	}
}

// TestHandleCancelBooking_CalcomErrorRendersCuratedRetry verifies that when
// Cal.com rejects the cancel (e.g. 404), the handler renders curated copy WITH a
// retry control and never echoes Cal.com's raw message to the visitor.
func TestHandleCancelBooking_CalcomErrorRendersCuratedRetry(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNotFound)
		_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Booking not found — ref 0xBADF00D"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}

	form := url.Values{"blockId": {"b1"}, "uid": {"gone-uid"}}
	req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.HandleCancelBooking(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200 with error body, got %d", rec.Code)
	}
	body := rec.Body.String()
	// templ HTML-escapes the apostrophe in "couldn't", so match the parts that
	// survive escaping rather than the raw apostrophe.
	if !strings.Contains(body, "cancel that booking") {
		t.Errorf("expected curated cancel-failure copy, got: %q", body)
	}
	if !strings.Contains(body, "Try again") {
		t.Errorf("expected a retry control on a failed cancel, got: %q", body)
	}
	if strings.Contains(body, "0xBADF00D") || strings.Contains(body, "Booking not found") {
		t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
	}
}

// TestHandleCancelBooking_RateLimited verifies the IP rate limit guards the
// cancel endpoint (the abuse guard for UID-based cancellation). A limiter with a
// budget of 1 lets the first cancel through and 429s the second from the same IP.
func TestHandleCancelBooking_RateLimited(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = io.WriteString(w, `{"status":"success","data":{"status":"cancelled"}}`)
	}))
	t.Cleanup(fake.Close)
	withCalcomBaseURL(t, fake.URL)

	settings, _ := newTestSettings()
	if err := settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}
	h := NewCalcomHandler(settings, NewRateLimiter(1), "https://test.blockninja.dev")

	doCancel := func() *httptest.ResponseRecorder {
		form := url.Values{"blockId": {"b1"}, "uid": {"some-uid"}}
		req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.RemoteAddr = "203.0.113.7:5555" // same IP both calls
		rec := httptest.NewRecorder()
		h.HandleCancelBooking(rec, req)
		return rec
	}

	if rec := doCancel(); rec.Code != http.StatusOK {
		t.Fatalf("first cancel: expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
	}
	rec := doCancel()
	if rec.Code != http.StatusTooManyRequests {
		t.Errorf("second cancel from same IP: expected 429, got %d", rec.Code)
	}
}

// TestCalcomBookingWidget_RendersSavedBookingPanelAndPersistMarkup verifies the
// "manage your booking" scaffolding is present in the server-rendered widget:
// the normally-hidden saved-booking panel (with its cancel form posting /cancel
// and the redo button) and the stable-key derivation in the widget script.
func TestCalcomBookingWidget_RendersSavedBookingPanelAndPersistMarkup(t *testing.T) {
	cfg := BookingConfig{
		BlockID:       "b-saved",
		Username:      "alice",
		EventTypeSlug: "30min",
		WeeksToShow:   1,
		WeekStart:     time.Now().Format("2006-01-02"),
		Today:         time.Now().Format("2006-01-02"),
		TimeZone:      "UTC",
		Dates:         generateDateGridFrom(time.Now(), 1, time.Now().Format("2006-01-02")),
	}
	var buf bytes.Buffer
	if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil {
		t.Fatalf("render widget: %v", err)
	}
	html := buf.String()
	for _, want := range []string{
		`id="calcom-saved-b-saved"`,                 // saved-booking panel container
		`data-calcom-saved`,                         // panel marker the script toggles
		`hx-post="/api/plugins/calcomblock/cancel"`, // cancel form posts the new route
		`name="uid"`,                                // hidden uid the script fills from localStorage
		`data-calcom-redo`,                          // reschedule/redo button
		`calcom:booking:`,                           // stable localStorage key prefix in the script
	} {
		if !strings.Contains(html, want) {
			t.Errorf("widget missing %q", want)
		}
	}
	// The saved panel must start hidden (revealed client-side only when storage holds a booking).
	if !strings.Contains(html, `calcom-saved-booking hidden`) {
		t.Errorf("saved-booking panel must render hidden by default; html=%q", html[:min(len(html), 400)])
	}
}
