package web

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

	"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"
)

// Guest launcher share (v0.165.0). These tests drive the pure core (token match, guest-app mapping),
// the standalone templates, and the real routes through the production mux composition (fullMux =
// RequireAuth + CsrfProtect + ServeHTTP, exactly as main.go wires it). Each security item carries a
// companion red-proof recorded in REPORT.

// shareTestServer builds a CLAIMED box (admin password set → authEnabled, claim gate off) with a
// SessionSecret, so the /s/ pre-auth pass-through and the admin-auth gate on /launcher/share/* are
// both exercised as they run in production.
func shareTestServer(t *testing.T) *Server {
	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 = "demo-felhom.eu"
	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-share"
	ph, _ := bcrypt.GenerateFromPassword([]byte("admin-pw-123456"), bcrypt.MinCost)
	cfg.Web.PasswordHash = string(ph)

	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: "9.9.9-test",
		sessions:      map[string]*session{},
		loginAttempts: map[string]*loginAttempt{},
		shareAttempts: map[string]*loginAttempt{}}
	s.loadTemplates()
	return s
}

const testShareToken = "TESTTOKEN0AAAAAAAAAAAAAAAAAA"

// ── Group B core: constant-time token match (Scenario B) ──────────────────────────────────────────
// COMPANION red-proof (REPORT): replace subtle.ConstantTimeCompare with strings.HasPrefix(presented,
// stored) → the superstring case ("abcd" vs stored "abc") is accepted and this test FAILS.
func TestShareTokenMatches(t *testing.T) {
	if !shareTokenMatches("abc", "abc") {
		t.Error("exact match must pass")
	}
	if shareTokenMatches("", "") || shareTokenMatches("", "anything") {
		t.Error("an empty stored token must never match (sharing disabled)")
	}
	if shareTokenMatches("abc", "ab") {
		t.Error("a prefix must not match")
	}
	if shareTokenMatches("abc", "abcd") {
		t.Error("a superstring must not match")
	}
	if shareTokenMatches("abc", "abx") {
		t.Error("a different token must not match")
	}
}

func TestNewShareToken_EntropyAndCharset(t *testing.T) {
	a, err := newShareToken()
	if err != nil {
		t.Fatal(err)
	}
	b, _ := newShareToken()
	if a == b {
		t.Error("two tokens collided — not random")
	}
	if len(a) != 27 { // 20 bytes → base64.RawURLEncoding = 27 chars
		t.Errorf("token length = %d, want 27 (160 bits, no padding)", len(a))
	}
	if strings.ContainsAny(a, "+/=") {
		t.Errorf("token %q contains non-URL-safe chars", a)
	}
}

// ── Group A: guest happy path — headers triple, tiles, no admin chrome (Scenario A) ───────────────
// COMPANION red-proof (REPORT): render the guest page through the admin layout template → the
// no-"nav-links"/no-version absence assertions FAIL.
func TestShareGuest_HeadersTilesNoAdminChrome(t *testing.T) {
	s := shareTestServer(t)
	if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
		t.Fatal(err)
	}
	writeStack(t, s.cfg.Paths.StacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true)
	_ = s.stackMgr.ScanStacks()

	rr := httptest.NewRecorder()
	s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
	if rr.Code != http.StatusOK {
		t.Fatalf("GET /s/<token> = %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
		t.Errorf("X-Robots-Tag = %q", got)
	}
	if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
		t.Errorf("Referrer-Policy = %q", got)
	}
	if got := rr.Header().Get("Cache-Control"); got != "no-store" {
		t.Errorf("Cache-Control = %q", got)
	}
	body := rr.Body.String()
	if !strings.Contains(body, "Receptek") {
		t.Error("guest page must render the openable app tile")
	}
	for _, chrome := range []string{`class="sidebar"`, "nav-links", "/logout", "9.9.9-test", "alert-banner"} {
		if strings.Contains(body, chrome) {
			t.Errorf("guest page leaked admin chrome: %q", chrome)
		}
	}
}

// ── Group B: wrong / disabled / empty token → byte-identical to the mux default 404 (Scenario B) ──
func TestShareGuest_WrongTokenIs404LikeDefault(t *testing.T) {
	s := shareTestServer(t)
	if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
		t.Fatal(err)
	}
	// Reference: the mux default case. /s/ bypasses auth, so both reach ServeHTTP's switch directly;
	// calling ServeHTTP is the apples-to-apples comparison against the default 404 branch.
	ref := httptest.NewRecorder()
	s.ServeHTTP(ref, httptest.NewRequest(http.MethodGet, "/no-such-route-xyz", nil))
	wantCode, wantBody := ref.Code, ref.Body.String()

	cases := []string{"/s/WRONGTOKEN", "/s/"}
	for _, p := range cases {
		rr := httptest.NewRecorder()
		s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
		if rr.Code != wantCode || rr.Body.String() != wantBody {
			t.Errorf("GET %s = %d %q; want default-404 %d %q", p, rr.Code, rr.Body.String(), wantCode, wantBody)
		}
	}
	// Disabled (empty stored token): the previously-valid token now 404s identically.
	if err := s.settings.SetLauncherShareToken(""); err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
	if rr.Code != wantCode || rr.Body.String() != wantBody {
		t.Errorf("disabled-token GET = %d %q; want default-404 %d %q", rr.Code, rr.Body.String(), wantCode, wantBody)
	}
}

// sharePOST posts the guest password form with a valid pre-auth HMAC CSRF pair (form field + cookie).
func (s *Server) sharePOST(mux http.Handler, path, password string, extra ...*http.Cookie) *httptest.ResponseRecorder {
	csrf := s.shareCSRFToken()
	form := url.Values{"_csrf": {csrf}, "password": {password}}
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf})
	for _, c := range extra {
		req.AddCookie(c)
	}
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, req)
	return rr
}

func cookieNamed(rr *httptest.ResponseRecorder, name string) *http.Cookie {
	for _, c := range rr.Result().Cookies() {
		if c.Name == name {
			return c
		}
	}
	return nil
}

// ── Group C: optional password gate (Scenario C) ──────────────────────────────────────────────────
// COMPANION red-proof (REPORT): drop passwordHash from shareCookieValue's HMAC input → the
// "changing the password invalidates the cookie" assertion FAILS.
func TestShareGuest_PasswordGate(t *testing.T) {
	s := shareTestServer(t)
	mux := s.fullMux()
	s.settings.SetLauncherShareToken(testShareToken)
	hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost)
	s.settings.SetLauncherSharePasswordHash(string(hash))
	path := "/s/" + testShareToken

	// GET with no cookie → the password gate, not the launcher.
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
	if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Ez az oldal jelszóval védett") {
		t.Fatalf("expected password gate, got %d: %s", rr.Code, rr.Body.String())
	}

	// 5 wrong attempts → "Hibás jelszó"; the 6th within the window → rate-limited.
	for i := 0; i < 5; i++ {
		rr := s.sharePOST(mux, path, "wrong")
		if !strings.Contains(rr.Body.String(), "Hibás jelszó") {
			t.Fatalf("wrong attempt %d: want 'Hibás jelszó', got: %s", i+1, rr.Body.String())
		}
	}
	rr = s.sharePOST(mux, path, "wrong")
	if !strings.Contains(rr.Body.String(), "Túl sok sikertelen") {
		t.Fatalf("6th attempt must be rate-limited, got: %s", rr.Body.String())
	}

	// New IP, correct password → 303 + a signed gate cookie is set.
	s2 := shareTestServer(t)
	mux2 := s2.fullMux()
	s2.settings.SetLauncherShareToken(testShareToken)
	s2.settings.SetLauncherSharePasswordHash(string(hash))
	rr = s2.sharePOST(mux2, path, "guest-secret")
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("correct password: want 303, got %d: %s", rr.Code, rr.Body.String())
	}
	gate := cookieNamed(rr, shareCookieName)
	if gate == nil || gate.Value == "" {
		t.Fatal("correct password must set the signed gate cookie")
	}

	// The cookie lets a subsequent GET through directly (no gate).
	req := httptest.NewRequest(http.MethodGet, path, nil)
	req.AddCookie(gate)
	rr = httptest.NewRecorder()
	mux2.ServeHTTP(rr, req)
	if strings.Contains(rr.Body.String(), "jelszóval védett") {
		t.Error("a valid gate cookie must skip the password page")
	}

	// Changing the password invalidates the outstanding cookie (it binds the hash).
	newHash, _ := bcrypt.GenerateFromPassword([]byte("new-secret"), bcrypt.MinCost)
	s2.settings.SetLauncherSharePasswordHash(string(newHash))
	req = httptest.NewRequest(http.MethodGet, path, nil)
	req.AddCookie(gate)
	rr = httptest.NewRecorder()
	mux2.ServeHTTP(rr, req)
	if !strings.Contains(rr.Body.String(), "jelszóval védett") {
		t.Error("changing the password must invalidate the old gate cookie")
	}
}

// ── Group D: rotation + disable (Scenario D) ──────────────────────────────────────────────────────
func TestShareGuest_RotateAndDisable(t *testing.T) {
	s := shareTestServer(t)
	s.settings.SetLauncherShareToken(testShareToken)
	hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
	s.settings.SetLauncherSharePasswordHash(string(hash))
	oldGate := &http.Cookie{Name: shareCookieName, Value: s.shareCookieValue(testShareToken, string(hash))}

	// Rotate: mint a new token; the old one 404s, the old gate cookie no longer passes.
	newTok, _ := newShareToken()
	s.settings.SetLauncherShareToken(newTok)

	rr := httptest.NewRecorder()
	s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
	if rr.Code != http.StatusNotFound {
		t.Errorf("old token after rotation: want 404, got %d", rr.Code)
	}
	// New token + old cookie → the cookie is bound to the OLD token, so the gate re-prompts.
	req := httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil)
	req.AddCookie(oldGate)
	rr = httptest.NewRecorder()
	s.ServeHTTP(rr, req)
	if !strings.Contains(rr.Body.String(), "jelszóval védett") {
		t.Error("rotation must invalidate the old gate cookie")
	}

	// Disable: every /s/ path 404s.
	s.settings.SetLauncherShareToken("")
	rr = httptest.NewRecorder()
	s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil))
	if rr.Code != http.StatusNotFound {
		t.Errorf("after disable: want 404, got %d", rr.Code)
	}
}

// ── Group E: guest state labels (Scenario E) ──────────────────────────────────────────────────────
func TestBuildGuestApps_Labels(t *testing.T) {
	apps := []LauncherApp{
		{Name: "a", DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
		{Name: "b", DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
		{Name: "c", DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
		{Name: "d", DisplayName: "Delta", Slug: "d", Subdomain: "d", State: stacks.StateDegraded},
	}
	g := buildGuestApps(apps, "demo-felhom.eu")
	if !g[0].Clickable || g[0].Href != "https://a.demo-felhom.eu" {
		t.Errorf("running app must be clickable with a public href, got %+v", g[0])
	}
	if g[1].Clickable || g[1].Label != "A tulajdonos leállította" {
		t.Errorf("stopped app: want greyed 'A tulajdonos leállította', got %+v", g[1])
	}
	if g[2].Clickable || g[2].Label != "Átmenetileg nem elérhető" {
		t.Errorf("exited app: want 'Átmenetileg nem elérhető', got %+v", g[2])
	}
	if g[3].Clickable || g[3].Label != "Átmenetileg nem elérhető" {
		t.Errorf("degraded app: want 'Átmenetileg nem elérhető', got %+v", g[3])
	}
}

// The rendered guest page shows the calm labels and NEVER the internal state vocabulary.
func TestShareGuestTemplate_LabelsNoInternalWords(t *testing.T) {
	g := buildGuestApps([]LauncherApp{
		{DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
		{DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
		{DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
	}, "demo-felhom.eu")
	html := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": g})
	if !strings.Contains(html, "A tulajdonos leállította") || !strings.Contains(html, "Átmenetileg nem elérhető") {
		t.Error("guest labels missing from rendered page")
	}
	for _, word := range []string{"stopped", "exited", "degraded", "unhealthy"} {
		if strings.Contains(html, word) {
			t.Errorf("internal state word %q leaked to the guest page", word)
		}
	}
	// Empty state.
	empty := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": []GuestLauncherApp{}})
	if !strings.Contains(empty, "Jelenleg nincs elérhető alkalmazás.") {
		t.Error("empty guest launcher must show the calm empty-state copy")
	}
}

// ── Group F: claim gate supreme + admin surfaces stay admin (Scenario F) ──────────────────────────
func TestShare_ClaimGateInterceptsGuestPage(t *testing.T) {
	s, _, _ := claimTestServer(t) // unclaimed: claim code, no password → claimGateActive
	s.settings.SetLauncherShareToken(testShareToken)
	rr := httptest.NewRecorder()
	s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
	if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
		t.Errorf("on an unclaimed box /s/<token> must hit the claim gate: got %d loc=%q", rr.Code, rr.Header().Get("Location"))
	}
}

func TestShare_AdminSurfacesRequireAuthAndCSRF(t *testing.T) {
	s := shareTestServer(t)
	mux := s.fullMux()

	// Unauthenticated QR + share POSTs → login redirect.
	for _, tc := range []struct {
		method, path string
	}{
		{http.MethodGet, "/launcher/share/qr.png"},
		{http.MethodPost, "/launcher/share/enable"},
		{http.MethodPost, "/launcher/share/rotate"},
		{http.MethodPost, "/launcher/share/disable"},
	} {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
		if rr.Code != http.StatusFound || !strings.HasPrefix(rr.Header().Get("Location"), "/login") {
			t.Errorf("%s %s unauthenticated: want login redirect, got %d loc=%q", tc.method, tc.path, rr.Code, rr.Header().Get("Location"))
		}
	}

	// Authenticated but CSRF-missing POST → 403.
	sessTok := s.createSession()
	req := httptest.NewRequest(http.MethodPost, "/launcher/share/enable", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessTok})
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, req)
	if rr.Code != http.StatusForbidden {
		t.Errorf("CSRF-missing admin POST: want 403, got %d", rr.Code)
	}
}

// ── Group G: the token never reaches the logs (Scenario G) ────────────────────────────────────────
// COMPANION red-proof (REPORT): revert the /s/ redaction in ServeHTTP (log the raw path) → the
// "token absent from logs" assertion FAILS.
func TestShareGuest_TokenNeverLogged(t *testing.T) {
	s := shareTestServer(t)
	var buf bytes.Buffer
	s.logger = log.New(&buf, "", 0)
	s.cfg.Logging.Level = "debug"
	s.settings.SetLauncherShareToken(testShareToken)

	// A valid GET (debug ServeHTTP line) and a wrong-token 404 must both keep the token out of logs.
	for _, p := range []string{"/s/" + testShareToken, "/s/WRONGSECRET123"} {
		rr := httptest.NewRecorder()
		s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
	}
	logs := buf.String()
	if strings.Contains(logs, testShareToken) || strings.Contains(logs, "WRONGSECRET123") {
		t.Errorf("a share token leaked into the logs:\n%s", logs)
	}
	if !strings.Contains(logs, "/s/<redacted>") {
		t.Errorf("expected a redacted /s/<redacted> path in the debug log, got:\n%s", logs)
	}
}
