package web

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

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

// Escrow wizard handler tests (v0.127.0). The agent + staging seams record CALL ORDER — the
// load-bearing Scenario A/B assertion is "re-stage happened BEFORE the agent trigger", and every
// Scenario E gate must exit with the agent NEVER called.

const wizardPassword = "titkos-jelszo"

type fakeEscrowAgent struct {
	order       *[]string // shared call-order log (harness-owned)
	version     string
	startResp   agentapi.EscrowCeremonyStartResponse
	startStatus int
	startErr    error
	status      agentapi.EscrowCeremonyStatusResponse
	claimCode   string
	claimStatus int
	claimErr    error
	pf          agentapi.EscrowPreflightResponse
}

func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) {
	*f.order = append(*f.order, "preflight")
	return f.pf, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) {
	*f.order = append(*f.order, "start")
	return f.startResp, f.startStatus, f.startErr
}
func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) {
	return f.status, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) {
	*f.order = append(*f.order, "claim")
	return f.claimCode, f.claimStatus, f.claimErr
}
func (f *fakeEscrowAgent) AgentVersion() string { return f.version }

type escrowWizardHarness struct {
	s     *Server
	sett  *settings.Settings
	m     *backup.Manager
	agent *fakeEscrowAgent
	order []string
}

func newEscrowWizardHarness(t *testing.T) *escrowWizardHarness {
	t.Helper()
	tmp := t.TempDir()
	lg := log.New(io.Discard, "", 0)
	sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
	if err != nil {
		t.Fatal(err)
	}
	cfg := &config.Config{}
	cfg.Paths.DataDir = tmp
	hash, err := bcrypt.GenerateFromPassword([]byte(wizardPassword), bcrypt.MinCost)
	if err != nil {
		t.Fatal(err)
	}
	cfg.Web.PasswordHash = string(hash)
	h := &escrowWizardHarness{
		sett: sett,
		m:    backup.NewManager(cfg, sett, lg),
	}
	h.agent = &fakeEscrowAgent{
		order:       &h.order,
		version:     "0.88.0",
		startResp:   agentapi.EscrowCeremonyStartResponse{JobID: "escrow-1", Phase: "running"},
		startStatus: http.StatusAccepted,
	}
	h.s = &Server{cfg: cfg, backupMgr: h.m, settings: sett, logger: lg}
	h.s.escrowAgentFn = func() (escrowAgent, error) { return h.agent, nil }
	h.s.escrowStageFn = func(context.Context) error { h.order = append(h.order, "stage"); return nil }
	return h
}

// configureOffbox makes OffboxConfigured() true with the given escrow state.
func (h *escrowWizardHarness) configureOffbox(t *testing.T, state string) {
	t.Helper()
	if err := h.m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
		t.Fatal(err)
	}
	if err := h.sett.SetOffboxTarget(&settings.OffboxTarget{
		Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
		Schedule: "daily", EscrowState: state,
	}); err != nil {
		t.Fatal(err)
	}
	if !h.m.OffboxConfigured() {
		t.Fatal("setup: offbox should be configured")
	}
}

func postStart(t *testing.T, s *Server, password string) *httptest.ResponseRecorder {
	t.Helper()
	form := url.Values{"password": {password}}
	r := httptest.NewRequest("POST", "/api/escrow/start", strings.NewReader(form.Encode()))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	w := httptest.NewRecorder()
	s.escrowStartAPIHandler(w, r)
	return w
}

// Scenario A — pending offsite happy path: correct password → re-stage FIRST, then the agent
// trigger; 200 with the job id.
func TestEscrowStart_StagesBeforeTrigger(t *testing.T) {
	h := newEscrowWizardHarness(t)
	h.configureOffbox(t, "pending")

	w := postStart(t, h.s, wizardPassword)
	if w.Code != http.StatusOK {
		t.Fatalf("start: got %d (%s)", w.Code, w.Body.String())
	}
	if got := strings.Join(h.order, ","); got != "stage,start" {
		t.Fatalf("call order = %q, want stage BEFORE start (a ceremony without the staged secret mints a hash-less blob)", got)
	}
	if !strings.Contains(w.Body.String(), "escrow-1") {
		t.Fatalf("response lacks the job id: %s", w.Body.String())
	}
}

// Scenario B — re-ceremony from ESCROWED state stages too (the same order assertion).
func TestEscrowStart_ReceremonyStagesToo(t *testing.T) {
	h := newEscrowWizardHarness(t)
	h.configureOffbox(t, "escrowed")

	if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
		t.Fatalf("re-ceremony start: got %d", w.Code)
	}
	if got := strings.Join(h.order, ","); got != "stage,start" {
		t.Fatalf("re-ceremony call order = %q, want stage,start", got)
	}
}

// Scenario C — no offbox configured: NO staging attempted; the ceremony still runs.
func TestEscrowStart_NoOffboxSkipsStaging(t *testing.T) {
	h := newEscrowWizardHarness(t)

	if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
		t.Fatalf("start: got %d", w.Code)
	}
	if got := strings.Join(h.order, ","); got != "start" {
		t.Fatalf("call order = %q, want start only (no staging without offbox)", got)
	}
}

// Scenario E — every gate refuses BEFORE the agent (and staging) is touched.
func TestEscrowStart_SecurityGates(t *testing.T) {
	t.Run("wrong password", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.configureOffbox(t, "pending")
		w := postStart(t, h.s, "rossz-jelszo")
		if w.Code != http.StatusUnauthorized {
			t.Fatalf("wrong password: got %d, want 401", w.Code)
		}
		if len(h.order) != 0 {
			t.Fatalf("agent/staging touched despite failed re-auth: %v", h.order)
		}
		// The failure rides the LOGIN rate limiter.
		h.s.loginAttemptMu.Lock()
		var count int
		for _, a := range h.s.loginAttempts {
			count += a.count
		}
		h.s.loginAttemptMu.Unlock()
		if count != 1 {
			t.Fatalf("wrong password must increment the login rate-limit counter, got %d", count)
		}
	})
	t.Run("rate limited after max attempts", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		for i := 0; i < loginMaxAttempts; i++ {
			postStart(t, h.s, "rossz-jelszo")
		}
		w := postStart(t, h.s, wizardPassword) // even the CORRECT password is refused inside the window
		if w.Code != http.StatusTooManyRequests {
			t.Fatalf("rate limit: got %d, want 429", w.Code)
		}
		if len(h.order) != 0 {
			t.Fatalf("agent touched while rate-limited: %v", h.order)
		}
	})
	t.Run("passwordless box refused", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.s.cfg.Web.PasswordHash = ""
		w := postStart(t, h.s, "")
		if w.Code != http.StatusForbidden {
			t.Fatalf("passwordless: got %d, want 403", w.Code)
		}
		if len(h.order) != 0 {
			t.Fatal("agent touched on a passwordless box")
		}
	})
	t.Run("stage failure aborts before trigger", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.configureOffbox(t, "pending")
		h.s.escrowStageFn = func(context.Context) error { return fmt.Errorf("agent down") }
		w := postStart(t, h.s, wizardPassword)
		if w.Code != http.StatusBadGateway {
			t.Fatalf("stage failure: got %d, want 502", w.Code)
		}
		for _, c := range h.order {
			if c == "start" {
				t.Fatal("the ceremony started despite the failed staging (would mint a hash-less blob)")
			}
		}
	})
	t.Run("agent too old", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.version = "0.87.0"
		w := postStart(t, h.s, wizardPassword)
		if w.Code != http.StatusConflict {
			t.Fatalf("old agent: got %d, want 409", w.Code)
		}
		for _, c := range h.order {
			if c == "start" {
				t.Fatal("an old agent must not be triggered")
			}
		}
	})
	t.Run("header-less agent counts as older", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.version = ""
		if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusConflict {
			t.Fatalf("version-less agent: got %d, want 409", w.Code)
		}
	})
	t.Run("ceremony already running", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.startStatus = http.StatusConflict
		h.agent.startErr = fmt.Errorf("agentapi: POST /escrow/ceremony: HTTP 409: already running")
		w := postStart(t, h.s, wizardPassword)
		if w.Code != http.StatusConflict {
			t.Fatalf("busy agent: got %d, want 409", w.Code)
		}
		if !strings.Contains(w.Body.String(), "folyamatban") {
			t.Fatalf("busy refusal must speak Hungarian: %s", w.Body.String())
		}
	})
}

// Scenario D (controller half) — the claim proxy: no-store on 200, the 410 void message, and the
// code appears ONLY in the claim response.
func TestEscrowClaim_ProxySemantics(t *testing.T) {
	const code = "proba-kod-tiz-szo"
	t.Run("success", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.claimCode = code
		h.agent.claimStatus = http.StatusOK
		w := httptest.NewRecorder()
		h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
		if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), code) {
			t.Fatalf("claim: got %d %s", w.Code, w.Body.String())
		}
		if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
			t.Fatalf("claim Cache-Control = %q, want no-store", cc)
		}
	})
	t.Run("gone", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.claimStatus = http.StatusGone
		h.agent.claimErr = fmt.Errorf("gone")
		w := httptest.NewRecorder()
		h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
		if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "újra nem kérhető le") {
			t.Fatalf("gone: got %d %s", w.Code, w.Body.String())
		}
	})
	// Scenario I (F-C fix): agent 404 "no ceremony has run" → a clean 404, NOT 502.
	t.Run("no_active_ceremony_404_not_502", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.claimStatus = http.StatusNotFound
		h.agent.claimErr = fmt.Errorf("no ceremony has run")
		w := httptest.NewRecorder()
		h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
		if w.Code != http.StatusNotFound {
			t.Fatalf("no-ceremony claim must be 404, got %d %s", w.Code, w.Body.String())
		}
		if !strings.Contains(w.Body.String(), "Nincs aktív helyreállítási folyamat") {
			t.Fatalf("expected honest Hungarian no-ceremony message, got %s", w.Body.String())
		}
	})
	// Scenario J (regression): agent 409 → passed through as 409, not 502.
	t.Run("conflict_409", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.claimStatus = http.StatusConflict
		h.agent.claimErr = fmt.Errorf("conflict")
		w := httptest.NewRecorder()
		h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
		if w.Code != http.StatusConflict {
			t.Fatalf("409 must pass through, got %d", w.Code)
		}
	})
	// Scenario K (regression): a genuinely-unreachable agent (status 0) stays 502 — that IS a real
	// bad gateway; the fix must NOT over-correct it to a 4xx.
	t.Run("unreachable_stays_502", func(t *testing.T) {
		h := newEscrowWizardHarness(t)
		h.agent.claimStatus = 0
		h.agent.claimErr = fmt.Errorf("dial tcp: connection refused")
		w := httptest.NewRecorder()
		h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
		if w.Code != http.StatusBadGateway {
			t.Fatalf("unreachable agent must stay 502, got %d", w.Code)
		}
	})
}

// The status proxy relays the agent's non-secret job view verbatim.
func TestEscrowStatus_Proxy(t *testing.T) {
	h := newEscrowWizardHarness(t)
	h.agent.status = agentapi.EscrowCeremonyStatusResponse{
		Phase: "done", JobID: "escrow-1", ResticPwSealed: true, Uploaded: true,
		Claimable: true, ClaimExpiresInSec: 599,
	}
	w := httptest.NewRecorder()
	h.s.escrowStatusAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/status", nil))
	body := w.Body.String()
	for _, want := range []string{`"phase":"done"`, `"claimable":true`, `"restic_pw_sealed":true`} {
		if !strings.Contains(body, want) {
			t.Fatalf("status proxy missing %s: %s", want, body)
		}
	}
}

// The wizard page + card render states (Hungarian copy, no server-side R anywhere by
// construction — the template has no code variable to leak).
func TestEscrowTemplates_Render(t *testing.T) {
	base := func() map[string]interface{} {
		return map[string]interface{}{
			"Title": "Helyreállítási kód", "Domain": "example.hu",
			"AgentSupported": true, "Receremony": false, "OffboxConfigured": true,
		}
	}
	t.Run("wizard fresh", func(t *testing.T) {
		html := renderBackupPage(t, "backups_escrow", base())
		for _, want := range []string{
			"Előfeltételek ellenőrzése",
			"a mentései utolsó kulcsa",
			"A folytatáshoz adja meg a bejelentkezési jelszavát",
			"Kód létrehozása",
			"Kód megjelenítése",
			// v0.127.3 (Viktor): the reveal states EXPLICITLY that the shown code is already the
			// live one (the supersede happened at upload, before display).
			"Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.",
			// The pre-generation cancel must stay next to the start button (nothing runs until then).
			`>Mégsem</a>`,
			"nem ezen a szerveren",
			"biztonsági okból újra nem kérhető le",
			// v0.127.1 polish: the Hungarian preflight detail map + the hide/show toggle
			// (v0.127.2: manual-only — initial label "Elrejtés", code visible by default).
			"nincs előkészített jelszó — a varázsló indításkor automatikusan előkészíti",
			"a rendszerjogosultság hiányzik",
			"Megjelenítés",
			">Elrejtés</button>",
		} {
			if !strings.Contains(html, want) {
				t.Errorf("wizard missing %q", want)
			}
		}
		// v0.127.2 (Viktor's live finding): the code must NOT auto-blur when a verification input
		// gets focus — the customer types from the screen. The hide toggle is manual-only.
		if strings.Contains(html, "armCodeBlur") || strings.Contains(html, "onfocus=") {
			t.Error("auto-blur-on-focus must be gone (manual toggle only)")
		}
		if strings.Contains(html, "érvényét veszti") {
			t.Error("fresh wizard must not show the re-ceremony supersede warning")
		}
		// v0.127.1: the agent's operator-English detail strings must never be hardcoded into the
		// page — they may only arrive at runtime, inside the muted failure-diagnostic span.
		for _, raw := range []string{"DR tier applied", "hub upload target configured", "sudo grant listed"} {
			if strings.Contains(html, raw) {
				t.Errorf("raw English agent detail %q leaked into the wizard page source", raw)
			}
		}
	})
	t.Run("wizard re-ceremony variant", func(t *testing.T) {
		d := base()
		d["Receremony"] = true
		if html := renderBackupPage(t, "backups_escrow", d); !strings.Contains(html, "érvényét veszti") {
			t.Error("re-ceremony wizard must show the supersede warning")
		}
	})
	t.Run("wizard agent too old", func(t *testing.T) {
		d := base()
		d["AgentSupported"] = false
		html := renderBackupPage(t, "backups_escrow", d)
		if !strings.Contains(html, "az ügynök frissítése szükséges") {
			t.Error("old-agent wizard must show the version note")
		}
		if strings.Contains(html, "Kód létrehozása") {
			t.Error("old-agent wizard must be inert (no start form)")
		}
	})
	t.Run("remote card states", func(t *testing.T) {
		data := splitTestData()
		data["EscrowAgentOK"] = true
		data["EscrowStale"] = false
		html := renderBackupPage(t, "backups_remote", data) // escrowed clean
		if !strings.Contains(html, "Új helyreállítási kód készítése") {
			t.Error("escrowed card must offer the secondary re-ceremony link")
		}
		// v0.127.1: the CTA is a real secondary BUTTON (outline — available-not-urgent), no longer
		// an inline link buried in the muted hint.
		if !strings.Contains(html, `class="btn btn-sm btn-outline">Új helyreállítási kód készítése`) {
			t.Error("escrowed-card CTA must render as a btn-outline button")
		}
		if strings.Contains(html, "Letét megerősítése") {
			t.Error("the deprecated manual-confirm button must be GONE from the card")
		}

		data["EscrowStale"] = true
		html = renderBackupPage(t, "backups_remote", data)
		if !strings.Contains(html, "nem fedi a jelenlegi távoli mentési jelszót") {
			t.Error("stale card must show the exact stale warning")
		}

		pending := splitTestData()
		pending["EscrowAgentOK"] = true
		pending["EscrowStale"] = false
		pending["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
		html = renderBackupPage(t, "backups_remote", pending)
		if !strings.Contains(html, "Helyreállítási kód szükséges") || !strings.Contains(html, "Helyreállítási kód létrehozása") {
			t.Error("pending card must show the CTA state")
		}

		old := splitTestData()
		old["EscrowAgentOK"] = false
		old["EscrowStale"] = false
		old["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
		html = renderBackupPage(t, "backups_remote", old)
		if !strings.Contains(html, "az ügynök frissítése szükséges") || strings.Contains(html, `href="/backup/escrow"`) {
			t.Error("old-agent card must show the version note with NO CTA")
		}
	})
}

// The preflight proxy augments the agent checklist with the version gate + offbox facts.
func TestEscrowPreflight_Augments(t *testing.T) {
	h := newEscrowWizardHarness(t)
	h.configureOffbox(t, "escrowed")
	h.agent.pf = agentapi.EscrowPreflightResponse{OK: true, Items: []agentapi.EscrowPreflightItem{
		{ID: "pbs_storage_id", OK: true, Detail: "felhom-pbs"},
	}}
	w := httptest.NewRecorder()
	h.s.escrowPreflightAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
	body := w.Body.String()
	for _, want := range []string{`"agent_supported":true`, `"escrow_state":"escrowed"`, `"offbox_configured":true`, `"pbs_storage_id"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("preflight missing %s: %s", want, body)
		}
	}
	// An old agent flips both the flag and the aggregate ok.
	h.agent.version = "0.87.0"
	w2 := httptest.NewRecorder()
	h.s.escrowPreflightAPIHandler(w2, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
	if !strings.Contains(w2.Body.String(), `"agent_supported":false`) || !strings.Contains(w2.Body.String(), `"ok":false`) {
		t.Fatalf("old agent must flip agent_supported + ok: %s", w2.Body.String())
	}
}
