package main

import (
	"bytes"
	"context"
	"encoding/json"
	"log"
	"log/slog"
	"net/http"
	"net/url"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
	"git.dev.alexdunmow.com/block/pluginsdk/auth"
	"git.dev.alexdunmow.com/block/pluginsdk/plugin"
	"git.dev.alexdunmow.com/block/pluginsdk/rbac"
	"github.com/go-chi/chi/v5"
	"github.com/nyaruka/phonenumbers"
)

// blockContentResolver answers the server-authoritative question "does a
// booking for this Cal.com username + event-type slug require a captcha token?"
// by scanning currently-published block content — NEVER a client-supplied
// blockId. *poolQuerier satisfies it in production (per-call transaction over
// plugin.Pool); tests inject a fake. Nil in contexts without a DB (editor
// preview, direct-handler unit tests) — bookingRequiresCaptcha then reports
// false (baseline honeypot + rate limit still apply).
type blockContentResolver interface {
	CalcomBookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) (bool, error)
}

// requireAdmin gates a sub-router to authenticated CMS admins.
// The outer server runs OptionalAuth which populates the context with
// auth.Claims when the request carries a valid admin session cookie or
// Bearer token; otherwise context is empty. Plugin admin endpoints are
// otherwise publicly mounted at /api/plugins/calcomblock/*, so this guard
// is the only thing standing between the open internet and the tenant's
// Cal.com API key / webhook secret.
func requireAdmin(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, ok := auth.GetUserFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		if !rbac.HasPermission(rbac.Role(claims.Role), rbac.RoleAdmin) {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// emailRE is the relaxed email validation pattern used as a cheap filter for
// obvious junk. Cal.com does its own validation server-side.
var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)

// bookingsPerHourPerIP is the rate-limit ceiling for the public /book endpoint.
const bookingsPerHourPerIP = 10

// controlFields are transport / explicitly-handled form values (attendee
// name+email, hidden routing inputs, the honeypot). They are never forwarded as
// Cal.com bookingFieldsResponses.
var controlFields = map[string]bool{
	"blockId":          true,
	"username":         true,
	"eventType":        true,
	"start":            true,
	"timezone":         true,
	"locale":           true, // browser locale, used server-side to infer the phone dial code
	"phoneCountry":     true, // country-combobox selection (ISO code), drives the phone dial code
	"bn_message_extra": true, // honeypot (deliberately non-semantic to dodge Chrome autofill)
	"name":             true,
	"email":            true,
	"cap-token":        true, // captcha verification token (server-read, never forwarded to Cal.com)
}

// phoneFieldSlugs are the Cal.com system slugs that carry the attendee phone
// number. The default phone field's slug is `attendeePhoneNumber` (NOT "phone"),
// so a value under any of these routes to attendee.phoneNumber — never to
// bookingFieldsResponses, where it would both fail phone validation and orphan
// the attendee's number.
var phoneFieldSlugs = map[string]bool{
	"attendeePhoneNumber": true,
	"phone":               true,
	"smsReminderNumber":   true,
	"number":              true,
}

// systemDropSlugs are Cal.com system fields with no valid home in a new-booking
// bookingFieldsResponses payload (custom-field-only per the v2 schema):
//   - location: a structured top-level object; left unset so Cal.com uses the
//     event type's configured location.
//   - rescheduleReason: reschedule-only — invalid on a create.
//   - title: system-managed.
//
// `guests` is handled separately (top-level array); `name`/`email` are read as
// attendee fields via controlFields.
var systemDropSlugs = map[string]bool{
	"location":         true,
	"rescheduleReason": true,
	"title":            true,
}

// bookingFormPayload is the Cal.com-bound projection of a submitted booking form.
type bookingFormPayload struct {
	Phone     string         // -> attendee.phoneNumber (E.164-normalized)
	Guests    []string       // -> top-level guests[]
	Responses map[string]any // -> bookingFieldsResponses (custom fields only)
}

// routeBookingForm splits a submitted booking form into Cal.com's typed slots
// per the v2 POST /bookings schema. Phone-typed fields go to attendee.phoneNumber,
// `guests` becomes the top-level email array, and only genuine custom fields land
// in bookingFieldsResponses. Empty values are dropped entirely — Cal.com rejects
// empty/mistyped system responses, which is the root cause of the silent booking
// failures this routing fixes. region (an ISO 3166-1 alpha-2 code, e.g. "AU") is
// used to convert national-format attendee phone numbers to E.164.
func routeBookingForm(form url.Values, region string) bookingFormPayload {
	out := bookingFormPayload{Responses: map[string]any{}}
	for k, vs := range form {
		switch {
		case controlFields[k]:
			// attendee name/email + transport fields handled elsewhere
		case phoneFieldSlugs[k]:
			if out.Phone == "" {
				out.Phone = normalizePhone(firstNonEmpty(vs), region)
			}
		case k == "guests":
			out.Guests = append(out.Guests, splitGuestEmails(vs)...)
		case systemDropSlugs[k]:
			// no valid custom-response home; dropped
		default:
			// Custom field. Drop empties; collapse to a scalar or a JSON array
			// for multi-valued inputs (checkboxes / multiselect).
			switch nonEmpty := dropEmptyValues(vs); len(nonEmpty) {
			case 0:
			case 1:
				out.Responses[k] = nonEmpty[0]
			default:
				out.Responses[k] = toAnySlice(nonEmpty)
			}
		}
	}
	if len(out.Responses) == 0 {
		out.Responses = nil
	}
	if len(out.Guests) == 0 {
		out.Guests = nil
	}
	return out
}

// normalizePhone converts a submitted phone number to the E.164 form Cal.com
// requires (it rejects national-format numbers with `{attendeePhoneNumber}
// invalid_number`). It parses via libphonenumber using `region` (an ISO 3166-1
// alpha-2 code, e.g. "AU") as the default country, which correctly applies each
// country's trunk-prefix rules — Italy keeps its leading 0, Russia's trunk is 8,
// most others drop a leading 0. Numbers already in international form (leading
// "+") parse regardless of region. When parsing fails or the number is invalid
// (unknown region, partial input) the separator-stripped input is returned
// best-effort and Cal.com does its own validation. Empty input yields "".
func normalizePhone(raw, region string) string {
	if strings.TrimSpace(raw) == "" {
		return ""
	}
	region = strings.ToUpper(strings.TrimSpace(region))
	if _, ok := countryByCode(region); !ok {
		region = "" // unknown region; libphonenumber then relies on a leading "+"
	}
	if num, err := phonenumbers.Parse(raw, region); err == nil && phonenumbers.IsValidNumber(num) {
		return phonenumbers.Format(num, phonenumbers.E164)
	}
	return stripPhoneSeparators(raw)
}

// stripPhoneSeparators removes spaces (incl. non-breaking), tabs and common
// punctuation used to format phone numbers, leaving digits and a leading "+".
func stripPhoneSeparators(raw string) string {
	var b strings.Builder
	for _, r := range strings.TrimSpace(raw) {
		switch r {
		case ' ', ' ', '\t', '-', '(', ')', '.', '/':
			// drop separators
		default:
			b.WriteRune(r)
		}
	}
	return b.String()
}

// splitGuestEmails flattens Cal.com's multiemail `guests` field — rendered by the
// widget as a single text input — into individual addresses. Accepts values
// separated by commas, semicolons, whitespace or newlines; trims and drops blanks.
func splitGuestEmails(vs []string) []string {
	var out []string
	for _, v := range vs {
		for _, part := range strings.FieldsFunc(v, func(r rune) bool {
			return r == ',' || r == ';' || r == '\n' || r == ' ' || r == '\t'
		}) {
			if p := strings.TrimSpace(part); p != "" {
				out = append(out, p)
			}
		}
	}
	return out
}

// firstNonEmpty returns the first trimmed non-empty value, or "".
func firstNonEmpty(vs []string) string {
	for _, v := range vs {
		if t := strings.TrimSpace(v); t != "" {
			return t
		}
	}
	return ""
}

// dropEmptyValues returns vs with blank / whitespace-only entries removed.
func dropEmptyValues(vs []string) []string {
	out := make([]string, 0, len(vs))
	for _, v := range vs {
		if strings.TrimSpace(v) != "" {
			out = append(out, v)
		}
	}
	return out
}

// toAnySlice widens a []string to []any for JSON array encoding.
func toAnySlice(ss []string) []any {
	out := make([]any, len(ss))
	for i, s := range ss {
		out[i] = s
	}
	return out
}

// CalcomHandler handles Cal.com booking API endpoints
type CalcomHandler struct {
	settings *SettingsManager
	limiter  *RateLimiter
	appURL   string
	// blockConfig resolves a block's stored content by blockId so captcha
	// enforcement reads the server-authoritative captchaEnabled flag rather than
	// trusting the POST body. nil in editor-preview / direct-handler unit tests,
	// where blockCaptchaEnabled reports false.
	blockConfig blockContentResolver
	// rt is the host-mediated egress transport (deps.OutboundHTTP, ADR 0023)
	// every Cal.com client is built with. nil in native/DESCRIBE/test builds,
	// where NewCalcomClient falls back to the default transport.
	rt http.RoundTripper
}

// NewCalcomHandler creates a new Cal.com handler. appURL is the externally
// reachable origin used to render the tenant-specific webhook URL in the
// admin settings panel (e.g. "https://acme.blockninjacms.com").
func NewCalcomHandler(settings *SettingsManager, limiter *RateLimiter, appURL string) *CalcomHandler {
	return &CalcomHandler{settings: settings, limiter: limiter, appURL: appURL}
}

// NewCalcomRouter creates the HTTP router for the Cal.com plugin.
// deps carries the CMS-provided Crypto and database Pool used to persist
// and encrypt the Cal.com API key + webhook secret.
func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
	r := chi.NewRouter()
	// Context does not cross the wasm ABI: requireAdmin's claims exist only if
	// the guest rebuilds them from the host-stamped X-Bn-Verified-* headers.
	// Without this middleware every admin endpoint 401s for real admins (the
	// settings panel could not save the API key — 2.0.3 regression).
	r.Use(auth.TrustedHeaderMiddleware)
	// Settings persistence goes through the SDK settings capabilities: under the
	// wasm sandbox the plugin's Postgres role cannot touch the CMS `settings`
	// table (public schema) directly, so the host performs the DB work.
	pq := &capabilityQuerier{settings: deps.Settings, updater: deps.SettingsUpdater}
	settings := NewSettingsManager(deps.Crypto, pq)
	// Routes are mounted at startup, before any page render — stash the
	// manager so CalcomBookingFunc (which renders with no request and no
	// handler) can resolve the site/event timezone for the initial grid.
	renderSettings = settings
	limiter := NewRateLimiter(bookingsPerHourPerIP)
	helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
	h := NewCalcomHandler(settings, limiter, deps.AppURL)
	// Route every Cal.com call through the host-mediated egress transport. nil
	// under native/DESCRIBE builds (deps has no host); the client then uses the
	// default transport, which only the non-wasm paths can reach.
	h.rt = deps.OutboundHTTP
	// blockConfig resolves the server-authoritative captcha requirement from
	// published block content via the content.published_block_configs
	// capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read
	// page_block_snapshots itself. deps.Content is nil in native/DESCRIBE
	// builds; blockConfig then stays nil and bookingRequiresCaptcha reports
	// false (honeypot + per-IP rate limit remain the baseline).
	if deps.Content != nil {
		h.blockConfig = publishedBlockResolver{source: deps.Content}
	}

	// Routes are registered ABSOLUTE on a flat chi router: the wasm host forwards
	// the FULL original path (r.URL.Path, no prefix strip), so the guest matches
	// on the complete path. A nested r.Route(httpBase, …) instead relies on chi
	// Mount's RoutePath rebasing, which the raw-path forwarder never populates —
	// mirror testplugin's httpBase convention (flat + absolute) so the surface
	// resolves regardless of how the host mounts the forwarder.
	// Public booking endpoints — reachable by anonymous visitors.
	r.Get(httpBase+"/slots", h.HandleGetSlots)
	r.Get(httpBase+"/form", h.HandleGetForm)
	r.Post(httpBase+"/book", h.HandleCreateBooking)
	r.Post(httpBase+"/cancel", h.HandleCancelBooking)
	r.Get(httpBase+"/reset", h.HandleReset)
	r.Get(httpBase+"/date-grid", h.HandleGetDateGrid)

	// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
	wh := NewWebhookHandler(settings, slog.Default())
	r.Post(httpBase+"/webhook", wh.Handle)

	// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
	// outer admin middleware, so this is the only line keeping the API key
	// + webhook secret + Cal.com proxy out of attackers' hands.
	r.Group(func(admin chi.Router) {
		admin.Use(requireAdmin)
		admin.Get(httpBase+"/settings", h.HandleGetSettings)
		admin.Post(httpBase+"/settings", h.HandleSaveSettings)
		admin.Post(httpBase+"/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
		admin.Post(httpBase+"/test", h.HandleTestConnection)
		admin.Get(httpBase+"/event-types", h.HandleListEventTypes)
	})

	return r
}

// eventTZCache memoizes the meeting timezone per (username, event slug) so
// the public endpoints don't pay two Cal.com round-trips per request. 15
// minutes is short enough that an availability-timezone change propagates
// quickly, long enough to amortize bursts. Failures are not cached — the next
// request retries. Package-level (not per-handler) so CalcomBookingFunc,
// which renders without a handler, can peek warm entries.
var eventTZCache = struct {
	sync.Mutex
	m map[string]eventTZEntry
}{m: map[string]eventTZEntry{}}

type eventTZEntry struct {
	tz      string
	fetched time.Time
}

const eventTZTTL = 15 * time.Minute

func eventTZKey(username, eventType string) string { return username + "|" + eventType }

// peekEventTZ returns the cached meeting timezone, or "" when cold/expired.
// Never fetches — safe to call from the page-render path.
func peekEventTZ(username, eventType string) string {
	eventTZCache.Lock()
	defer eventTZCache.Unlock()
	e, ok := eventTZCache.m[eventTZKey(username, eventType)]
	if !ok || time.Since(e.fetched) > eventTZTTL {
		return ""
	}
	return e.tz
}

func storeEventTZ(username, eventType, tz string) {
	eventTZCache.Lock()
	defer eventTZCache.Unlock()
	eventTZCache.m[eventTZKey(username, eventType)] = eventTZEntry{tz: tz, fetched: time.Now()}
}

// resetEventTZCache empties the cache. Test seam — withCalcomBaseURL calls it
// so per-test fake servers can't leak timezones into each other.
func resetEventTZCache() {
	eventTZCache.Lock()
	defer eventTZCache.Unlock()
	eventTZCache.m = map[string]eventTZEntry{}
}

// fetchEventTimezone resolves the meeting's timezone from Cal.com: the event
// type's pinned availability schedule when one is set, else the account's
// default schedule. Returns "" when any hop fails — callers then fall down
// the site-setting ladder rather than guessing.
func fetchEventTimezone(ctx context.Context, rt http.RoundTripper, apiKey, username, eventType string) string {
	client := NewCalcomClient(apiKey, rt)
	schedules, err := client.ListSchedules(ctx)
	if err != nil || len(schedules) == 0 {
		log.Printf("calcom: list schedules for %s/%s: %v", username, eventType, err)
		return ""
	}
	var scheduleID *int64
	if et, err := client.GetEventType(ctx, username, eventType); err == nil {
		scheduleID = et.ScheduleID
	} else {
		log.Printf("calcom: get event type %s/%s for timezone: %v (using default schedule)", username, eventType, err)
	}
	var fallback string
	for _, s := range schedules {
		if scheduleID != nil && s.ID == *scheduleID {
			return s.TimeZone
		}
		if s.IsDefault && fallback == "" {
			fallback = s.TimeZone
		}
	}
	return fallback
}

// eventLocation resolves the timezone every booking surface renders in: the
// MEETING's timezone (its Cal.com availability schedule), else the site-wide
// Localization setting, else UTC. The visitor's browser timezone is
// deliberately not consulted: availability like "Mon–Fri 8–6" is a fact about
// the host's calendar, and making the display depend on per-visitor client
// state is what produced the bidbuddy incidents (2026-06-10) — cached pages
// shipped a UTC sentinel and visitors saw 12:00 AM slots. One zone, server
// resolved, every surface.
func (h *CalcomHandler) eventLocation(ctx context.Context, apiKey, username, eventType string) (*time.Location, string) {
	tz := peekEventTZ(username, eventType)
	if tz == "" && apiKey != "" && username != "" && eventType != "" {
		if tz = fetchEventTimezone(ctx, h.rt, apiKey, username, eventType); tz != "" {
			storeEventTZ(username, eventType, tz)
		}
	}
	return h.settings.resolveLocation(ctx, tz)
}

// HandleGetSlots fetches available time slots from Cal.com via CalcomClient.
func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	apiKey, err := h.settings.GetAPIKey(r.Context())
	if err != nil {
		h.renderError(w, "Failed to load Cal.com settings", "", true)
		return
	}
	if apiKey == "" {
		h.renderError(w, "Cal.com integration is not configured", "", true)
		return
	}

	username := r.URL.Query().Get("username")
	eventType := r.URL.Query().Get("eventType")
	dateStr := r.URL.Query().Get("date")
	blockID := r.URL.Query().Get("blockId")

	if username == "" || eventType == "" || dateStr == "" {
		h.renderError(w, "Missing required parameters", blockID, true)
		return
	}

	loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)

	// The day window is midnight-to-midnight in the MEETING's timezone — not
	// UTC. Built at UTC midnight, a Perth event's "June 11" window covers
	// June 11 8:00 AM – June 12 8:00 AM local, pulling next-day slots under
	// the wrong heading.
	dayStart, err := time.ParseInLocation("2006-01-02", dateStr, loc)
	if err != nil {
		h.renderError(w, "Invalid date format", blockID, true)
		return
	}
	dayEnd := dayStart.AddDate(0, 0, 1)

	client := NewCalcomClient(apiKey, h.rt)
	slotsResp, err := client.GetSlots(
		r.Context(),
		username,
		eventType,
		dayStart.Format(time.RFC3339),
		dayEnd.Format(time.RFC3339),
		tzName,
	)
	if err != nil {
		log.Printf("calcom: get slots: %v", err)
		h.renderError(w, "Failed to fetch slots from Cal.com", blockID, true)
		return
	}

	type timedSlot struct {
		at   time.Time
		slot TimeSlot
	}
	var timed []timedSlot
	for dateKey, timeSlots := range slotsResp.Data {
		for _, slot := range timeSlots {
			ts := slot.When()
			t, err := time.Parse(time.RFC3339, ts)
			if err != nil {
				continue
			}
			local := t.In(loc)
			// Cal.com's end bound is date-granular: ask for one day and the
			// entire end day comes back too. Clamp to the requested local day.
			if local.Before(dayStart) || !local.Before(dayEnd) {
				continue
			}
			timed = append(timed, timedSlot{at: local, slot: TimeSlot{
				// UTC (…Z), never the event-timezone offset Cal.com may return:
				// this value is carried raw in the slot button's query string, and
				// a `+HH:MM` offset decodes to a space server-side, corrupting the
				// timestamp Cal.com then rejects on booking.
				Start:       t.UTC().Format(time.RFC3339),
				DisplayTime: local.Format("3:04 PM"),
				DateKey:     dateKey,
			}})
		}
	}
	// Slots are assembled by ranging a map — without a sort the order is luck.
	sort.Slice(timed, func(i, j int) bool { return timed[i].at.Before(timed[j].at) })
	slots := make([]TimeSlot, 0, len(timed))
	for _, ts := range timed {
		slots = append(slots, ts.slot)
	}

	config := SlotConfig{
		BlockID:      blockID,
		Username:     username,
		EventType:    eventType,
		SelectedDate: dayStart.Format("Monday, January 2, 2006"),
	}

	var buf bytes.Buffer
	if err := CalcomTimeSlots(slots, config).Render(r.Context(), &buf); err != nil {
		h.renderError(w, "Failed to render slots", blockID, true)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com slots response: %v", err)
	}
}

// HandleGetForm renders the booking form, including any custom bookingFields
// configured on the Cal.com event type.
func (h *CalcomHandler) HandleGetForm(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	blockID := r.URL.Query().Get("blockId")
	startTime := r.URL.Query().Get("start")
	dateStr := r.URL.Query().Get("date")
	username := r.URL.Query().Get("username")
	eventType := r.URL.Query().Get("eventType")

	apiKey, apiKeyErr := h.settings.GetAPIKey(r.Context())
	loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)

	t, err := time.Parse(time.RFC3339, startTime)
	displayTime := startTime
	if err == nil {
		displayTime = t.In(loc).Format("3:04 PM")
	}

	displayDate := dateStr
	if d, err := time.Parse("2006-01-02", dateStr); err == nil {
		displayDate = d.Format("Monday, January 2, 2006")
	}

	var bookingFields []BookingField
	if apiKeyErr == nil && apiKey != "" && username != "" && eventType != "" {
		client := NewCalcomClient(apiKey, h.rt)
		et, etErr := client.GetEventType(r.Context(), username, eventType)
		if etErr != nil {
			// Fall back to default fields so a transient Cal.com error doesn't
			// strand the visitor on a half-rendered form.
			log.Printf("calcom: get event type %q/%q: %v", username, eventType, etErr)
		} else {
			bookingFields = et.BookingFields
		}
	}

	formConfig := FormConfig{
		BlockID:            blockID,
		Username:           username,
		EventType:          eventType,
		SelectedDate:       displayDate,
		SelectedTime:       displayTime,
		StartTime:          startTime,
		TimeZone:           tzName,
		BookingFields:      bookingFields,
		DefaultCountryCode: resolveRegion(r.URL.Query().Get("locale"), tzName),
		// Same server-authoritative source HandleCreateBooking enforces against,
		// so the widget is shown exactly when a token will be required — keyed on
		// the username+eventType, not the client-supplied blockId.
		CaptchaEnabled: h.bookingRequiresCaptcha(r.Context(), username, eventType),
	}

	var buf bytes.Buffer
	if err := CalcomBookingForm(formConfig).Render(r.Context(), &buf); err != nil {
		h.renderError(w, "Failed to render form", blockID, false)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com booking form response: %v", err)
	}
}

// bookingRequiresCaptcha reports whether a booking for this Cal.com username +
// event-type slug requires a captcha token. The decision is server-authoritative:
// it is true iff ANY currently-published calcom:booking block for that
// username+eventType has captchaEnabled=true in its stored content — resolved
// from published content, NEVER from the client-supplied blockId, so a bot
// cannot strip the requirement by omitting or forging the id. Returns false when
// there is no resolver (editor preview / DB-less unit tests), when no published
// block matches (draft-only ⇒ baseline honeypot + rate limit still apply), or on
// a resolver error (logged; a transient DB blip must not block every booking —
// the honeypot + per-IP rate limit remain in force). This is the same source the
// block render (HandleGetForm) reads, keeping "show the widget" and "enforce the
// widget" in lockstep.
func (h *CalcomHandler) bookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) bool {
	if h.blockConfig == nil {
		return false
	}
	required, err := h.blockConfig.CalcomBookingRequiresCaptcha(ctx, username, eventTypeSlug)
	if err != nil {
		log.Printf("calcom: resolve captcha requirement for %q/%q: %v", username, eventTypeSlug, err)
		return false
	}
	return required
}

// HandleCreateBooking creates a booking via Cal.com using CalcomClient.
func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	apiKey, err := h.settings.GetAPIKey(r.Context())
	if err != nil {
		h.renderError(w, "Failed to load Cal.com settings", "", false)
		return
	}
	if apiKey == "" {
		h.renderError(w, "Cal.com integration is not configured", "", false)
		return
	}

	if err := r.ParseForm(); err != nil {
		h.renderError(w, "Invalid form data", r.FormValue("blockId"), false)
		return
	}

	blockID := r.FormValue("blockId")

	// Honeypot: real users never fill this hidden field (deliberately
	// non-semantic name to dodge Chrome autofill heuristics). Return a fake
	// confirmation to bots so they think they succeeded — never reach Cal.com.
	if strings.TrimSpace(r.FormValue("bn_message_extra")) != "" {
		var buf bytes.Buffer
		if err := CalcomConfirmation(BookingResult{Success: true}, blockID).Render(r.Context(), &buf); err != nil {
			h.renderError(w, "Failed to render confirmation", blockID, false)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if _, err := w.Write(buf.Bytes()); err != nil {
			log.Printf("calcom: write honeypot response: %v", err)
		}
		return
	}

	username := r.FormValue("username")
	eventType := r.FormValue("eventType")
	startTime := r.FormValue("start")
	// Cal.com's bookings endpoint requires `start` in UTC. Slot starts already
	// arrive as UTC (…Z); normalize defensively so a future slots-format change
	// (e.g. a local UTC offset) can never send a non-UTC start upstream.
	if t, err := time.Parse(time.RFC3339, startTime); err == nil {
		startTime = t.UTC().Format(time.RFC3339)
	}
	name := r.FormValue("name")
	email := r.FormValue("email")

	// Cheap validation BEFORE the rate-limit allowance is consumed so a flood
	// of garbage-email requests can't drain a victim IP's quota (spec § 3.8).
	if name == "" || email == "" {
		h.renderError(w, "Name and email are required", blockID, false)
		return
	}
	if !emailRE.MatchString(email) {
		h.renderError(w, "Invalid email address", blockID, false)
		return
	}

	ip := helpers.GetRealIP(r)
	if h.limiter != nil && !h.limiter.Allow(ip) {
		w.WriteHeader(http.StatusTooManyRequests)
		h.renderError(w, "Too many bookings from this network. Please try again later.", blockID, false)
		return
	}

	// Captcha enforcement. Layered ON TOP of the honeypot + per-IP rate limit
	// above (verified after both, per the shared captcha design). The
	// authoritative source is whether any currently-published calcom:booking
	// block for this username+eventType has captchaEnabled=true — resolved from
	// published content, never the POST body/blockId — so a bot cannot strip the
	// requirement by omitting or forging blockId. Verification is HOST-side: a
	// guest cannot hold the host's stateful captcha server, so the host consumes
	// the cap-token and stamps the unforgeable X-Bn-Verified-Captcha trusted
	// header (pluginsdk/auth). When captcha IS required we fail CLOSED: no
	// stamp (invalid token, or the instance captcha server failed at startup)
	// rejects rather than allows. On failure we render the same error partial
	// as other validation failures (not a 500), so the visitor can re-solve
	// and retry.
	if h.bookingRequiresCaptcha(r.Context(), username, eventType) {
		if !auth.CaptchaVerified(r.Header) {
			h.renderError(w, "Captcha check failed, please try again.", blockID, false)
			return
		}
	}

	loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)

	// Resolve the phone region used to convert national numbers to E.164 (Cal.com
	// rejects national-format numbers as invalid_number). The visitor's explicit
	// country-combobox choice (phoneCountry) wins; otherwise fall back to the
	// timezone/locale detection that seeded the combobox default.
	region := strings.ToUpper(strings.TrimSpace(r.FormValue("phoneCountry")))
	if _, ok := countryByCode(region); !ok {
		region = resolveRegion(r.FormValue("locale"), tzName)
	}
	payload := routeBookingForm(r.Form, region)

	client := NewCalcomClient(apiKey, h.rt)
	br := BookingRequest{
		EventTypeSlug: eventType,
		Username:      username,
		Start:         startTime,
		Attendee: BookingAttendee{
			Name:     name,
			Email:    email,
			Phone:    payload.Phone,
			TimeZone: tzName,
		},
		BookingFieldsResponses: payload.Responses,
		Guests:                 payload.Guests,
	}
	booking, err := client.CreateBooking(r.Context(), br)
	if err != nil {
		// Keep the FULL technical cause server-side (unchanged), but never
		// surface it. Classify into curated, user-actionable copy: Tier-1
		// specific messages for visitor-fixable failures (with a retry control
		// where re-picking a slot is the fix), else the admin-authored Tier-2
		// message (a hidden form field, defaulted when blank).
		log.Printf("calcom: create booking: %v", err)
		adminMessage := strings.TrimSpace(r.FormValue("unavailableMessage"))
		if adminMessage == "" {
			adminMessage = DefaultUnavailableMessage
		}
		res := classifyBookingError(err, adminMessage)
		h.renderError(w, res.message, blockID, res.canRetry)
		return
	}

	// startTime was normalized to UTC for the Cal.com API above; the
	// confirmation must render in the meeting's timezone, not the UTC instant.
	formattedDateTime := startTime
	if t, err := time.Parse(time.RFC3339, startTime); err == nil {
		formattedDateTime = t.In(loc).Format("Monday, January 2, 2006 at 3:04 PM")
	}

	result := BookingResult{
		Success:           true,
		BookingID:         booking.Data.UID,
		FormattedDateTime: formattedDateTime,
		Email:             email,
		MeetingLink:       booking.Data.MeetingLink(),
	}

	var buf bytes.Buffer
	if err := CalcomConfirmation(result, blockID).Render(r.Context(), &buf); err != nil {
		h.renderError(w, "Failed to render confirmation", blockID, false)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com confirmation response: %v", err)
	}
}

// HandleCancelBooking cancels a previously-created booking and renders the
// "cancelled" partial (which OOB-restores the date grid so the visitor can
// rebook). It is the server half of the localStorage-backed "manage your
// booking" panel: the widget script populates the hidden `uid` input from the
// visitor's own localStorage and POSTs here.
//
// SECURITY MODEL (deliberate): cancellation is authorized by the booking UID
// alone — the same capability model as Cal.com's own emailed cancel link. The
// UID is an unguessable Cal.com identifier, and the only place it is stored
// client-side is the booking visitor's own localStorage, so a visitor can only
// ever cancel a booking they made. There is no per-visitor auth on the public
// widget to bind it to (bookings are anonymous), so the IP rate limit is the
// abuse guard against someone brute-forcing UIDs. We reuse the booking limiter:
// a cancel is a write against Cal.com just like a booking, so the two share one
// per-IP budget and a flood of cancel attempts can't drain into unlimited
// upstream calls. Cal.com's raw error text is never surfaced — only curated
// copy — and the full cause is logged server-side.
func (h *CalcomHandler) HandleCancelBooking(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	apiKey, err := h.settings.GetAPIKey(r.Context())
	if err != nil {
		h.renderError(w, "Failed to load Cal.com settings", "", false)
		return
	}
	if apiKey == "" {
		h.renderError(w, "Cal.com integration is not configured", "", false)
		return
	}

	if err := r.ParseForm(); err != nil {
		h.renderError(w, "Invalid form data", r.FormValue("blockId"), false)
		return
	}

	blockID := r.FormValue("blockId")
	uid := strings.TrimSpace(r.FormValue("uid"))
	if uid == "" {
		h.renderError(w, "Missing booking reference", blockID, false)
		return
	}

	// Rate-limit by IP (shared booking budget) — the abuse guard for UID-based
	// cancellation. Consumed only after the cheap uid presence check so a flood
	// of empty requests can't drain a victim IP's quota.
	ip := helpers.GetRealIP(r)
	if h.limiter != nil && !h.limiter.Allow(ip) {
		w.WriteHeader(http.StatusTooManyRequests)
		h.renderError(w, "Too many requests from this network. Please try again later.", blockID, false)
		return
	}

	client := NewCalcomClient(apiKey, h.rt)
	if err := client.CancelBooking(r.Context(), uid, "Cancelled by attendee"); err != nil {
		// Full technical cause stays server-side; the visitor sees curated copy.
		log.Printf("calcom: cancel booking %q: %v", uid, err)
		h.renderError(w, "Sorry, we couldn't cancel that booking. Please try again or use our contact form.", blockID, true)
		return
	}

	var buf bytes.Buffer
	if err := CalcomCancelled(blockID).Render(r.Context(), &buf); err != nil {
		h.renderError(w, "Failed to render cancellation", blockID, false)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com cancellation response: %v", err)
	}
}

// HandleGetDateGrid returns a freshly rendered date-grid partial for a given
// weekStart + weeks window. Wired to the prev/next month-nav buttons, this
// is the server-side endpoint that drives month navigation; the response
// innerHTML-swaps into #calcom-date-grid-{id}.
func (h *CalcomHandler) HandleGetDateGrid(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	blockID := q.Get("blockId")
	username := q.Get("username")
	eventType := q.Get("eventType")

	weeks, _ := strconv.Atoi(q.Get("weeks"))
	if weeks <= 0 {
		weeks = 2
	}
	if weeks > 8 {
		weeks = 8 // matches the editor's max
	}

	// "Today" — and therefore the earliest reachable window — is a date on
	// the MEETING's calendar, not the server's. A UTC server clock is a day
	// behind a Perth schedule until 08:00 AWST; clamping to it would let the
	// grid start on a day the host can no longer be booked.
	apiKey, _ := h.settings.GetAPIKey(r.Context())
	loc, _ := h.eventLocation(r.Context(), apiKey, username, eventType)
	todayStr := time.Now().In(loc).Format("2006-01-02")
	today, _ := time.ParseInLocation("2006-01-02", todayStr, loc)

	start, err := time.ParseInLocation("2006-01-02", q.Get("weekStart"), loc)
	if err != nil {
		start = today
	}
	// Visitors cannot navigate before today.
	if start.Before(today) {
		start = today
	}

	config := BookingConfig{
		BlockID:       blockID,
		Username:      username,
		EventTypeSlug: eventType,
		WeeksToShow:   weeks,
		WeekStart:     start.Format("2006-01-02"),
		Today:         todayStr,
		RangeLabel:    formatRangeLabel(start, weeks),
		CanGoBack:     start.After(today),
		Dates:         generateDateGridFrom(start, weeks, todayStr),
	}

	var buf bytes.Buffer
	if err := calcomDateGrid(config).Render(r.Context(), &buf); err != nil {
		log.Printf("calcom: render date grid: %v", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com date-grid response: %v", err)
	}
}

// HandleReset rewinds the booking widget to its initial state. The body
// replaces #calcom-slots-{id} with nothing and includes hx-swap-oob clears
// for the form region + a step-indicator rewind — so a visitor pressing
// "Change date" no longer has a stale form lingering from a previous slot.
func (h *CalcomHandler) HandleReset(w http.ResponseWriter, r *http.Request) {
	blockID := r.URL.Query().Get("blockId")
	var buf bytes.Buffer
	if err := CalcomReset(blockID).Render(r.Context(), &buf); err != nil {
		log.Printf("calcom: render reset: %v", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com reset response: %v", err)
	}
}

// HandleGetSettings returns the current Cal.com settings: API key (masked) +
// webhook URL + masked webhook secret + last-event status. Secrets are never
// exposed verbatim.
func (h *CalcomHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey, err := h.settings.GetAPIKey(ctx)
	if err != nil {
		writeServerError(w, err, "Failed to load settings", "calcomblock.HandleGetSettings")
		return
	}
	secret, _ := h.settings.GetWebhookSecret(ctx)
	store, _ := h.settings.GetSettings(ctx)
	username, _ := h.settings.GetUsername(ctx)

	// Self-heal: tenants whose API key was saved before this feature shipped
	// have no cached username. Fire /me once on the first settings load and
	// persist the result so subsequent loads are cache-hits.
	if username == "" && apiKey != "" {
		client := NewCalcomClient(apiKey, h.rt)
		if me, err := client.GetMe(ctx); err != nil {
			log.Printf("calcom: backfill /me in HandleGetSettings: %v", err)
		} else {
			username = me.Username
			if err := h.settings.SetUsername(ctx, me.Username); err != nil {
				log.Printf("calcom: persist backfilled username: %v", err)
			}
		}
	}

	resp := map[string]any{
		"api_key_configured":        apiKey != "",
		"api_key_masked":            maskSecret(apiKey),
		"username":                  username,
		"webhook_url":               h.webhookURL(),
		"webhook_secret_configured": secret != "",
		"webhook_secret_masked":     helpers.MaskSecret(secret),
		"last_event_at":             store["last_event_at"],
		"last_event_type":           store["last_event_type"],
	}

	writeJSON(w, resp)
}

// webhookURL builds the externally reachable webhook URL for this tenant. It
// reads the AppURL the CMS injected when the plugin was mounted.
func (h *CalcomHandler) webhookURL() string {
	if h.appURL == "" {
		return "/api/plugins/calcomblock/webhook"
	}
	return strings.TrimRight(h.appURL, "/") + "/api/plugins/calcomblock/webhook"
}

// HandleSaveSettings saves the Cal.com API key
func (h *CalcomHandler) HandleSaveSettings(w http.ResponseWriter, r *http.Request) {
	var req struct {
		APIKey string `json:"api_key"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	ctx := r.Context()
	if err := h.settings.SetAPIKey(ctx, req.APIKey); err != nil {
		writeServerError(w, err, "Failed to save settings", "calcomblock.HandleSaveSettings")
		return
	}

	if req.APIKey == "" {
		// Clearing the key also clears the cached identity so a future save
		// with a fresh key doesn't reuse the previous owner's username.
		if err := h.settings.SetUsername(ctx, ""); err != nil {
			log.Printf("calcom: clear username on api-key removal: %v", err)
		}
	} else {
		// Resolve the API key's owner so the block editor doesn't have to
		// ask the admin to type their own username. Best-effort: if /me is
		// down or rate-limited, the save still succeeds and the admin can
		// refresh later via Test Connection.
		client := NewCalcomClient(req.APIKey, h.rt)
		if me, err := client.GetMe(ctx); err != nil {
			log.Printf("calcom: /me lookup after save: %v", err)
		} else if err := h.settings.SetUsername(ctx, me.Username); err != nil {
			log.Printf("calcom: persist username after /me: %v", err)
		}

		// On the first save with an API key, generate a webhook secret so
		// the admin has something to paste into Cal.com's webhook
		// configuration immediately.
		existing, _ := h.settings.GetWebhookSecret(ctx)
		if existing == "" {
			secret, err := generateWebhookSecret()
			if err != nil {
				writeServerError(w, err, "Failed to generate webhook secret", "calcomblock.HandleSaveSettings")
				return
			}
			if err := h.settings.SetWebhookSecret(ctx, secret); err != nil {
				writeServerError(w, err, "Failed to save webhook secret", "calcomblock.HandleSaveSettings")
				return
			}
		}
	}

	writeJSON(w, map[string]any{
		"success":            true,
		"api_key_masked":     maskSecret(req.APIKey),
		"api_key_configured": req.APIKey != "",
	})
}

// HandleRotateWebhookSecret generates a new webhook signing secret and returns
// its masked form. Invalidates the previous secret immediately — Cal.com will
// reject subsequent retries with the old secret.
func (h *CalcomHandler) HandleRotateWebhookSecret(w http.ResponseWriter, r *http.Request) {
	secret, err := generateWebhookSecret()
	if err != nil {
		writeServerError(w, err, "Failed to generate webhook secret", "calcomblock.HandleRotateWebhookSecret")
		return
	}
	if err := h.settings.SetWebhookSecret(r.Context(), secret); err != nil {
		writeServerError(w, err, "Failed to save webhook secret", "calcomblock.HandleRotateWebhookSecret")
		return
	}
	writeJSON(w, map[string]any{
		"success":               true,
		"webhook_secret_masked": helpers.MaskSecret(secret),
	})
}

// HandleListEventTypes powers the block editor's event-type dropdown. The
// `username` query param scopes the lookup; an empty value returns the
// API-key owner's own event types.
func (h *CalcomHandler) HandleListEventTypes(w http.ResponseWriter, r *http.Request) {
	apiKey, err := h.settings.GetAPIKey(r.Context())
	if err != nil {
		writeServerError(w, err, "Failed to read settings", "calcomblock.HandleListEventTypes")
		return
	}
	if apiKey == "" {
		writeJSON(w, map[string]any{"success": false, "error": "API key not configured"})
		return
	}
	username := r.URL.Query().Get("username")
	client := NewCalcomClient(apiKey, h.rt)
	ets, err := client.ListEventTypes(r.Context(), username)
	if err != nil {
		log.Printf("calcom: list event types: %v", err)
		writeJSON(w, map[string]any{"success": false, "error": err.Error()})
		return
	}
	writeJSON(w, map[string]any{"success": true, "event_types": ets})
}

// HandleTestConnection verifies the configured API key by asking Cal.com for
// the API-key owner's event types.
func (h *CalcomHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
	apiKey, err := h.settings.GetAPIKey(r.Context())
	if err != nil {
		writeJSON(w, map[string]any{"success": false, "error": "Failed to load settings"})
		return
	}
	if apiKey == "" {
		writeJSON(w, map[string]any{"success": false, "error": "API key not configured"})
		return
	}

	client := NewCalcomClient(apiKey, h.rt)
	ets, err := client.ListEventTypes(r.Context(), "")
	if err != nil {
		log.Printf("calcom: test connection: %v", err)
		writeJSON(w, map[string]any{"success": false, "error": err.Error()})
		return
	}

	writeJSON(w, map[string]any{
		"success":     true,
		"event_types": ets,
	})
}

// renderError renders an error message
func (h *CalcomHandler) renderError(w http.ResponseWriter, message, blockID string, canRetry bool) {
	var buf bytes.Buffer
	if err := CalcomError(message, blockID, canRetry).Render(context.Background(), &buf); err != nil {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if _, writeErr := w.Write(htmlErrorFallback(message)); writeErr != nil {
			log.Printf("failed to write Cal.com fallback error response: %v", writeErr)
		}
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if _, err := w.Write(buf.Bytes()); err != nil {
		log.Printf("failed to write Cal.com rendered error response: %v", err)
	}
}

func htmlErrorFallback(message string) []byte {
	return []byte(`<div class="p-4 bg-destructive/10 border border-destructive/20 rounded-lg"><p class="text-sm text-destructive">` + message + `</p></div>`)
}

// writeJSON sends a JSON response with the standard content type and best-effort logging.
func writeJSON(w http.ResponseWriter, payload any) {
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(payload); err != nil {
		log.Printf("calcom: encode JSON response: %v", err)
	}
}

// maskSecret returns a masked version of a secret
func maskSecret(secret string) string {
	if len(secret) <= 11 {
		return "***"
	}
	return secret[:7] + "..." + secret[len(secret)-4:]
}
