package main

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/google/uuid"

	"git.dev.alexdunmow.com/block/pluginsdk/auth"
)

// fakeBlockResolver is an in-memory blockContentResolver so the booking handler
// can resolve the server-authoritative "does this username+eventType require a
// captcha?" answer without a live database. It mirrors the DB query
// (CalcomBookingRequiresCaptcha), which returns true iff a published
// calcom:booking block for that username+eventTypeSlug has captchaEnabled=true.
// The key is username+eventTypeSlug — deliberately NOT the blockId — so tests
// prove the requirement no longer depends on the client-supplied id.
type fakeBlockResolver struct {
	// required maps "username\x00eventTypeSlug" → whether a published block
	// requires captcha for that pair.
	required map[string]bool
	// err, when set, is returned to exercise the resolver-error path.
	err error
}

func (f fakeBlockResolver) CalcomBookingRequiresCaptcha(_ context.Context, username, eventTypeSlug string) (bool, error) {
	if f.err != nil {
		return false, f.err
	}
	return f.required[username+"\x00"+eventTypeSlug], nil
}

// newCalcomCaptchaHandler builds a CalcomHandler whose published calcom:booking
// block for username "alice" / eventType "30min" carries the given
// captchaEnabled flag, plus a persisted API key so the booking flow reaches the
// Cal.com call. The requirement is keyed on username+eventType, NOT a blockId —
// see fakeBlockResolver.
func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler {
	t.Helper()
	h, _ := newTestHandler()
	if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
		t.Fatalf("SetAPIKey: %v", err)
	}
	h.blockConfig = fakeBlockResolver{required: map[string]bool{
		"alice\x0030min": captchaEnabled,
	}}
	return h
}

// captchaBookingForm builds a booking POST body that passes every cheap
// validation (name+email present, valid email) so the only thing standing
// between the request and the Cal.com call is the captcha check. blockID is
// posted only as a render target (as a real widget would); when empty it is
// omitted entirely, proving the captcha requirement does not depend on it.
func captchaBookingForm(blockID string) url.Values {
	v := url.Values{
		"username":  {"alice"},
		"eventType": {"30min"},
		"start":     {"2026-05-27T10:00:00Z"},
		"name":      {"Bob"},
		"email":     {"bob@example.com"},
	}
	if blockID != "" {
		v.Set("blockId", blockID)
	}
	return v
}

// postCaptchaBooking submits the form. captchaHeader is the raw value of the
// host-stamped X-Bn-Verified-Captcha trusted header ("" = host stamped
// nothing: no valid cap-token crossed the host, or the instance captcha
// server was unavailable). Guests never see raw tokens — the host verifies
// and consumes them before dispatch (pluginsdk/auth/trustedheaders.go).
func postCaptchaBooking(h *CalcomHandler, form url.Values, captchaHeader string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("X-Forwarded-For", "198.51.100.7") // TEST-NET-2, isolated bucket
	if captchaHeader != "" {
		req.Header.Set(auth.HeaderVerifiedCaptcha, captchaHeader)
	}
	rec := httptest.NewRecorder()
	h.HandleCreateBooking(rec, req)
	return rec
}

// fakeCalcomServer stands in for Cal.com and counts booking creations, so tests
// can assert whether a submission got past the captcha gate to actually book.
func fakeCalcomServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
	t.Helper()
	var bookings atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/bookings") {
			bookings.Add(1)
		}
		_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u"}}`)
	}))
	t.Cleanup(srv.Close)
	return srv, &bookings
}

func TestCalcomBooking_CaptchaEnabled_RejectsWithoutVerifiedHeader(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

	h := newCalcomCaptchaHandler(t, true)

	rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // host stamped nothing

	if got := bookings.Load(); got != 0 {
		t.Fatalf("Cal.com booking created %d times; want 0 (missing verified-captcha header must be rejected)", got)
	}
	if !strings.Contains(rec.Body.String(), "aptcha") {
		t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
	}
}

func TestCalcomBooking_CaptchaEnabled_RejectsNonCanonicalHeaderValue(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

	h := newCalcomCaptchaHandler(t, true)

	// Only the exact host-stamped value "1" counts; anything else reads as
	// unverified (auth.CaptchaVerified).
	rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "true")

	if got := bookings.Load(); got != 0 {
		t.Fatalf("Cal.com booking created %d times; want 0 (non-canonical header value rejected)", got)
	}
	if !strings.Contains(rec.Body.String(), "aptcha") {
		t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
	}
}

func TestCalcomBooking_CaptchaEnabled_ProceedsWithVerifiedHeader(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

	h := newCalcomCaptchaHandler(t, true)

	// A real widget still posts the cap-token field; the HOST consumes it and
	// stamps the header. The guest must trust the header and never forward the
	// raw token to Cal.com.
	form := captchaBookingForm(uuid.NewString())
	form.Set("cap-token", "host-already-consumed-this")
	rec := postCaptchaBooking(h, form, "1")

	if got := bookings.Load(); got != 1 {
		t.Fatalf("Cal.com booking created %d times; want 1 (host-verified captcha must pass)", got)
	}
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
	}
	// The captcha token must never be forwarded to Cal.com as a booking field.
	if strings.Contains(rec.Body.String(), "cap-token") {
		t.Fatalf("cap-token leaked into rendered response: %s", rec.Body.String())
	}
}

func TestCalcomBooking_CaptchaDisabled_HeaderNotRequired(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

	h := newCalcomCaptchaHandler(t, false)

	rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // no header

	if got := bookings.Load(); got != 1 {
		t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got)
	}
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
	}
	if strings.Contains(rec.Body.String(), "aptcha") {
		t.Fatalf("captcha error fragment rendered when captcha disabled: %s", rec.Body.String())
	}
}

// TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp pins the
// fail-closed contract: a request that carries a cap-token but NO host stamp
// (instance captcha server down, or the token was invalid/replayed host-side)
// must be rejected — the guest never verifies tokens itself.
func TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

	h := newCalcomCaptchaHandler(t, true)

	form := captchaBookingForm(uuid.NewString())
	form.Set("cap-token", "anything")
	rec := postCaptchaBooking(h, form, "")

	if got := bookings.Load(); got != 0 {
		t.Fatalf("Cal.com booking created %d times; want 0 (fail closed without host stamp)", got)
	}
	if !strings.Contains(rec.Body.String(), "aptcha") {
		t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
	}
}

// TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement is the
// regression guard for the security finding (commit 37e88d3b9): the captcha
// requirement derives from published content (username+eventType), NEVER the
// client-supplied blockId. A bot that omits blockId OR forges a random one must
// still be rejected when a published calcom:booking block for that
// username+eventType has captchaEnabled=true. Before the fix, omitting blockId
// caused blockCaptchaEnabled to fail open and book with no captcha.
func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T) {
	for _, tc := range []struct{ name, blockID string }{
		{"blockId omitted", ""},
		{"blockId forged (random, unresolvable)", uuid.NewString()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			fake, bookings := fakeCalcomServer(t)
			withCalcomBaseURL(t, fake.URL)

			h := newCalcomCaptchaHandler(t, true) // published block requires captcha

			// No host stamp; the requirement must not depend on blockId.
			rec := postCaptchaBooking(h, captchaBookingForm(tc.blockID), "")

			if got := bookings.Load(); got != 0 {
				t.Fatalf("Cal.com booking created %d times; want 0 (captcha requirement must not depend on blockId)", got)
			}
			if !strings.Contains(rec.Body.String(), "aptcha") {
				t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
			}
		})
	}
}

// TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen documents the
// deliberate choice for the requirement lookup: a resolver (DB) error is logged
// and treated as "not required", so a transient DB blip cannot block every
// booking site-wide. The honeypot + per-IP rate limit remain the baseline
// protection. (This is distinct from the missing-host-stamp path, which fails
// CLOSED when a block IS known to require captcha.)
func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
	fake, bookings := fakeCalcomServer(t)
	withCalcomBaseURL(t, fake.URL)

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

	rec := postCaptchaBooking(h, captchaBookingForm(""), "") // no host stamp
	if got := bookings.Load(); got != 1 {
		t.Fatalf("Cal.com booking created %d times; want 1 (resolver error → captcha not enforced; honeypot + rate limit still apply)", got)
	}
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
	}
}
