package web

import (
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
	"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
	"golang.org/x/crypto/bcrypt"
)

// claimTestServer builds a Server with a claim code installed (unclaimed, no password) and the
// full mux (RequireAuth+CsrfProtect wired exactly as main.go does), so route-level gating is
// exercised end-to-end. Returns the server, the plaintext code, and the settings.
func claimTestServer(t *testing.T) (*Server, string, *settings.Settings) {
	t.Helper()
	lg := log.New(io.Discard, "", 0)
	dir := t.TempDir()
	cfg := &config.Config{}
	cfg.Customer.ID = "c1"
	cfg.Customer.Name = "Teszt"
	cfg.Customer.Domain = "example.hu"
	cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
	cfg.Paths.DataDir = filepath.Join(dir, "data")
	cfg.Stacks.ComposeCommand = "docker compose"
	cfg.Web.SessionSecret = "test-session-secret-abcdef"

	sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
	if err != nil {
		t.Fatalf("settings: %v", err)
	}
	mgr, err := stacks.NewManager(cfg, lg)
	if err != nil {
		t.Fatalf("stacks: %v", err)
	}
	s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
	s.loadTemplates()

	code := "alma-korte-szilva"
	hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
	if err := sett.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("SetClaimCode: %v", err)
	}
	return s, code, sett
}

// fullMux replicates main.go's handler composition so the gate is tested where it actually runs.
func (s *Server) fullMux() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
	mux.Handle("/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeHTTP))))
	return mux
}

// §10 THE SIGNATURE TEST — every route of an unclaimed box (with a code hash) answers the claim
// page (redirect to /claim) or a 401 JSON; NOTHING else is reachable, and a mutating POST reaches
// NO handler. Red-proof: remove the claim-gate block in RequireAuth → these assertions fail.
func TestClaimGate_EveryRouteGated(t *testing.T) {
	s, _, _ := claimTestServer(t)
	mux := s.fullMux()

	// A representative sweep of the real route surface (pages + APIs + a mutating deploy POST).
	htmlRoutes := []string{"/", "/dashboard", "/stacks", "/backups", "/monitoring", "/settings", "/settings/security", "/storage", "/apps/vaultwarden", "/import", "/debug"}
	for _, p := range htmlRoutes {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
		if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
			t.Errorf("GET %s: got %d loc=%q, want 302→/claim", p, rr.Code, rr.Header().Get("Location"))
		}
	}

	apiRoutes := []string{"/api/disks", "/api/storage/x", "/api/host-metrics", "/api/backup/restore-status"}
	for _, p := range apiRoutes {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
		if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), "not yet claimed") {
			t.Errorf("GET %s: got %d body=%q, want 401 not-yet-claimed", p, rr.Code, rr.Body.String())
		}
	}

	// A mutating deploy POST must be REFUSED before any handler runs (401, no side effect).
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/stacks/vaultwarden/deploy", strings.NewReader("{}")))
	if rr.Code != http.StatusUnauthorized {
		t.Errorf("POST deploy on unclaimed box: got %d, want 401 (no mutation reachable)", rr.Code)
	}

	// The claim page + its assets + health ARE reachable.
	for _, p := range []string{"/claim", "/api/health", "/static/style.css"} {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
		if rr.Code != http.StatusOK {
			t.Errorf("GET %s on unclaimed box: got %d, want 200 (allowed)", p, rr.Code)
		}
	}
}

// The happy-path claim: correct code + password → password set, claimed, code consumed, session
// issued; a second use of the SAME code is refused (single-use via consumed generation).
func TestClaimSubmit_HappyPathThenReuseRefused(t *testing.T) {
	s, code, sett := claimTestServer(t)

	do := func(codeVal, pw string) *httptest.ResponseRecorder {
		form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {codeVal}, "new_password": {pw}, "confirm_password": {pw}}
		req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
		rr := httptest.NewRecorder()
		s.handleClaimSubmit(rr, req)
		return rr
	}

	rr := do(code, "a-strong-passphrase-12")
	if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" {
		t.Fatalf("claim submit: got %d loc=%q, want 302→/", rr.Code, rr.Header().Get("Location"))
	}
	if !sett.GetClaimed() {
		t.Fatal("box not marked claimed after a successful claim")
	}
	if !s.authEnabled() {
		t.Fatal("password not set after claim (authEnabled false)")
	}
	if s.claimGateActive() {
		t.Fatal("gate still active after claim")
	}
	if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("a-strong-passphrase-12")) != nil {
		t.Fatal("stored password does not verify the chosen password")
	}
	// A session cookie was issued.
	if len(rr.Result().Cookies()) == 0 {
		t.Fatal("no session cookie issued on claim")
	}

	// Reuse the SAME code (generation 1, now consumed) → refused even though the hash matches.
	rr = do(code, "another-strong-pass-12")
	if rr.Code == http.StatusFound {
		t.Fatal("consumed code was accepted again — single-use broken")
	}
	if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
		t.Errorf("reuse should show the wrong/expired-code error, body=%q", claimFirstLine(rr.Body.String()))
	}
}

// Wrong codes lock the endpoint after 5 attempts (fake clock); the window then reopens.
func TestClaimSubmit_LockoutAndWindowReopen(t *testing.T) {
	s, _, _ := claimTestServer(t)
	now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
	s.claimClock = func() time.Time { return now }

	submitWrong := func() *httptest.ResponseRecorder {
		form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {"wrong-wrong-wrong"}, "new_password": {"x-really-long-pass"}, "confirm_password": {"x-really-long-pass"}}
		req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.RemoteAddr = "203.0.113.7:5000"
		req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
		rr := httptest.NewRecorder()
		s.handleClaimSubmit(rr, req)
		return rr
	}

	for i := 0; i < claimMaxAttempts; i++ {
		submitWrong()
	}
	// The 6th (post-cap) attempt is locked out.
	rr := submitWrong()
	if !strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
		t.Fatalf("expected lockout after %d failures, body=%q", claimMaxAttempts, claimFirstLine(rr.Body.String()))
	}
	// Advance past the window → unlocked (a wrong code shows the normal error again, not lockout).
	now = now.Add(claimLockoutWindow + time.Minute)
	rr = submitWrong()
	if strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
		t.Fatal("still locked after the window elapsed")
	}
	if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
		t.Errorf("post-window wrong code should show the normal error, body=%q", claimFirstLine(rr.Body.String()))
	}
}

// An expired code (issued > 72h ago) is refused.
func TestClaimSubmit_ExpiredCodeRefused(t *testing.T) {
	s, code, sett := claimTestServer(t)
	// Re-issue the code with an old issued_at.
	hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
	sett.SetClaimCode(string(hash), 2, time.Now().Add(-73*time.Hour).UTC().Format(time.RFC3339))

	form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"}}
	req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
	rr := httptest.NewRecorder()
	s.handleClaimSubmit(rr, req)
	if rr.Code == http.StatusFound {
		t.Fatal("expired code was accepted")
	}
	if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
		t.Errorf("expected expired-code error, body=%q", claimFirstLine(rr.Body.String()))
	}
}

// Legacy-open (no password, no code hash) passes through with the red banner flag; a box with a
// set password is entirely ungated (no claim page ever).
func TestClaimGate_LegacyOpenAndPasswordSet(t *testing.T) {
	// Legacy-open: fresh server, no claim code, no password.
	lg := log.New(io.Discard, "", 0)
	dir := t.TempDir()
	cfg := &config.Config{}
	cfg.Customer.Domain = "example.hu"
	cfg.Paths.StacksDir = filepath.Join(dir, "s")
	cfg.Paths.DataDir = filepath.Join(dir, "d")
	cfg.Stacks.ComposeCommand = "docker compose"
	sett, _ := settings.Load(filepath.Join(dir, "settings.json"), lg)
	mgr, _ := stacks.NewManager(cfg, lg)
	s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
	s.loadTemplates()

	if s.claimGateActive() {
		t.Fatal("no code hash → gate must NOT be active (legacy-open)")
	}
	if !s.claimLegacyOpen() {
		t.Fatal("no password + no code → expected legacy-open")
	}

	// Password set → neither gated nor legacy-open.
	pw, _ := bcrypt.GenerateFromPassword([]byte("existing-strong-pass"), 10)
	sett.SetPasswordHash(string(pw))
	if s.claimGateActive() || s.claimLegacyOpen() {
		t.Fatal("a set password must disable both the gate and the legacy banner")
	}
}

func claimFirstLine(s string) string {
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		return s[:i]
	}
	return s
}
