package main

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// Cal.com field-taxonomy regressions
//
// Cal.com event types return a mix of SYSTEM booking fields (name, email,
// attendeePhoneNumber, location, notes, guests, title, rescheduleReason) and
// CUSTOM fields. Each system field has a fixed slug + type + `editable` value,
// and reschedule-only fields carry views:[{id:"reschedule"}] (see Cal.com
// getBookingFields.ts). The widget must:
//   - NOT render reschedule-only fields on a NEW booking (rescheduleReason)
//   - route each field to its correct slot in POST /v2/bookings:
//       phone-typed  -> attendee.phoneNumber (E.164-normalized)
//       guests       -> top-level guests[] array
//       custom        -> bookingFieldsResponses (custom-only per the v2 schema)
//   - never emit empty-string responses (Cal.com rejects empty/mistyped values)
// These tests pin that contract.
// ---------------------------------------------------------------------------

// TestHandleGetForm_DropsRescheduleOnlyFields asserts the booking form does not
// render fields whose Cal.com `views` restricts them to the reschedule flow
// (rescheduleReason). Genuine custom fields (birthday) must still render.
func TestHandleGetForm_DropsRescheduleOnlyFields(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[{
				"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
				"bookingFields":[
					{"slug":"name","type":"name","editable":"system","required":true},
					{"slug":"email","type":"email","editable":"system-but-optional","required":true},
					{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false,"views":[{"id":"reschedule","label":"Reschedule"}]},
					{"slug":"birthday","type":"text","label":"Birthday","editable":"user","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&timezone=UTC", 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()
	// rescheduleReason is reschedule-only — must NOT appear on a new booking.
	if strings.Contains(body, `name="rescheduleReason"`) {
		t.Errorf("reschedule-only field rescheduleReason rendered on a new booking form; body: %q", body)
	}
	if strings.Contains(body, "Reason for reschedule") {
		t.Errorf("reschedule-only label 'Reason for reschedule' rendered on a new booking form; body: %q", body)
	}
	// Genuine custom field must still render.
	if !strings.Contains(body, `name="birthday"`) {
		t.Errorf("expected custom field name=\"birthday\" in form, got: %q", body)
	}
}

// TestHandleGetForm_RendersPhoneCountryCombobox asserts a Cal.com phone field
// renders the searchable country combobox with the detected country (AU, from
// the Australia/Perth timezone) pre-selected, alongside the tel number input.
func TestHandleGetForm_RendersPhoneCountryCombobox(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[{
				"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
				"bookingFields":[
					{"slug":"name","type":"name","required":true},
					{"slug":"attendeePhoneNumber","type":"phone","required":true}
				]
			}]
		}`)
	}))
	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&timezone=Australia/Perth&locale=en-AU", 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()
	for _, want := range []string{
		"data-cc-combobox",
		`name="phoneCountry"`,
		`value="AU"`,         // detected default pre-selected in the hidden input
		"🇦🇺 Australia (+61)", // default search label / AU option
		`data-code="US"`,     // other countries present in the list
		`type="tel"`,         // the national-number input
		`name="attendeePhoneNumber"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("phone combobox missing %q in rendered form", want)
		}
	}
}

// TestHandleGetForm_DropsSystemFieldsBySlug asserts system fields with no valid
// new-booking home — location (radioInput) and rescheduleReason even WITHOUT a
// views flag — are not rendered (their submitted values would be discarded).
// Genuine custom fields still render.
func TestHandleGetForm_DropsSystemFieldsBySlug(t *testing.T) {
	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":[{
				"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
				"bookingFields":[
					{"slug":"name","type":"name","required":true},
					{"slug":"location","type":"radioInput","editable":"system","required":false},
					{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false},
					{"slug":"birthday","type":"text","label":"Birthday","editable":"user","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&timezone=UTC", 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, `name="location"`) {
		t.Errorf("location field must not render (its value is dropped on submit); body: %q", body)
	}
	if strings.Contains(body, `name="rescheduleReason"`) {
		t.Errorf("rescheduleReason must not render even without a views flag; body: %q", body)
	}
	if !strings.Contains(body, `name="birthday"`) {
		t.Errorf("expected custom field name=\"birthday\" to still render")
	}
}

// TestHandleCreateBooking_EmptySystemFieldsNotSent asserts the real-world
// failure case: the visitor fills name/email but leaves the optional system
// fields (location, notes, guests, rescheduleReason, title) empty. None of
// those empties may reach Cal.com — Cal.com rejects empty/mistyped system
// responses with a 400, which is the production booking failure.
func TestHandleCreateBooking_EmptySystemFieldsNotSent(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"},
		"timezone":            {"America/Toronto"},
		"attendeePhoneNumber": {""},
		"location":            {""},
		"notes":               {""},
		"guests":              {""},
		"rescheduleReason":    {""},
		"title":               {""},
	}
	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")
	}

	// bookingFieldsResponses must be omitted entirely when there is nothing
	// custom and non-empty to send.
	if resps, present := cap.body["bookingFieldsResponses"]; present {
		m, _ := resps.(map[string]any)
		if len(m) != 0 {
			t.Errorf("bookingFieldsResponses must be empty/omitted when all optional fields are blank, got: %v", resps)
		}
		// No empty-string values may ever be sent.
		for k, v := range m {
			if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
				t.Errorf("bookingFieldsResponses[%q] is an empty string — Cal.com rejects empty responses", k)
			}
		}
		// System/reschedule keys must never appear as custom responses.
		for _, banned := range []string{"location", "notes", "guests", "rescheduleReason", "title", "attendeePhoneNumber"} {
			if _, bad := m[banned]; bad {
				t.Errorf("bookingFieldsResponses must not contain system key %q", banned)
			}
		}
	}
	// guests is a top-level field — empty guests must be omitted, not sent as "".
	if g, present := cap.body["guests"]; present {
		t.Errorf("empty guests must be omitted from the top-level payload, got: %v", g)
	}
}

// TestHandleCreateBooking_GuestsRoutedToTopLevelArray asserts that the `guests`
// system field (Cal.com type multiemail) is split and sent as the top-level
// `guests` array of emails — NOT as a comma-joined string inside
// bookingFieldsResponses.
func TestHandleCreateBooking_GuestsRoutedToTopLevelArray(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"},
		"guests":    {"a@x.com, b@y.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())
	}

	cap.mu.Lock()
	defer cap.mu.Unlock()
	guests, ok := cap.body["guests"].([]any)
	if !ok {
		t.Fatalf("guests must be a top-level JSON array, got %T: %v", cap.body["guests"], cap.body["guests"])
	}
	if len(guests) != 2 || guests[0] != "a@x.com" || guests[1] != "b@y.com" {
		t.Errorf("guests array wrong: %v", guests)
	}
	if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
		if _, bad := resps["guests"]; bad {
			t.Errorf("guests must not appear in bookingFieldsResponses")
		}
	}
}

// TestHandleCreateBooking_PhoneSlugRoutedToAttendee asserts that a phone value
// submitted under Cal.com's system slug `attendeePhoneNumber` (not the literal
// "phone") lands in attendee.phoneNumber, E.164-normalized (separators
// stripped) — not in bookingFieldsResponses.
func TestHandleCreateBooking_PhoneSlugRoutedToAttendee(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"},
		"attendeePhoneNumber": {"+61 432 777 227"},
	}
	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()
	att, ok := cap.body["attendee"].(map[string]any)
	if !ok {
		t.Fatalf("attendee missing/wrong type: %v", cap.body)
	}
	if att["phoneNumber"] != "+61432777227" {
		t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (separators stripped)", att["phoneNumber"])
	}
	if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
		if _, bad := resps["attendeePhoneNumber"]; bad {
			t.Errorf("attendeePhoneNumber must route to attendee.phoneNumber, not bookingFieldsResponses")
		}
	}
}

// TestNormalizePhone_AppliesDialCode pins the E.164 normalization: Cal.com
// rejects national-format numbers (`{attendeePhoneNumber}invalid_number`), so a
// configured default dial code converts trunk-0 / bare-national numbers to
// international form. Numbers already in +/00 international form are left intact.
func TestNormalizePhone(t *testing.T) {
	// libphonenumber applies each country's trunk rules. The IN/IT/RU cases failed
	// under the old hand-rolled heuristic — the reviewer's bug class.
	cases := []struct{ raw, region, want string }{
		{"0432777227", "AU", "+61432777227"},     // AU mobile, trunk-0 dropped
		{"0412 345 678", "AU", "+61412345678"},   // separators + trunk-0
		{"07911123456", "GB", "+447911123456"},   // UK mobile, trunk-0 dropped
		{"+44 7911 123456", "", "+447911123456"}, // already international, region ignored
		{"9876543210", "IN", "+919876543210"},    // India mobile
		{"0236618300", "IT", "+390236618300"},    // Italy landline KEEPS the leading 0
		{"89161234567", "RU", "+79161234567"},    // Russia trunk prefix is 8
		{"", "AU", ""},                           // empty stays empty
		{"NOPE", "AU", "NOPE"},                   // unparseable -> best-effort fallback
		{"555-1212", "", "5551212"},              // no region, not international -> fallback strip
	}
	for _, c := range cases {
		if got := normalizePhone(c.raw, c.region); got != c.want {
			t.Errorf("normalizePhone(%q, %q) = %q, want %q", c.raw, c.region, got, c.want)
		}
	}
}

// TestRegionFromLocale pins extraction of the region subtag from navigator.language.
func TestRegionFromLocale(t *testing.T) {
	cases := map[string]string{
		"en-AU":      "AU",
		"en_AU":      "AU",
		"AU":         "AU",
		"fr-FR":      "FR",
		"zh-Hans-CN": "CN",
		"de-DE-1996": "DE",
		"en":         "", // no region subtag
		"":           "",
	}
	for in, want := range cases {
		if got := regionFromLocale(in); got != want {
			t.Errorf("regionFromLocale(%q) = %q, want %q", in, got, want)
		}
	}
}

// TestResolveRegion pins the country pre-selected in the phone combobox:
// timezone region first (strongest locator), then browser locale region.
func TestResolveRegion(t *testing.T) {
	cases := []struct{ locale, tz, want string }{
		{"en-AU", "", "AU"},
		{"en-US", "Australia/Sydney", "AU"}, // timezone beats mismatched locale
		{"fr-FR", "", "FR"},
		{"", "Pacific/Auckland", "NZ"},
		{"en", "", ""},  // no region
		{"", "UTC", ""}, // unmapped tz, no locale
		{"", "", ""},
	}
	for _, c := range cases {
		if got := resolveRegion(c.locale, c.tz); got != c.want {
			t.Errorf("resolveRegion(%q, %q) = %q, want %q", c.locale, c.tz, got, c.want)
		}
	}
}

// TestCountryFlag verifies ISO code → regional-indicator emoji.
func TestCountryFlag(t *testing.T) {
	if got := countryFlag("AU"); got != "🇦🇺" {
		t.Errorf("countryFlag(AU) = %q, want 🇦🇺", got)
	}
	// Guard rejects malformed input (wrong length / non-letters), not merely
	// unknown-but-well-formed codes.
	if got := countryFlag("X"); got != "" {
		t.Errorf("countryFlag(too short) = %q, want empty", got)
	}
	if got := countryFlag("1A"); got != "" {
		t.Errorf("countryFlag(non-letters) = %q, want empty", got)
	}
	if got := countryLabel("AU"); got != "🇦🇺 Australia (+61)" {
		t.Errorf("countryLabel(AU) = %q, want '🇦🇺 Australia (+61)'", got)
	}
}

// TestHandleCreateBooking_PhoneCountrySelectionWins asserts the visitor's
// explicit country-combobox choice drives the E.164 conversion and overrides
// browser detection.
func TestHandleCreateBooking_PhoneCountrySelectionWins(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":            {"bidbuddy"},
		"eventType":           {"30min"},
		"start":               {"2026-06-01T09:30:00Z"},
		"name":                {"Alex"},
		"email":               {"alex@example.com"},
		"attendeePhoneNumber": {"0432777227"},
		"phoneCountry":        {"AU"},    // explicit selection
		"locale":              {"en-US"}, // mismatched locale that must NOT win
	}
	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()
	att, _ := cap.body["attendee"].(map[string]any)
	if att["phoneNumber"] != "+61432777227" {
		t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (explicit AU selection)", att["phoneNumber"])
	}
	// phoneCountry is a control field — must not leak into responses.
	if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
		if _, bad := resps["phoneCountry"]; bad {
			t.Errorf("phoneCountry must not appear in bookingFieldsResponses")
		}
	}
}

// TestHandleCreateBooking_InfersDialCodeFromLocale is the zero-config regression:
// the bidbuddy visitor submits the AU national number 0432777227 with NO admin
// dial code configured; the browser locale (en-AU) must drive the E.164
// conversion so Cal.com accepts attendee.phoneNumber.
func TestHandleCreateBooking_InfersDialCodeFromLocale(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)
	}
	// Deliberately NO SetDefaultDialCode — detection must come from the locale.

	form := url.Values{
		"blockId":             {"b1"},
		"username":            {"bidbuddy"},
		"eventType":           {"30min"},
		"start":               {"2026-06-01T09:30:00Z"},
		"name":                {"Alex Dunmow"},
		"email":               {"alex@example.com"},
		"attendeePhoneNumber": {"0432777227"},
		"locale":              {"en-AU"},
	}
	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()
	att, ok := cap.body["attendee"].(map[string]any)
	if !ok {
		t.Fatalf("attendee missing/wrong type: %v", cap.body)
	}
	if att["phoneNumber"] != "+61432777227" {
		t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (E.164 inferred from en-AU locale)", att["phoneNumber"])
	}
	// locale is a control field — it must never leak into bookingFieldsResponses.
	if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
		if _, bad := resps["locale"]; bad {
			t.Errorf("locale must not appear in bookingFieldsResponses")
		}
	}
}

// TestStatusErrorWithBody_ParsesNestedCalcomError guards the diagnostic gap:
// Cal.com v2 returns errors as a NESTED object {"error":{"code","message"}},
// not a flat {"message"}. statusErrorWithBody must surface that message in the
// (server-side) error so booking failures are diagnosable instead of a bare
// "status 400".
func TestStatusErrorWithBody_ParsesNestedCalcomError(t *testing.T) {
	body := []byte(`{"status":"error","timestamp":"2026-05-30T00:00:00Z","path":"/v2/bookings","error":{"code":"BAD_REQUEST","message":"Invalid responses - guests: Expected array, received string"}}`)
	err := statusErrorWithBody("booking", http.StatusBadRequest, body)
	if err == nil {
		t.Fatal("expected an error")
	}
	msg := err.Error()
	if !strings.Contains(msg, "400") {
		t.Errorf("error must include the status code, got: %q", msg)
	}
	if !strings.Contains(msg, "guests: Expected array") {
		t.Errorf("error must surface Cal.com's nested error.message, got: %q", msg)
	}
}
