package web

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

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

// Controller-driven escrow ceremony wizard (v0.127.0, agent ≥ v0.88.0; mechanics validated by
// SPIKE-controller-escrow-2026-07-13). The customer runs the ceremony from /backup/escrow:
// preflight → warnings → password re-auth → the agent's detached root job → the ONE-SHOT R
// reveal (claim XHR only — R is NEVER templated server-side into HTML) → typed-back confirm.
//
// R-handling absolutes (§9 rule 4 of the task): R is never logged (either repo, any level,
// including the debug ring), never persisted, never placed in any payload except the claim XHR
// response (Cache-Control: no-store), never echoed in errors.

// minEscrowAgentVersion is the MinAgent for the ceremony endpoints (the v0.88.0 localapi job).
// Gate: AgentVersion() compare, HEADER ABSENT = OLDER (fail-closed — unlike the probe-based
// Supports, an unknown agent must not be triggered blind; the preflight/stage traffic populates
// the passive version header, so a live 0.88+ agent is always known by the time start runs).
const minEscrowAgentVersion = "0.88.0"

// escrowAgent is the narrow agent surface the wizard needs (*agentapi.Client satisfies it;
// tests inject fakes to assert call order and refusal short-circuits).
type escrowAgent interface {
	EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error)
	EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error)
	EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error)
	EscrowCeremonyClaim(ctx context.Context) (string, int, error)
	AgentVersion() string
}

// SetEscrowStale wires the Scenario-F stale-blob flag source (report.EscrowAutoConfirmer.StaleBlob).
// Init-time only, like every Set*.
func (s *Server) SetEscrowStale(fn func() bool) { s.escrowStaleFn = fn }

// escrowStale reads the stale-blob display flag (false when unwired).
func (s *Server) escrowStale() bool {
	return s.escrowStaleFn != nil && s.escrowStaleFn()
}

// escrowAgentConn resolves the agent surface (seam-first; default = the shared pinned client).
func (s *Server) escrowAgentConn() (escrowAgent, error) {
	if s.escrowAgentFn != nil {
		return s.escrowAgentFn()
	}
	return s.agentClient()
}

// escrowStage re-stages the CURRENT offbox repo password to the agent (re-stage-first: a ceremony
// without the staged secret would mint the forbidden hash-less blob). Seam-first for tests.
func (s *Server) escrowStage(ctx context.Context) error {
	if s.escrowStageFn != nil {
		return s.escrowStageFn(ctx)
	}
	client, err := s.agentClient()
	if err != nil {
		return err
	}
	return s.backupMgr.PushOffboxPasswordForEscrow(ctx, client.StageEscrowSecret)
}

// escrowAgentSupported reports whether ver (the passive X-Felhom-Agent-Version capture) is at
// least minEscrowAgentVersion. "" (header-less agent or no traffic yet) = OLDER — fail-closed.
func escrowAgentSupported(ver string) bool {
	if ver == "" {
		return false
	}
	av, err := util.ParseVersion(ver)
	if err != nil {
		return false
	}
	mv, err := util.ParseVersion(minEscrowAgentVersion)
	if err != nil {
		return false
	}
	return av.Compare(mv) >= 0
}

// escrowJSON writes the {ok,data,error} envelope the /api/* surface speaks.
func escrowJSON(w http.ResponseWriter, code int, data map[string]any, errMsg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(map[string]any{"ok": errMsg == "", "data": data, "error": errMsg})
}

// ServeEscrowAPI dispatches /api/escrow/* (session-authed + CSRF-protected at the mux, like the
// disk API).
func (s *Server) ServeEscrowAPI(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.URL.Path == "/api/escrow/preflight" && r.Method == http.MethodGet:
		s.escrowPreflightAPIHandler(w, r)
	case r.URL.Path == "/api/escrow/start" && r.Method == http.MethodPost:
		s.escrowStartAPIHandler(w, r)
	case r.URL.Path == "/api/escrow/status" && r.Method == http.MethodGet:
		s.escrowStatusAPIHandler(w, r)
	case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost:
		s.escrowClaimAPIHandler(w, r)
	default:
		escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont")
	}
}

// escrowWizardPageHandler renders /backup/escrow (GET). The page itself is no-store: it hosts
// the R reveal, and a cached copy of ANY wizard state is one copy too many.
func (s *Server) escrowWizardPageHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	offboxTgt := s.settings.GetOffboxTarget()
	escrowed := offboxTgt != nil && offboxTgt.EscrowState == "escrowed"
	agentVer := ""
	if agent, err := s.escrowAgentConn(); err == nil {
		agentVer = agent.AgentVersion()
	}
	data := map[string]interface{}{
		"Title":            "Helyreállítási kód",
		"CustomerName":     s.cfg.Customer.Name,
		"Domain":           s.cfg.Customer.Domain,
		"ActivePage":       "backups-remote",
		"Receremony":       escrowed || s.escrowStale(), // the supersede warning variant
		"OffboxConfigured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
		"AgentSupported":   escrowAgentSupported(agentVer),
	}
	s.executeTemplate(w, r, "backups_escrow", data)
}

// escrowPreflightAPIHandler proxies the agent checklist + the controller-side facts the wizard
// renders (agent version gate, offbox/escrow state for the supersede copy).
func (s *Server) escrowPreflightAPIHandler(w http.ResponseWriter, r *http.Request) {
	agent, err := s.escrowAgentConn()
	if err != nil {
		escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
		return
	}
	pf, err := agent.EscrowPreflight(r.Context())
	if err != nil {
		s.logger.Printf("[WARN] [web] escrow preflight: %v", err)
		escrowJSON(w, http.StatusBadGateway, nil, "Az előfeltételek ellenőrzése nem sikerült — az ügynök nem válaszol.")
		return
	}
	offboxTgt := s.settings.GetOffboxTarget()
	escrowState := ""
	if offboxTgt != nil {
		escrowState = offboxTgt.EscrowState
	}
	agentOK := escrowAgentSupported(agent.AgentVersion())
	escrowJSON(w, http.StatusOK, map[string]any{
		"ok": pf.OK && agentOK, "items": pf.Items,
		"agent_supported":   agentOK,
		"offbox_configured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
		"escrow_state":      escrowState,
		"stale":             s.escrowStale(),
	}, "")
}

// escrowStartAPIHandler is the wizard's run trigger. Order (load-bearing, Scenario A/E):
// (1) password re-auth — rides the LOGIN rate limiter; (2) re-stage-first when offbox is
// configured, ABORT on failure (a ceremony without the staged secret mints the forbidden
// hash-less blob); (3) agent version gate (the stage/preflight traffic has populated the passive
// header by now); (4) trigger the agent job. Every refusal exits BEFORE the agent is called.
func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()

	// (1) Re-auth. A passwordless box cannot re-auth — refuse (the claim gate normally prevents
	// this state; a legacy-open box must claim/set a password first).
	hash := s.effectivePasswordHash()
	if hash == "" {
		escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
		return
	}
	ip := clientIP(r)
	if s.escrowRateLimited(ip) {
		s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
		escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
		return
	}
	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil {
		s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr)
		s.recordEscrowAuthFailure(ip)
		escrowJSON(w, http.StatusUnauthorized, nil, "Hibás jelszó.")
		return
	}
	s.clearAuthFailures(ip)

	// (2) Re-stage-first (only when offsite is configured — Scenario C boxes skip it).
	if s.backupMgr != nil && s.backupMgr.OffboxConfigured() {
		if err := s.escrowStage(r.Context()); err != nil {
			s.logger.Printf("[WARN] [web] escrow start: re-stage failed (ceremony NOT started): %v", err) // err carries no secret
			escrowJSON(w, http.StatusBadGateway, nil, "A távoli mentés jelszavának letéti előkészítése nem sikerült — a folyamat nem indult el. Próbálja újra.")
			return
		}
	}

	// (3) Agent + version gate.
	agent, err := s.escrowAgentConn()
	if err != nil {
		escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
		return
	}
	if !escrowAgentSupported(agent.AgentVersion()) {
		escrowJSON(w, http.StatusConflict, nil, "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.")
		return
	}

	// (4) Trigger.
	resp, status, err := agent.EscrowCeremonyStart(r.Context())
	if err != nil {
		if status == http.StatusConflict {
			escrowJSON(w, http.StatusConflict, nil, "Egy kódkészítés már folyamatban van — várja meg, míg befejeződik.")
			return
		}
		s.logger.Printf("[WARN] [web] escrow start: agent trigger: %v", err)
		escrowJSON(w, http.StatusBadGateway, nil, "A folyamat indítása nem sikerült — az ügynök nem válaszol.")
		return
	}
	s.logger.Printf("[INFO] [web] escrow ceremony started via wizard (job %s)", resp.JobID)
	escrowJSON(w, http.StatusOK, map[string]any{"job_id": resp.JobID, "phase": resp.Phase}, "")
}

// escrowStatusAPIHandler proxies the NON-SECRET job status for the wizard's 2 s poll.
func (s *Server) escrowStatusAPIHandler(w http.ResponseWriter, r *http.Request) {
	agent, err := s.escrowAgentConn()
	if err != nil {
		escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
		return
	}
	st, err := agent.EscrowCeremonyStatus(r.Context())
	if err != nil {
		escrowJSON(w, http.StatusBadGateway, nil, "Az állapot lekérdezése nem sikerült.")
		return
	}
	escrowJSON(w, http.StatusOK, map[string]any{
		"phase": st.Phase, "job_id": st.JobID,
		"key_fingerprint": st.KeyFingerprint, "entropy_bits": st.EntropyBits,
		"restic_pw_sealed": st.ResticPwSealed, "uploaded": st.Uploaded,
		"claimable": st.Claimable, "claimed": st.Claimed,
		"claim_expires_in_sec": st.ClaimExpiresInSec, "detail": st.Detail,
	}, "")
}

// escrowClaimAPIHandler proxies the ONE-SHOT claim. no-store on the response; the body is never
// logged; R goes to the wizard's XHR and nowhere else. 410 relays the agent's void verdict.
func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	agent, err := s.escrowAgentConn()
	if err != nil {
		escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
		return
	}
	code, status, err := agent.EscrowCeremonyClaim(r.Context())
	if err != nil {
		switch status {
		case http.StatusGone: // 410 — the code was minted but never shown; permanently void.
			escrowJSON(w, http.StatusGone, nil, "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsa újra a folyamatot: az új kód a régit érvényteleníti.")
		case http.StatusNotFound: // 404 — no ceremony has run (e.g. phase:none post-reboot). F-C:
			// this used to fall through to a 502; a bad-gateway class code for "nothing to claim"
			// is wrong. Relay a clean, honest 4xx.
			escrowJSON(w, http.StatusNotFound, nil, "Nincs aktív helyreállítási folyamat — előbb indítsa el a kódkészítést.")
		case http.StatusConflict: // 409 — the ceremony state doesn't allow a claim right now. F-C.
			escrowJSON(w, http.StatusConflict, nil, "A folyamat jelenlegi állapotában a kód nem kérhető le.")
		default: // status 0 (agent unreachable / transport error) or a genuine agent 5xx — a real
			// bad gateway; keep 502.
			s.logger.Printf("[WARN] [web] escrow claim failed (status %d)", status) // reason text may echo agent detail; the code itself is never in errors
			escrowJSON(w, http.StatusBadGateway, nil, "A kód lekérése nem sikerült.")
		}
		return
	}
	s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)")
	// v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub
	// confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed
	// (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap).
	// Only while pending — never re-stamp an already-escrowed target. Best-effort: a stamp failure
	// must not fail the claim (the code is already revealed and the blob already uploaded).
	if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
		if o.EscrowState != "escrowed" {
			o.CeremonyCompletedAt = time.Now().Format(time.RFC3339)
		}
	}); err != nil {
		s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err)
	}
	// v0.139.0: the blob is already uploaded at claim time — an immediate report lets the
	// hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged)
	// instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle.
	s.reportTriggerNow()
	escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
	code = "" // drop the reference promptly (GC caveat: best-effort)
	_ = code
}

// escrowRateLimited / recordEscrowAuthFailure / clearAuthFailures ride the SAME per-IP counter as
// the login form (loginAttempts, loginMaxAttempts, loginWindowDuration) — a wrong wizard password
// is a wrong password, wherever it was typed.
func (s *Server) escrowRateLimited(ip string) bool {
	s.loginAttemptMu.Lock()
	defer s.loginAttemptMu.Unlock()
	attempt := s.loginAttempts[ip]
	if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
		delete(s.loginAttempts, ip)
		return false
	}
	return attempt != nil && attempt.count >= loginMaxAttempts
}

func (s *Server) recordEscrowAuthFailure(ip string) {
	s.loginAttemptMu.Lock()
	defer s.loginAttemptMu.Unlock()
	if s.loginAttempts == nil {
		s.loginAttempts = map[string]*loginAttempt{}
	}
	if s.loginAttempts[ip] == nil {
		s.loginAttempts[ip] = &loginAttempt{}
	}
	s.loginAttempts[ip].count++
	s.loginAttempts[ip].lastFail = time.Now()
}

func (s *Server) clearAuthFailures(ip string) {
	s.loginAttemptMu.Lock()
	defer s.loginAttemptMu.Unlock()
	delete(s.loginAttempts, ip)
}
