package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
	"unicode"
)

// Cal.com v2 API constants. Each endpoint requires its own `cal-api-version`
// value — historically they have all diverged.
const (
	versionSlots      = "2024-09-04"
	versionEventTypes = "2024-06-14"
	versionBookings   = "2026-02-25"
	versionMe         = "2024-06-11"
	versionSchedules  = "2024-06-11"
)

// calcomBaseURL is the Cal.com v2 API root. Declared as a var (not const) so
// the integration tests in handler_test.go can override it to point at a fake
// httptest.NewServer; in production it is set exactly once at startup and
// never mutated. The handlers call NewCalcomClient(apiKey) inline, which reads
// this value, so a single var swap reroutes every public handler at once.
var calcomBaseURL = "https://api.cal.com/v2"

// CalcomClient is a thin wrapper over Cal.com's v2 REST API. Each method sets
// the version header appropriate for the endpoint it targets.
type CalcomClient struct {
	http    *http.Client
	apiKey  string
	baseURL string
}

// NewCalcomClient builds a client with the standard 30s HTTP timeout. rt is the
// host-mediated egress transport (deps.OutboundHTTP, ADR 0023) — under wasm the
// guest has no network, so every Cal.com request must ride it. A nil rt falls
// back to http.DefaultTransport, keeping native/DESCRIBE and httptest paths
// working (tests reach their fake server by overriding calcomBaseURL).
func NewCalcomClient(apiKey string, rt http.RoundTripper) *CalcomClient {
	return &CalcomClient{
		http:    &http.Client{Transport: rt, Timeout: 30 * time.Second},
		apiKey:  apiKey,
		baseURL: calcomBaseURL,
	}
}

// SlotsResponse mirrors the shape of `GET /v2/slots`:
//
//	{ "status": "success", "data": { "YYYY-MM-DD": [{ "time": "..." }] } }
type SlotsResponse struct {
	Status string                 `json:"status"`
	Data   map[string][]SlotEntry `json:"data"`
}

// SlotEntry is a single slot row in the SlotsResponse.Data map.
//
// The slot's timestamp field is cal-api-version dependent. The version this
// client pins — versionSlots = "2024-09-04" — returns {"start": "<RFC3339>"},
// whereas the older 2024-08-13 shape returned {"time": "<RFC3339>"}. Cal.com
// has a long history of diverging response shapes across versions (see the
// version-const note above), so we decode BOTH fields and resolve the
// effective value via When() — keeping the widget working even if the pinned
// version is ever rolled forward or back.
type SlotEntry struct {
	Start string `json:"start"`
	Time  string `json:"time"`
}

// When returns the slot's RFC3339 start timestamp, preferring the 2024-09-04
// `start` field and falling back to the legacy `time` field.
func (s SlotEntry) When() string {
	if s.Start != "" {
		return s.Start
	}
	return s.Time
}

// GetSlots returns the slots Cal.com would offer for the given event type
// between [start, end] in the requested timezone. start/end should be RFC3339.
func (c *CalcomClient) GetSlots(ctx context.Context, username, eventTypeSlug, start, end, timeZone string) (SlotsResponse, error) {
	q := url.Values{}
	q.Set("username", username)
	q.Set("eventTypeSlug", eventTypeSlug)
	q.Set("start", start)
	q.Set("end", end)
	if timeZone != "" {
		q.Set("timeZone", timeZone)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/slots?"+q.Encode(), nil)
	if err != nil {
		return SlotsResponse{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionSlots)

	resp, err := c.http.Do(req)
	if err != nil {
		return SlotsResponse{}, err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return SlotsResponse{}, statusError("slots", resp)
	}
	var out SlotsResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return SlotsResponse{}, fmt.Errorf("decode slots: %w", err)
	}
	return out, nil
}

// ListEventTypes returns the compact summary list used to populate the editor
// dropdown.
func (c *CalcomClient) ListEventTypes(ctx context.Context, username string) ([]EventTypeSummary, error) {
	q := url.Values{}
	q.Set("username", username)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionEventTypes)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return nil, statusError("event-types", resp)
	}
	var wrap struct {
		Status string             `json:"status"`
		Data   []EventTypeSummary `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
		return nil, fmt.Errorf("decode event-types: %w", err)
	}
	return wrap.Data, nil
}

// MeResponse is the subset of Cal.com's /v2/me response that the plugin uses
// to identify the API key's owner so the block editor doesn't have to ask the
// admin to type their own username.
type MeResponse struct {
	Username string `json:"username"`
	Email    string `json:"email"`
}

// GetMe returns the account identity associated with the configured API key.
// This is the source of truth for "whose Cal.com is this plugin configured
// for", and lets us drop the manual username input from the block editor.
func (c *CalcomClient) GetMe(ctx context.Context) (MeResponse, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/me", nil)
	if err != nil {
		return MeResponse{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionMe)

	resp, err := c.http.Do(req)
	if err != nil {
		return MeResponse{}, err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return MeResponse{}, statusError("me", resp)
	}
	var wrap struct {
		Status string     `json:"status"`
		Data   MeResponse `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
		return MeResponse{}, fmt.Errorf("decode me: %w", err)
	}
	return wrap.Data, nil
}

// ListSchedules returns the availability schedules of the account the API key
// belongs to. Used to resolve the meeting's timezone: an event type either
// pins a schedule (ScheduleID) or uses the account's default (IsDefault).
func (c *CalcomClient) ListSchedules(ctx context.Context) ([]Schedule, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/schedules", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionSchedules)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return nil, statusError("schedules", resp)
	}
	var wrap struct {
		Status string     `json:"status"`
		Data   []Schedule `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
		return nil, fmt.Errorf("decode schedules: %w", err)
	}
	return wrap.Data, nil
}

// GetEventType fetches the full event type record — including the configured
// `bookingFields` array — so the public form can render dynamic questions.
func (c *CalcomClient) GetEventType(ctx context.Context, username, eventTypeSlug string) (EventType, error) {
	q := url.Values{}
	q.Set("username", username)
	q.Set("eventSlug", eventTypeSlug)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil)
	if err != nil {
		return EventType{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionEventTypes)

	resp, err := c.http.Do(req)
	if err != nil {
		return EventType{}, err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return EventType{}, statusError("event-type", resp)
	}
	var wrap struct {
		Status string      `json:"status"`
		Data   []EventType `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
		return EventType{}, fmt.Errorf("decode event-type: %w", err)
	}
	if len(wrap.Data) == 0 {
		return EventType{}, fmt.Errorf("event type %q not found for user %q", eventTypeSlug, username)
	}
	et := wrap.Data[0]
	et.BookingFields = normalizeBookingFields(et.BookingFields)
	return et, nil
}

// normalizeBookingFields adapts Cal.com's v2 bookingFields to the shape the
// booking form template renders. Without it, default/system fields decode with
// an empty Name (the v2 identifier is `slug`, not `name`) and an empty Label
// (Cal.com leaves system-field labels blank and renders them client-side from
// i18n) — producing unlabelled inputs with empty `name` attributes, which are
// both invisible to the visitor AND drop their values on submit. Hidden system
// fields (e.g. `title`, `rescheduleReason`) are dropped so they never render.
func normalizeBookingFields(fields []BookingField) []BookingField {
	if len(fields) == 0 {
		return fields
	}
	out := make([]BookingField, 0, len(fields))
	for _, f := range fields {
		if f.Hidden || f.Editable == "system-but-hidden" {
			continue
		}
		// Reschedule-only system fields (rescheduleReason carries
		// views:[{id:"reschedule"}]) must never render on a NEW booking.
		if isRescheduleOnlyView(f.Views) {
			continue
		}
		// System fields the submit path drops because they have no valid
		// new-booking home (location → structured top-level object handled by
		// Cal.com's default; rescheduleReason → reschedule-only; title →
		// system-managed). Drop them from the form too, so we never render an
		// input whose value would be silently discarded. (rescheduleReason is
		// also covered here by slug for event types that omit the views flag.)
		if systemDropSlugs[f.Slug] || systemDropSlugs[f.Name] {
			continue
		}
		if f.Name == "" {
			f.Name = f.Slug
		}
		if f.Label == "" {
			f.Label = bookingFieldLabel(f.Name)
		}
		out = append(out, f)
	}
	return out
}

// isRescheduleOnlyView reports whether a booking field's `views` restrict it to
// the reschedule flow. Cal.com sets views:[{id:"reschedule"}] on rescheduleReason;
// such fields are invalid on a create booking and must not be rendered.
func isRescheduleOnlyView(views []BookingFieldView) bool {
	if len(views) == 0 {
		return false
	}
	for _, v := range views {
		if v.ID != "reschedule" {
			return false
		}
	}
	return true
}

// bookingFieldLabel resolves a human-readable label for a booking field whose
// Cal.com `label` came back empty — always the case for default/system fields.
// Known system slugs map to their conventional booker labels; anything else
// (a custom field that somehow lacks a label) is humanized from its slug.
func bookingFieldLabel(slug string) string {
	switch slug {
	case "name":
		return "Name"
	case "email":
		return "Email"
	case "location":
		return "Location"
	case "notes":
		return "Additional notes"
	case "guests":
		return "Guests"
	case "rescheduleReason":
		return "Reason for reschedule"
	case "attendeePhoneNumber", "phone", "smsReminderNumber":
		return "Phone number"
	case "title":
		return "What is this meeting about?"
	}
	return humanizeSlug(slug)
}

// humanizeSlug turns a field slug like "company-size" or "company_size" into a
// sentence-case display label ("Company size"). Empty input yields "Field" so a
// label element is never rendered completely blank.
func humanizeSlug(slug string) string {
	s := strings.TrimSpace(strings.NewReplacer("-", " ", "_", " ").Replace(slug))
	if s == "" {
		return "Field"
	}
	r := []rune(s)
	r[0] = unicode.ToUpper(r[0])
	return string(r)
}

// BookingAttendee carries the built-in fields that Cal.com expects every
// booking to include. Custom event-type fields travel in BookingFieldsResponses.
type BookingAttendee struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	TimeZone string `json:"timeZone"`
	Phone    string `json:"phoneNumber,omitempty"`
	Language string `json:"language,omitempty"`
}

// BookingRequest is the v2 `POST /v2/bookings` body. The event type is
// identified by slug + username — no separate ID lookup.
type BookingRequest struct {
	EventTypeSlug          string          `json:"eventTypeSlug"`
	Username               string          `json:"username"`
	Start                  string          `json:"start"`
	Attendee               BookingAttendee `json:"attendee"`
	BookingFieldsResponses map[string]any  `json:"bookingFieldsResponses,omitempty"`
	Metadata               map[string]any  `json:"metadata,omitempty"`
	Guests                 []string        `json:"guests,omitempty"`
}

// BookingResponse is the parsed `POST /v2/bookings` success envelope.
type BookingResponse struct {
	Status string              `json:"status"`
	Data   BookingResponseData `json:"data"`
}

// BookingResponseData carries the Cal.com-provided identifiers and the meeting
// location surfaced to the attendee.
//
// Per the 2026-02-25 POST /v2/bookings response, `location` is the canonical
// meeting field and `meetingUrl` is deprecated ("rely on 'location' instead"),
// kept here only as a fallback. The response does NOT carry rescheduleUrl /
// cancelUrl — those reach the attendee via Cal.com's confirmation email, so we
// no longer decode (or surface) them.
type BookingResponseData struct {
	UID        string `json:"uid"`
	StartTime  string `json:"start"`
	EndTime    string `json:"end"`
	Location   string `json:"location"`
	MeetingURL string `json:"meetingUrl"` // deprecated by Cal.com; fallback only
}

// MeetingLink returns the best available join URL. It prefers the canonical
// `location` field, but only when that is an http(s) URL — for non-video event
// types `location` can be a physical address or phone number, which must not be
// surfaced as a link. Falls back to the deprecated `meetingUrl`.
func (d BookingResponseData) MeetingLink() string {
	if isHTTPURL(d.Location) {
		return d.Location
	}
	if isHTTPURL(d.MeetingURL) {
		return d.MeetingURL
	}
	return ""
}

// isHTTPURL reports whether s is an http(s) URL.
func isHTTPURL(s string) bool {
	return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}

// CreateBooking submits a new booking to Cal.com. For a regular or instant
// event type the response `data` is a single object; for a recurring event
// type it is an array (one entry per occurrence). Both shapes are resolved —
// a recurring booking surfaces its first occurrence.
func (c *CalcomClient) CreateBooking(ctx context.Context, br BookingRequest) (BookingResponse, error) {
	body, err := json.Marshal(br)
	if err != nil {
		return BookingResponse{}, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/bookings", bytes.NewReader(body))
	if err != nil {
		return BookingResponse{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionBookings)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return BookingResponse{}, err
	}
	defer func() { _ = resp.Body.Close() }()

	respBody, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
		return BookingResponse{}, statusErrorWithBody("booking", resp.StatusCode, respBody)
	}
	// The bookings `data` is a single object for regular/instant bookings but an
	// ARRAY for recurring event types. Decode the envelope with a raw `data` so
	// both shapes resolve instead of hard-failing on the array form.
	var env struct {
		Status string          `json:"status"`
		Data   json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(respBody, &env); err != nil {
		return BookingResponse{}, fmt.Errorf("decode booking: %w", err)
	}
	out := BookingResponse{Status: env.Status}
	switch trimmed := bytes.TrimSpace(env.Data); {
	case len(trimmed) == 0:
		// no data object — leave zero-value Data
	case trimmed[0] == '[':
		var arr []BookingResponseData
		if err := json.Unmarshal(trimmed, &arr); err != nil {
			return BookingResponse{}, fmt.Errorf("decode booking (recurring): %w", err)
		}
		if len(arr) == 0 {
			return BookingResponse{}, fmt.Errorf("calcom booking: recurring data array was empty")
		}
		out.Data = arr[0]
	default:
		if err := json.Unmarshal(trimmed, &out.Data); err != nil {
			return BookingResponse{}, fmt.Errorf("decode booking: %w", err)
		}
	}
	return out, nil
}

// CancelBooking cancels an existing booking on Cal.com.
//
// Endpoint (confirmed against the Cal.com v2 docs, 2026-06-21):
//
//	POST /v2/bookings/{bookingUid}/cancel
//	cal-api-version: 2026-02-25   (the same version the create path pins —
//	                               passing the wrong value silently routes to an
//	                               older endpoint shape)
//	body: { "cancellationReason": "<reason>" }   (reason optional)
//
// Authorization is UID-based, mirroring Cal.com's own emailed cancel-link
// model: the booking UID is an unguessable identifier, so possession of it is
// the capability to cancel. The caller is responsible for the abuse guard (the
// public handler rate-limits by IP). uid is URL-path-escaped so a UID with
// path-significant characters can't break out of the route.
//
// A non-2xx response becomes a *CalcomAPIError (via statusErrorWithBody) so the
// handler can classify it; the raw Cal.com message stays server-side only.
func (c *CalcomClient) CancelBooking(ctx context.Context, uid, reason string) error {
	body, err := json.Marshal(struct {
		CancellationReason string `json:"cancellationReason"`
	}{CancellationReason: reason})
	if err != nil {
		return err
	}

	endpoint := c.baseURL + "/bookings/" + url.PathEscape(uid) + "/cancel"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("cal-api-version", versionBookings)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
		respBody, _ := io.ReadAll(resp.Body)
		return statusErrorWithBody("cancel-booking", resp.StatusCode, respBody)
	}
	return nil
}

// CalcomAPIError is a typed, non-2xx Cal.com API response. It carries the
// structured pieces the booking handler needs to classify a failure into
// user-actionable, curated copy — the HTTP Status, Cal.com's error Code (e.g.
// "BadRequestException"), and the raw Message ("Attempting to book a meeting in
// the past.") — WITHOUT the handler having to re-parse a flattened log string.
//
// Network/transport failures (DNS, timeout, connection reset) are NOT
// CalcomAPIErrors — those stay plain wrapped errors so the handler routes them
// to the admin-authored Tier-2 message. Only an HTTP response that came back
// with a non-2xx status produces a CalcomAPIError.
//
// Error() reproduces the exact string the previous fmt.Errorf form logged, so
// server-side logging is byte-for-byte unchanged. The Message is for
// classification + logs only — it is NEVER rendered to a visitor verbatim.
type CalcomAPIError struct {
	Endpoint string // the Cal.com endpoint that failed, e.g. "booking"
	Status   int    // HTTP status of the non-2xx response
	Code     string // Cal.com error.code, e.g. "BadRequestException" / "BAD_REQUEST" ("" if absent)
	Message  string // raw Cal.com error.message ("" if the body carried none)
}

func (e *CalcomAPIError) Error() string {
	// Preserve the historical log shape: "calcom <endpoint>: status <n>[: <code>: <msg>]".
	combined := e.Message
	if e.Code != "" && e.Message != "" {
		combined = e.Code + ": " + e.Message
	} else if e.Code != "" {
		combined = e.Code
	}
	if combined == "" {
		return fmt.Sprintf("calcom %s: status %d", e.Endpoint, e.Status)
	}
	return fmt.Sprintf("calcom %s: status %d: %s", e.Endpoint, e.Status, combined)
}

// statusError builds a sanitized error from a non-2xx response. The status
// code is exposed; the body is captured but only included for non-5xx so we
// don't leak upstream incident traces to operators.
func statusError(endpoint string, resp *http.Response) error {
	body, _ := io.ReadAll(resp.Body)
	return statusErrorWithBody(endpoint, resp.StatusCode, body)
}

// statusErrorWithBody returns a *CalcomAPIError for a non-2xx Cal.com response.
// The handler can errors.As it to branch on Status/Code/Message; everything
// else (and log output) sees the same flattened string as before.
func statusErrorWithBody(endpoint string, status int, body []byte) error {
	code, msg := extractCalcomError(body)
	return &CalcomAPIError{Endpoint: endpoint, Status: status, Code: code, Message: msg}
}

// extractCalcomError pulls the structured (code, message) pair out of a Cal.com
// error body. Cal.com v2 nests these under `error` as an OBJECT
// ({"error":{"code","message"}}); older/other shapes use a flat top-level
// `message` or a string `error`. All three are handled so a failed booking can
// be classified (and logged) by its real reason ("Attempting to book a meeting
// in the past.") instead of a bare "status 400". Both pieces stay server-side
// only — public handlers always render curated visitor-facing copy.
func extractCalcomError(body []byte) (code, message string) {
	var parsed struct {
		Message string          `json:"message"`
		Error   json.RawMessage `json:"error"`
	}
	if err := json.Unmarshal(body, &parsed); err != nil {
		return "", ""
	}
	if parsed.Message != "" {
		return "", parsed.Message
	}
	if len(parsed.Error) == 0 {
		return "", ""
	}
	// `error` may be a structured object {code,message} (v2) or a plain string.
	var errObj struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	}
	if err := json.Unmarshal(parsed.Error, &errObj); err == nil && errObj.Message != "" {
		return errObj.Code, errObj.Message
	}
	var errStr string
	if err := json.Unmarshal(parsed.Error, &errStr); err == nil && errStr != "" {
		return "", errStr
	}
	return "", ""
}
