package web

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"testing"
	"time"

	"golang.org/x/crypto/bcrypt"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// selfbind_test.go — customer self-bind (v0.66.0, R-27 slice 1), Scenarios A–F.
//
// Each red-proof below names the ONE line to break to turn a scenario red — the guard that the test
// actually pins. If a red-proof does NOT turn its scenario red, the test is hollow.

const (
	testPass    = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words)
	testCode    = "ABC234"                          // an appliance console pairing code (raw stored form)
	testCodeFmt = "abc-234"                         // as a human might type it (lowercased, separated)
)

// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the
// given console pairing code, and returns the appliance id. Distinct customers must use distinct
// codes (a code shared by two registered appliances is ambiguous → binds nothing).
func selfBindSetup(t *testing.T, st *store.Store, customerID, code string) int64 {
	t.Helper()
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: customerID, CustomerName: customerID, APIKey: "k",
		RetrievalPassword: testPass, Email: customerID + "@example.test",
	}); err != nil {
		t.Fatal(err)
	}
	sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
	if _, _, err := st.RegisterAppliance("uuid-"+customerID, "bc:24:11:98:10:0e", sshKey, `{"product":"N100"}`, "aphash-"+customerID, code); err != nil {
		t.Fatal(err)
	}
	list, _ := st.ListUnclaimedAppliances()
	for _, a := range list {
		if a.UUID == "uuid-"+customerID {
			return a.ID
		}
	}
	t.Fatal("seeded appliance not found")
	return 0
}

// mintLink mints a self-bind token for the customer and returns the plaintext token (URL segment).
// Each call uses a fresh nonce so distinct calls yield distinct tokens (single-active still applies —
// the store deletes the prior row for the customer on each mint).
var mintNonce int

func mintLink(t *testing.T, st *store.Store, customerID string, ttl time.Duration) string {
	t.Helper()
	mintNonce++
	token := "tok-" + customerID + "-" + string(rune('a'+mintNonce%26)) + strconv.Itoa(mintNonce)
	if err := st.MintSelfBindToken(customerID, selfBindHash(token), ttl); err != nil {
		t.Fatal(err)
	}
	return token
}

func bindGET(t *testing.T, s *Server, token string) *httptest.ResponseRecorder {
	t.Helper()
	rr := httptest.NewRecorder()
	s.handleBind(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
	return rr
}

func bindPOST(t *testing.T, s *Server, token, code, pass string) *httptest.ResponseRecorder {
	t.Helper()
	form := url.Values{"pairing_code": {code}, "passphrase": {pass}}
	req := httptest.NewRequest("POST", "/bind/"+token, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()
	s.handleBind(rr, req)
	return rr
}

// --- Scenario A: happy path (GET renders form; POST with both correct factors stages the bind) ---
func TestSelfBind_A_HappyPath(t *testing.T) {
	s, st := newTestServer(t)
	id := selfBindSetup(t, st, "acme", testCode)
	token := mintLink(t, st, "acme", selfBindTTL)

	if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "Párosító kód") || !strings.Contains(body, "action=\"/bind/"+token+"\"") {
		t.Fatalf("GET did not render the entry form")
	}
	// A human types the code lowercased + separated and the passphrase with odd spacing — normalization
	// must accept both.
	rr := bindPOST(t, s, token, testCodeFmt, "  Alpha  Beta gamma-delta epsilon ")
	if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
		t.Fatalf("POST success page not rendered: code=%d body=%q", rr.Code, rr.Body.String())
	}
	// The appliance is bound to the customer (same effect as an operator bind).
	if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound || a.CustomerID != "acme" {
		t.Fatalf("appliance not bound: %+v", a)
	}
	// Provenance event is customer self-bind, not operator/hub.
	ev, _ := st.GetLatestEventByType("acme", "appliance_bound")
	if ev == nil || ev.Source != "customer_selfbind" {
		t.Fatalf("expected customer_selfbind provenance event, got %+v", ev)
	}
	// One-shot: the token is now consumed; a second open shows the consumed state.
	if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "már fel lett használva") {
		t.Fatalf("token not consumed after success")
	}
	// Red-proof: drop the ConsumeSelfBindToken call (or make BindAppliance the only effect) → the
	// token stays live and this consumed-state assertion goes red.
}

// --- Scenario B: NO ORACLE — wrong code and wrong passphrase yield the SAME generic failure ---
func TestSelfBind_B_NoOracle(t *testing.T) {
	s, st := newTestServer(t)
	// Two customers with DISTINCT pairing codes so the single-active mint does not cross-invalidate,
	// and each has one fresh (attempt-count 1) token — the only difference between the two failure
	// pages is then the token in the form action, which we normalize out.
	selfBindSetup(t, st, "acme", testCode)
	selfBindSetup(t, st, "acme2", "XYZ789")

	t1 := mintLink(t, st, "acme", selfBindTTL)   // wrong-code attempt (right passphrase)
	t2 := mintLink(t, st, "acme2", selfBindTTL)  // wrong-passphrase attempt (right code)
	wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN")
	wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN")

	if wrongCode != wrongPass {
		t.Fatalf("failure pages differ between wrong-code and wrong-passphrase — that is an oracle")
	}
	// The generic failure must not leak any appliance data.
	if !strings.Contains(wrongCode, "nem megfelelőek") {
		t.Fatalf("failure page missing the generic banner: %q", wrongCode)
	}
	if strings.Contains(wrongCode, "uuid-") || strings.Contains(wrongCode, "N100") {
		t.Fatalf("failure page leaked appliance data")
	}
	// Red-proof: give wrong-code and wrong-passphrase distinct messages/states (an oracle) → the
	// wrongCode == wrongPass assertion goes red.
}

// --- Scenario C1: LOCKOUT after 5 failed attempts; a subsequent CORRECT attempt cannot bind ---
func TestSelfBind_C1_Lockout(t *testing.T) {
	s, st := newTestServer(t)
	id := selfBindSetup(t, st, "acme", testCode)
	token := mintLink(t, st, "acme", selfBindTTL)

	for i := 1; i <= store.SelfBindMaxAttempts; i++ {
		rr := bindPOST(t, s, token, "ZZZ999", "definitely wrong words indeed")
		if i < store.SelfBindMaxAttempts && !strings.Contains(rr.Body.String(), "nem megfelelőek") {
			t.Fatalf("attempt %d should re-render the form with a failure, got %q", i, rr.Body.String())
		}
	}
	// The 5th failure locked it: even the CORRECT secrets now bind nothing.
	rr := bindPOST(t, s, token, testCodeFmt, testPass)
	if !strings.Contains(rr.Body.String(), "zárolva") {
		t.Fatalf("token not locked after %d failures: %q", store.SelfBindMaxAttempts, rr.Body.String())
	}
	if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
		t.Fatalf("a locked link still bound the appliance: %+v", a)
	}
	// Red-proof: remove the `locked = attempts >= SelfBindMaxAttempts` lock (never lock) → the correct
	// post-lockout POST binds and both the "zárolva" and still-registered assertions go red.
}

// --- Scenario C4: SINGLE-ACTIVE per customer — re-minting kills the prior link ---
func TestSelfBind_C4_SingleActive(t *testing.T) {
	s, st := newTestServer(t)
	selfBindSetup(t, st, "acme", testCode)

	first := mintLink(t, st, "acme", selfBindTTL)
	second := mintLink(t, st, "acme", selfBindTTL) // re-mint for the SAME customer

	if body := bindGET(t, s, first).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
		t.Fatalf("the first link still resolves after a re-mint (not single-active): %q", body)
	}
	if body := bindGET(t, s, second).Body.String(); !strings.Contains(body, "Párosító kód") {
		t.Fatalf("the freshly-minted link does not render the form: %q", body)
	}
	// Red-proof: drop the `DELETE FROM selfbind_tokens WHERE customer_id` in MintSelfBindToken → the
	// first link still resolves and the érvénytelen assertion goes red.
}

// --- Scenario D: the operator auth gate is intact — self-bind's exemption did NOT open other routes ---
func TestSelfBind_D_AuthGateIntact(t *testing.T) {
	s, st := newAuthServer(t)
	selfBindSetup(t, st, "acme", testCode)
	h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))

	for _, path := range []string{"/", "/hosts", "/customers/acme", "/configuration", "/offsite"} {
		rr := httptest.NewRecorder()
		h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
		if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/login" {
			t.Fatalf("gated route %s not redirected to /login: code=%d loc=%q", path, rr.Code, rr.Header().Get("Location"))
		}
	}
	// Red-proof: this is the companion to Scenario E — widening isPublicBindPath turns THIS red too.
}

// --- Scenario E: THE TRAP — /bind/ is exempt from operator auth, matched TIGHTLY (no leak/traversal) ---
func TestSelfBind_E_TheTrap(t *testing.T) {
	s, st := newAuthServer(t)
	selfBindSetup(t, st, "acme", testCode)
	token := mintLink(t, st, "acme", selfBindTTL)
	h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))

	// The public bind link renders WITHOUT a login (the exemption works).
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
	if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Párosító kód") {
		t.Fatalf("public /bind/ link was gated or not rendered: code=%d", rr.Code)
	}

	// A sibling prefix must NOT be exempt: /bindsecret is still gated (tight trailing-slash match).
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest("GET", "/bindsecret", nil))
	if rr.Code != http.StatusFound {
		t.Fatalf("/bindsecret leaked through the exemption (prefix not tight): code=%d", rr.Code)
	}

	// Traversal through the exempt prefix must not reach a gated handler: it stays inside handleBind,
	// which rejects a token containing '/', never touching /hosts.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/../hosts", nil))
	if strings.Contains(rr.Body.String(), "Unclaimed appliances") || strings.Contains(rr.Body.String(), "No hosts enrolled") {
		t.Fatalf("path traversal through /bind/ reached the hosts page")
	}
	// Red-proof: widen isPublicBindPath to strings.HasPrefix(path, "/bind") (drop the slash) → the
	// /bindsecret gated assertion goes red; broaden it further and Scenario D goes red too.
}

// --- Scenario F: EXPIRY falls back — an expired link binds nothing, even with correct factors ---
func TestSelfBind_F_ExpiryFallsBack(t *testing.T) {
	s, st := newTestServer(t)
	id := selfBindSetup(t, st, "acme", testCode)
	token := mintLink(t, st, "acme", -1*time.Hour) // already expired

	if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
		t.Fatalf("expired link did not render the expired state")
	}
	// Even the CORRECT secrets on an expired link bind nothing (operator-bind fallback is unchanged).
	bindPOST(t, s, token, testCodeFmt, testPass)
	if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
		t.Fatalf("an expired link still bound the appliance: %+v", a)
	}
	// Red-proof: drop the `expires_at > datetime('now')` guard in ConsumeSelfBindToken AND the
	// tok.Expired() gate → the expired POST binds and the still-registered assertion goes red.
}

// --- Scenario (F1/F2): the operator MINT honesty paths ---

// F1: a customer with no registered email → nothing minted, LOUD flash.
func TestSelfBind_MintNoEmail(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "noemail", APIKey: "k", RetrievalPassword: testPass}); err != nil {
		t.Fatal(err)
	}
	s.SetSelfBindMailer(&stubMailer{})
	rr := httptest.NewRecorder()
	req := httptest.NewRequest("POST", "/customers/noemail/selfbind-link", nil)
	s.handleSelfBindLinkSend(rr, req, "noemail")
	if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "selfbind-no-email") {
		t.Fatalf("no-email mint should redirect with selfbind-no-email: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
	}
	if tok, _ := st.SelfBindTokenByHash(selfBindHash("x")); tok != nil {
		t.Fatal("a token was minted despite no email")
	}
}

// F2: the email send fails → the just-minted token is deleted (not left silently live).
func TestSelfBind_MintSendFailsCleansUp(t *testing.T) {
	s, st := newTestServer(t)
	selfBindSetup(t, st, "acme", testCode)
	stub := &stubMailer{fail: true}
	s.SetSelfBindMailer(stub)
	rr := httptest.NewRecorder()
	s.handleSelfBindLinkSend(rr, httptest.NewRequest("POST", "/customers/acme/selfbind-link", nil), "acme")
	if !strings.Contains(rr.Header().Get("Location"), "selfbind-send-failed") {
		t.Fatalf("send failure should redirect with selfbind-send-failed: %q", rr.Header().Get("Location"))
	}
	// The token that was minted for the (failed) send is gone — not left silently live (F2 cleanup).
	// Recover the token from the link the mailer was handed, and confirm it no longer resolves.
	tokenFromLink := stub.link[strings.LastIndexByte(stub.link, '/')+1:]
	if tokenFromLink == "" {
		t.Fatal("mailer was never handed a link")
	}
	if tok, _ := st.SelfBindTokenByHash(selfBindHash(tokenFromLink)); tok != nil {
		t.Fatal("a failed send left a live token behind")
	}
}

// stubMailer records the last send and can be told to fail.
type stubMailer struct {
	fail bool
	link string
}

func (m *stubMailer) SendSelfBindEmail(customerID, email, link string) error {
	m.link = link
	if m.fail {
		return errStubSend
	}
	return nil
}

var errStubSend = &stubErr{}

type stubErr struct{}

func (*stubErr) Error() string { return "stub send failure" }

// newAuthServer is newTestServer with an operator password configured, so RequireAuth is live.
func newAuthServer(t *testing.T) (*Server, *store.Store) {
	t.Helper()
	s, st := newTestServer(t)
	h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
	if err != nil {
		t.Fatal(err)
	}
	s.configPasswordHash = string(h)
	return s, st
}
