package main

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

// These tests pin the timezone contract for the public booking flow: every
// surface (slot list, form recap, confirmation, attendee record) renders in
// the MEETING's timezone — the event's Cal.com availability schedule — never
// the visitor's. Ladder: event schedule timezone → site Localization setting
// → UTC.
//
// Written against the bidbuddy.com.au incidents (2026-06-10). First: the
// handler windowed and rendered slots in UTC (Perth visitors saw "12:00 AM"),
// leaked the next day (Cal.com's end bound is date-granular), and assembled
// slots from an unsorted map range. Second: display depended on client-side
// timezone detection, which shipped a never-updated "UTC" sentinel on cached
// pages. The meeting timezone is a server-side fact; visitor state is out.

// seedSiteTimezone writes a site settings blob with the given localization
// timezone into the fake querier, mirroring Site Settings → Localization.
func seedSiteTimezone(q *fakeQuerier, tz string) {
	q.mu.Lock()
	defer q.mu.Unlock()
	q.data["site"] = []byte(`{"localization":{"timezone":"` + tz + `"}}`)
}

// fakeCalcom builds a Cal.com test double serving the event-timezone
// resolution pair — /event-types (scheduleId: null → default schedule) and
// /schedules (the schedule carrying scheduleTZ) — and delegates every other
// path (slots, bookings) to rest. scheduleTZ "" makes /schedules fail with a
// 500, simulating Cal.com being unreachable for the timezone lookup. The
// double is installed as the package base URL for the duration of the test.
func fakeCalcom(t *testing.T, scheduleTZ string, rest http.HandlerFunc) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case strings.HasPrefix(r.URL.Path, "/event-types"):
			_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`)
		case strings.HasPrefix(r.URL.Path, "/schedules"):
			if scheduleTZ == "" {
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
			_, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"`+scheduleTZ+`","isDefault":true}]}`)
		default:
			if rest != nil {
				rest(w, r)
				return
			}
			_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
		}
	}))
	t.Cleanup(srv.Close)
	withCalcomBaseURL(t, srv.URL)
	return srv
}

// newKeyedHandler returns a handler whose settings carry an API key — the
// precondition for the event-timezone fetch.
func newKeyedHandler(t *testing.T) (*CalcomHandler, *fakeQuerier) {
	t.Helper()
	h, q := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}
	return h, q
}

// TestHandleGetSlots_WindowBuiltInEventTimezone asserts the [start, end)
// window sent to Cal.com covers the selected date in the MEETING's timezone —
// resolved from the event's availability schedule, with no timezone hint on
// the request at all. For date=2026-06-11 and a Perth schedule the window
// must be 2026-06-11T00:00:00+08:00 .. 2026-06-12T00:00:00+08:00.
func TestHandleGetSlots_WindowBuiltInEventTimezone(t *testing.T) {
	var gotQuery url.Values
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
	})
	h, _ := newKeyedHandler(t)

	req := httptest.NewRequest(http.MethodGet,
		"/slots?username=alice&eventType=30min&date=2026-06-11&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())
	}
	if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
		t.Errorf("outbound start: got %q, want 2026-06-11T00:00:00+08:00", got)
	}
	if got := gotQuery.Get("end"); got != "2026-06-12T00:00:00+08:00" {
		t.Errorf("outbound end: got %q, want 2026-06-12T00:00:00+08:00", got)
	}
	if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
		t.Errorf("outbound timeZone: got %q, want Australia/Perth", got)
	}
}

// TestHandleGetSlots_VisitorTimezoneParamIgnored pins the design decision: a
// timezone query param — whether a visitor's real browser zone or a stale
// cached page's sentinel — must NOT move the display away from the meeting's
// timezone. Availability is a fact about the host's calendar.
func TestHandleGetSlots_VisitorTimezoneParamIgnored(t *testing.T) {
	var gotQuery url.Values
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
	})
	h, _ := newKeyedHandler(t)

	req := httptest.NewRequest(http.MethodGet,
		"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1&timezone=America/New_York", 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())
	}
	if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
		t.Errorf("outbound timeZone: got %q, want Australia/Perth (visitor param must be ignored)", got)
	}
	if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
		t.Errorf("outbound start: got %q, want Perth-midnight window despite visitor param", got)
	}
}

// TestHandleGetSlots_FiltersSlotsOutsideEventDay reproduces the production
// leak: Cal.com treats the end bound date-granularly and returns the entire
// next day too. Slots not falling on the requested date in the meeting's
// timezone must be dropped, not rendered under the wrong heading (where
// same-wall-clock times read as duplicates).
func TestHandleGetSlots_FiltersSlotsOutsideEventDay(t *testing.T) {
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		// Perth-local response shape with a leaked next-day group, as observed
		// live on 2026-06-10 against api.cal.com.
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{
				"2026-06-11":[{"start":"2026-06-11T08:00:00.000+08:00"},{"start":"2026-06-11T17:30:00.000+08:00"}],
				"2026-06-12":[{"start":"2026-06-12T13:30:00.000+08:00"}]
			}
		}`)
	})
	h, _ := newKeyedHandler(t)

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

	body := rec.Body.String()
	if !strings.Contains(body, "8:00 AM") {
		t.Errorf("expected '8:00 AM' (Perth-local slot) in body, got: %q", body)
	}
	if !strings.Contains(body, "5:30 PM") {
		t.Errorf("expected '5:30 PM' (Perth-local slot) in body, got: %q", body)
	}
	if strings.Contains(body, "1:30 PM") {
		t.Errorf("next-day slot (2026-06-12 13:30) leaked into the 2026-06-11 view: %q", body)
	}
}

// TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone asserts display times are
// converted into the meeting's timezone even when Cal.com returns UTC (Z)
// timestamps — display must never depend on the offset Cal.com happens to
// format with.
func TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone(t *testing.T) {
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		// 2026-06-11 00:00Z == 8:00 AM Perth; 09:30Z == 5:30 PM Perth.
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{"2026-06-11":[{"start":"2026-06-11T00:00:00.000Z"},{"start":"2026-06-11T09:30:00.000Z"}]}
		}`)
	})
	h, _ := newKeyedHandler(t)

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

	body := rec.Body.String()
	if !strings.Contains(body, "8:00 AM") {
		t.Errorf("expected UTC slot rendered as '8:00 AM' Perth time, got: %q", body)
	}
	if !strings.Contains(body, "5:30 PM") {
		t.Errorf("expected UTC slot rendered as '5:30 PM' Perth time, got: %q", body)
	}
	if strings.Contains(body, "12:00 AM") {
		t.Errorf("slot rendered as raw UTC wall time ('12:00 AM') instead of Perth time: %q", body)
	}
}

// TestHandleGetSlots_SortsSlotsChronologically guards against Go map iteration
// order: a Perth-local day can span two UTC date keys, so slots assembled
// across keys must be explicitly sorted. (2026-06-10T23:00Z == 7:00 AM Perth
// on June 11; 2026-06-11T01:00:00Z == 9:00 AM Perth on June 11.)
func TestHandleGetSlots_SortsSlotsChronologically(t *testing.T) {
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.WriteString(w, `{
			"status":"success",
			"data":{
				"2026-06-10":[{"start":"2026-06-10T23:00:00.000Z"}],
				"2026-06-11":[{"start":"2026-06-11T01:00:00.000Z"}]
			}
		}`)
	})
	h, _ := newKeyedHandler(t)

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

	body := rec.Body.String()
	early := strings.Index(body, "7:00 AM")
	late := strings.Index(body, "9:00 AM")
	if early == -1 || late == -1 {
		t.Fatalf("expected both '7:00 AM' and '9:00 AM' in body, got: %q", body)
	}
	if early > late {
		t.Errorf("slots out of chronological order: '7:00 AM' rendered after '9:00 AM'")
	}
}

// TestHandleGetSlots_FallsBackToSiteTimezone asserts that when the event
// timezone can't be fetched (Cal.com schedules endpoint down), the site-wide
// Localization timezone is used instead of UTC.
func TestHandleGetSlots_FallsBackToSiteTimezone(t *testing.T) {
	var gotQuery url.Values
	fakeCalcom(t, "" /* schedules endpoint fails */, func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
	})
	h, q := newKeyedHandler(t)
	seedSiteTimezone(q, "Australia/Perth")

	req := httptest.NewRequest(http.MethodGet,
		"/slots?username=alice&eventType=30min&date=2026-06-11&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())
	}
	if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
		t.Errorf("outbound timeZone: got %q, want Australia/Perth (site fallback)", got)
	}
	if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
		t.Errorf("outbound start: got %q, want Perth-midnight window", got)
	}
}

// TestHandleGetSlots_InvalidEventTimezoneFallsBack asserts a garbage timezone
// on the Cal.com schedule degrades to the site timezone rather than erroring
// or silently using the bogus value.
func TestHandleGetSlots_InvalidEventTimezoneFallsBack(t *testing.T) {
	var gotQuery url.Values
	fakeCalcom(t, "Not/AZone", func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
	})
	h, q := newKeyedHandler(t)
	seedSiteTimezone(q, "Australia/Perth")

	req := httptest.NewRequest(http.MethodGet,
		"/slots?username=alice&eventType=30min&date=2026-06-11&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())
	}
	if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
		t.Errorf("outbound timeZone: got %q, want Australia/Perth (invalid event tz must fall back)", got)
	}
}

// TestHandleGetForm_DisplayTimeInEventTimezone asserts the form's "selected
// time" recap renders in the meeting's timezone when the slot start arrives
// as a UTC timestamp.
func TestHandleGetForm_DisplayTimeInEventTimezone(t *testing.T) {
	fakeCalcom(t, "Australia/Perth", nil)
	h, _ := newKeyedHandler(t)

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

	body := rec.Body.String()
	if !strings.Contains(body, "9:00 AM") {
		t.Errorf("expected form recap '9:00 AM' (Perth), got: %q", body)
	}
	if strings.Contains(body, "1:00 AM") {
		t.Errorf("form recap rendered raw UTC wall time ('1:00 AM'): %q", body)
	}
}

// TestHandleCreateBooking_ConfirmationInEventTimezone asserts the booking
// confirmation panel renders the meeting time in the meeting's timezone, and
// that the attendee record sent to Cal.com carries the meeting timezone too —
// even when a stale cached page still submits a visitor timezone field.
func TestHandleCreateBooking_ConfirmationInEventTimezone(t *testing.T) {
	var attendeeTZ string
	fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/bookings") {
			var payload struct {
				Attendee struct {
					TimeZone string `json:"timeZone"`
				} `json:"attendee"`
			}
			_ = json.NewDecoder(r.Body).Decode(&payload)
			attendeeTZ = payload.Attendee.TimeZone
			_, _ = io.WriteString(w, `{
				"status":"success",
				"data":{"uid":"uid-1","start":"2026-06-11T01:00:00Z","end":"2026-06-11T01:30:00Z","location":"https://meet.example.com/uid-1"}
			}`)
			return
		}
		_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
	})
	h, _ := newKeyedHandler(t)

	form := url.Values{
		"blockId":   {"b1"},
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-06-11T01:00:00Z"},
		"name":      {"Sue Findlay"},
		"email":     {"sue@example.test"},
		// A page cached before the rework still posts this — must be ignored.
		"timezone": {"America/New_York"},
	}
	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)

	body := rec.Body.String()
	// 01:00Z on June 11 is 9:00 AM Thursday June 11 in Perth.
	if !strings.Contains(body, "9:00 AM") {
		t.Errorf("expected confirmation in Perth time ('9:00 AM'), got: %q", body)
	}
	if strings.Contains(body, "1:00 AM") {
		t.Errorf("confirmation rendered the UTC instant ('1:00 AM') instead of meeting time: %q", body)
	}
	if attendeeTZ != "Australia/Perth" {
		t.Errorf("attendee timeZone sent to Cal.com: got %q, want Australia/Perth", attendeeTZ)
	}
}

// TestCalcomBookingWidget_NoClientTimezonePlumbing pins the removal of
// client-side timezone detection — the mechanism behind the original bidbuddy
// incident (a hidden input's "UTC" default that never updated on cached
// pages). The widget must carry no timezone input, attribute, or detection
// call; the locale capture must still defer to DOMContentLoaded (the input it
// fills sits after the script in parse order); and the visible label must be
// the server-resolved zone.
func TestCalcomBookingWidget_NoClientTimezonePlumbing(t *testing.T) {
	var buf strings.Builder
	cfg := BookingConfig{
		BlockID:       "b1",
		Username:      "alice",
		EventTypeSlug: "30min",
		WeeksToShow:   2,
		WeekStart:     "2026-06-11",
		Today:         "2026-06-11",
		TimeZone:      "Australia/Perth",
		Dates:         nil,
		ShowTimezone:  true,
	}
	if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil {
		t.Fatalf("render widget: %v", err)
	}
	html := buf.String()

	for _, leftover := range []string{`name="timezone"`, "data-timezone", "Intl.DateTimeFormat"} {
		if strings.Contains(html, leftover) {
			t.Errorf("widget still ships client timezone plumbing (%s)", leftover)
		}
	}
	if !strings.Contains(html, "DOMContentLoaded") {
		t.Errorf("widget init script must defer until DOMContentLoaded — running during parse leaves the locale input at its empty default")
	}
	if !strings.Contains(html, "Australia/Perth") {
		t.Errorf("ShowTimezone label must render the server-resolved zone, got: %q", html)
	}
}
