package web

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"strings"
	"time"

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

// Customer-claim password arc (v0.122.0, DRILL-day0-vm F-4). The customer OWNS the dashboard
// password: an unclaimed box serves ONLY the claim page (code → set own password → claimed);
// everything else answers the claim page (HTML) or 401 (API). A set password always wins (the
// gate never shows once effectivePasswordHash != ""). Reset rides the same code engine. A box
// with a code hash but no password and not-yet-claimed is GATED; a box with neither hash nor
// password is legacy-open with a red transition banner (transitional only).

const (
	claimCodeTTL       = 72 * time.Hour
	claimMinPassword   = 12
	claimMaxAttempts   = 5
	claimLockoutWindow = 15 * time.Minute
	claimCSRFCookie    = "felhom_claim_csrf"
)

// claimAttempt tracks failed claim-code attempts for the per-source + global limiter.
type claimAttempt struct {
	count      int
	lockedTill time.Time
}

// effectiveClaimCode returns the freshest hub-delivered claim-code state: the ACK-cached
// settings value when its generation is at least the config-baked one (fresher), else the
// controller.yaml bake. Returns ("", 0, "") when neither carries a code.
func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string) {
	var sHash, sIssued string
	var sGen int
	if s.settings != nil {
		sHash, sGen, sIssued = s.settings.GetClaimCode()
	}
	cHash := s.cfg.Web.ClaimCodeHash
	cGen := s.cfg.Web.ClaimCodeGeneration
	cIssued := s.cfg.Web.ClaimCodeIssuedAt
	if sHash != "" && sGen >= cGen {
		return sHash, sGen, sIssued
	}
	return cHash, cGen, cIssued
}

// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim
// code hash is present, and the box has not been claimed. A set password (settings or config)
// disables the gate entirely — password auth wins.
func (s *Server) claimGateActive() bool {
	if s.authEnabled() {
		return false // a password beats the gate (claimed boxes, or an operator-set one)
	}
	hash, _, _ := s.effectiveClaimCode()
	if hash == "" {
		return false // legacy-open (transition state) — no code to gate on
	}
	if s.settings != nil && s.settings.GetClaimed() {
		return false // claimed but password somehow cleared — don't re-gate; treat as legacy-open
	}
	return true
}

// claimLegacyOpen reports the transitional open state: no password, no code hash — the red
// banner is shown until the hub delivers a code hash. NOT the fresh-box state (that is gated).
func (s *Server) claimLegacyOpen() bool {
	if s.authEnabled() {
		return false
	}
	hash, _, _ := s.effectiveClaimCode()
	return hash == ""
}

// ── pre-auth CSRF for the claim form (closes CTRL-007: HMAC with the server-side session
// secret, not a bare double-submit) ────────────────────────────────────────────────────────

func (s *Server) claimCSRFToken() string {
	mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
	mac.Write([]byte("felhom-claim-csrf-v1"))
	return hex.EncodeToString(mac.Sum(nil))
}

func (s *Server) setClaimCSRFCookie(w http.ResponseWriter, r *http.Request) string {
	tok := s.claimCSRFToken()
	http.SetCookie(w, &http.Cookie{
		Name:     claimCSRFCookie,
		Value:    tok,
		Path:     "/",
		HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site
		SameSite: http.SameSiteStrictMode,
		Secure:   r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
		MaxAge:   int(claimCodeTTL.Seconds()),
	})
	return tok
}

func (s *Server) validClaimCSRF(r *http.Request) bool {
	want := s.claimCSRFToken()
	form := r.FormValue(csrfFormField)
	if subtle.ConstantTimeCompare([]byte(form), []byte(want)) != 1 {
		return false
	}
	c, err := r.Cookie(claimCSRFCookie)
	if err != nil {
		return false
	}
	return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
}

// ── the limiter (per-source IP + a global counter; both must be clear) ───────────────────────

func (s *Server) claimRateLocked() (locked bool, till time.Time) {
	s.claimMu.Lock()
	defer s.claimMu.Unlock()
	now := s.claimNow()
	if s.claimGlobal.lockedTill.After(now) {
		return true, s.claimGlobal.lockedTill
	}
	return false, time.Time{}
}

func (s *Server) claimSourceLocked(ip string) (locked bool, till time.Time) {
	s.claimMu.Lock()
	defer s.claimMu.Unlock()
	now := s.claimNow()
	a := s.claimAttempts[ip]
	if a != nil && a.lockedTill.After(now) {
		return true, a.lockedTill
	}
	return false, time.Time{}
}

// claimRegisterFailure bumps the per-IP + global counters; on hitting the cap it locks that
// scope for claimLockoutWindow and returns locked=true (the caller reports the lockout event).
// An EXPIRED lock resets its scope's counter first, so a fresh attempt after the window starts
// clean rather than re-locking on a stale count.
func (s *Server) claimRegisterFailure(ip string) (locked bool) {
	s.claimMu.Lock()
	defer s.claimMu.Unlock()
	now := s.claimNow()
	if s.claimAttempts == nil {
		s.claimAttempts = make(map[string]*claimAttempt)
	}
	a := s.claimAttempts[ip]
	if a == nil {
		a = &claimAttempt{}
		s.claimAttempts[ip] = a
	}
	if !a.lockedTill.IsZero() && !a.lockedTill.After(now) {
		*a = claimAttempt{} // per-IP lock expired → clean slate
	}
	if !s.claimGlobal.lockedTill.IsZero() && !s.claimGlobal.lockedTill.After(now) {
		s.claimGlobal = claimAttempt{} // global lock expired → clean slate
	}
	a.count++
	s.claimGlobal.count++
	if a.count >= claimMaxAttempts {
		a.lockedTill = now.Add(claimLockoutWindow)
		locked = true
	}
	if s.claimGlobal.count >= claimMaxAttempts {
		s.claimGlobal.lockedTill = now.Add(claimLockoutWindow)
		locked = true
	}
	return locked
}

func (s *Server) claimClearFailures(ip string) {
	s.claimMu.Lock()
	defer s.claimMu.Unlock()
	delete(s.claimAttempts, ip)
	s.claimGlobal = claimAttempt{}
}

// claimNow is the clock seam (tests inject a fake). Defaults to time.Now.
func (s *Server) claimNow() time.Time {
	if s.claimClock != nil {
		return s.claimClock()
	}
	return time.Now()
}

// clientIP returns the client IP used as the rate-limiter key. Order: the X-Forwarded-For first
// hop (set by the traefik/Cloudflare proxy) wins; otherwise the HOST portion of RemoteAddr with the
// ephemeral PORT stripped (net.SplitHostPort). This is the CAMPAIGN-4 F-B fix: keying on the raw
// RemoteAddr (IP:PORT) meant every fresh direct connection from one host got a distinct ephemeral
// port → a distinct key → the failed-attempt counter never accrued, so a direct-to-controller
// (LAN/guest, non-proxied) path had NO brute-force protection. A RemoteAddr with no port
// (tests/edge) or an IPv6 form is handled by SplitHostPort, falling back to the raw value.
//
// Accepted limitation (out of scope here): X-Forwarded-For is attacker-controlled on a direct path,
// so a client rotating the first hop still evades the per-IP counter. This fix only closes the
// port-in-key bug so the proxied / stable-source-IP case — the real deployment — works; it does NOT
// attempt to establish XFF trust.
func clientIP(r *http.Request) string {
	if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
		return strings.TrimSpace(strings.Split(fwd, ",")[0])
	}
	if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
		return host
	}
	return strings.TrimSpace(r.RemoteAddr)
}

// ── the pages ────────────────────────────────────────────────────────────────────────────────

// claimPageAllowedPath reports the paths reachable while the unclaimed gate is active (the claim
// page itself, its static assets, health). Everything else is gated.
func claimPageAllowedPath(path string) bool {
	switch path {
	case "/claim", "/claim/request-new-code", "/api/health":
		return true
	}
	return strings.HasPrefix(path, "/static/")
}

// serveClaimGate is invoked by RequireAuth when the unclaimed gate is active and the request is
// NOT an allowed path: render the claim page (HTML) or a 401 (API / mutating).
func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) {
	if strings.HasPrefix(r.URL.Path, "/api/") {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusUnauthorized)
		fmt.Fprint(w, `{"ok":false,"error":"dashboard not yet claimed"}`)
		return
	}
	http.Redirect(w, r, "/claim", http.StatusFound)
}

// handleClaimPage renders the claim/reset code-entry page (GET). Reachable pre-auth: the code is
// the strong factor. For a claimed box (password set) it doubles as the reset-code entry.
func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
	csrf := s.setClaimCSRFCookie(w, r)
	hash, _, _ := s.effectiveClaimCode()
	reset := s.authEnabled() // a set password means this is the reset flow, not first-claim
	data := map[string]interface{}{
		"Title":        "A szerver beállítása",
		"CustomerName": s.cfg.Customer.Name,
		"Domain":       s.cfg.Customer.Domain,
		"Version":      s.version,
		"Error":        errorMsg,
		"Flash":        flashMsg,
		"ClaimCSRF":    csrf,
		"IsReset":      reset,
		"HasCode":      hash != "",
		"MinPassword":  claimMinPassword,
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := s.tmpl.ExecuteTemplate(w, "claim", data); err != nil {
		s.logger.Printf("[ERROR] [web] Template error (claim): %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
	}
}

// handleClaimSubmit verifies the code and sets the customer's password (POST /claim). On success
// the box is claimed (or the password reset), the code generation is consumed (single-use), all
// sessions are invalidated and a fresh one is issued.
func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	if !s.validClaimCSRF(r) {
		s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
		return
	}
	wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
	ip := clientIP(r)

	if locked, _ := s.claimRateLocked(); locked {
		s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
		return
	}
	if locked, _ := s.claimSourceLocked(ip); locked {
		s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
		return
	}

	code := strings.TrimSpace(r.FormValue("code"))
	newPassword := r.FormValue("new_password")
	confirm := r.FormValue("confirm_password")

	hash, generation, issuedAt := s.effectiveClaimCode()
	if hash == "" {
		s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "")
		return
	}

	// Code checks: not expired, not an already-consumed generation, hash matches. A failure of
	// ANY of these counts toward the lockout (they are indistinguishable to a guesser).
	valid := true
	if consumed := s.settings.GetClaimConsumedGeneration(); generation <= consumed {
		valid = false // this code was already used (single-use)
	}
	if valid && issuedAt != "" {
		if t, err := time.Parse(time.RFC3339, issuedAt); err == nil && s.claimNow().Sub(t) > claimCodeTTL {
			valid = false // expired
		}
	}
	if valid && bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) != nil {
		valid = false // wrong code
	}
	if !valid {
		if s.claimRegisterFailure(ip) {
			s.reportClaimLockout(ip)
			s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
			return
		}
		s.handleClaimPage(w, r, "Hibás vagy lejárt kód", "")
		return
	}

	// Password rules (min length, match).
	if len(newPassword) < claimMinPassword {
		s.handleClaimPage(w, r, fmt.Sprintf("A jelszónak legalább %d karakter hosszúnak kell lennie", claimMinPassword), "")
		return
	}
	if newPassword != confirm {
		s.handleClaimPage(w, r, "A két jelszó nem egyezik", "")
		return
	}

	pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
	if err != nil {
		s.logger.Printf("[ERROR] [web] claim: hashing new password: %v", err)
		s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
		return
	}
	if err := s.settings.SetPasswordHash(string(pwHash)); err != nil {
		s.logger.Printf("[ERROR] [web] claim: saving password: %v", err)
		s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
		return
	}
	// Consume the generation (single-use) + mark claimed (set-only). Order: consume BEFORE
	// claimed so a crash between them can't leave a reusable code on a claimed box.
	if err := s.settings.SetClaimConsumedGeneration(generation); err != nil {
		s.logger.Printf("[WARN] [web] claim: recording consumed generation failed: %v", err)
	}
	if err := s.settings.SetClaimed(); err != nil {
		s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err)
	}
	// v0.139.0: report out-of-cycle so the hub's Claimed flag (and claim-code consumption)
	// flips in seconds — the operator sees the customer claim land immediately.
	s.reportTriggerNow()
	s.claimClearFailures(ip)
	s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist

	action := "claimed"
	if wasReset {
		action = "password reset"
	}
	s.logger.Printf("[INFO] [web] dashboard %s by the customer from %s (code generation %d consumed)", action, ip, generation)

	// Issue a fresh session so the customer lands logged-in.
	token := s.createSession()
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    token,
		Path:     "/",
		MaxAge:   int(sessionMaxAge.Seconds()),
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
	})
	http.Redirect(w, r, "/", http.StatusFound)
}

// handleClaimRequestNewCode forwards a "kérj új kódot" / "Elfelejtett jelszó" to the hub, which
// emails a FRESH code to the REGISTERED address only (the requester never chooses the
// destination). The response is always the neutral confirmation page.
func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	if !s.validClaimCSRF(r) {
		s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
		return
	}
	go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
	s.handleClaimPage(w, r, "", "Ha az e-mail cím regisztrálva van, elküldtük a kódot.")
}

// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response;
// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the
// emailed code works the moment it lands instead of after the next ACK (~15 min). An old hub's
// bare {"status":"ok"} response is a clean no-op (no claim object → Reconcile skips).
func (s *Server) requestHubResetCode() {
	if s.cfg.Hub.URL == "" || s.cfg.Hub.APIKey == "" {
		s.logger.Printf("[WARN] [web] claim: cannot request a new code — hub URL/key not configured")
		return
	}
	body, _ := json.Marshal(map[string]string{"customer_id": s.cfg.Customer.ID})
	req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.cfg.Hub.URL, "/")+"/api/v1/claim/reset-request", strings.NewReader(string(body)))
	if err != nil {
		s.logger.Printf("[ERROR] [web] claim: building reset-request: %v", err)
		return
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+s.cfg.Hub.APIKey)
	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		s.logger.Printf("[ERROR] [web] claim: reset-request to hub failed: %v", err)
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		s.logger.Printf("[WARN] [web] claim: hub reset-request returned HTTP %d", resp.StatusCode)
		return
	}
	s.logger.Printf("[INFO] [web] claim: requested a fresh code from the hub for %s", s.cfg.Customer.ID)
	var payload struct {
		Claim *report.ClaimStatus `json:"claim"`
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&payload); err != nil {
		s.logger.Printf("[WARN] [web] claim: parsing reset-request response failed (code arrives via the next ACK): %v", err)
		return
	}
	if payload.Claim != nil && s.settings != nil {
		sync := &report.ClaimSync{Settings: s.settings, Logger: s.logger}
		sync.Reconcile(payload.Claim)
	}
}

// PrintLocalResetCode is the root escape hatch (v0.122.0, --print-reset-code): generate a fresh
// local claim/reset code, install its hash at a generation ABOVE any cached/consumed one (so the
// gate accepts it), persist to settings.json, and print the plaintext ONCE to stdout. Same gate
// consumes it (single-use). Root-gated by reachability (docker exec into the container). Returns
// a process exit code.
func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int {
	code, err := localCode()
	if err != nil {
		fmt.Fprintf(os.Stderr, "print-reset-code: generating code: %v\n", err)
		return 1
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(code), 10)
	if err != nil {
		fmt.Fprintf(os.Stderr, "print-reset-code: hashing code: %v\n", err)
		return 1
	}
	_, cachedGen, _ := sett.GetClaimCode()
	nextGen := cachedGen
	if cfg.WebClaimGeneration() > nextGen {
		nextGen = cfg.WebClaimGeneration()
	}
	if c := sett.GetClaimConsumedGeneration(); c >= nextGen {
		nextGen = c
	}
	nextGen++ // strictly above cached, baked, and consumed → the gate treats it as fresh + unused
	if err := sett.SetClaimCode(string(hash), nextGen, time.Now().UTC().Format(time.RFC3339)); err != nil {
		fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err)
		return 1
	}
	fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n    %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
	return 0
}

// ClaimHatchSettings / ClaimHatchConfig are the minimal seams the escape hatch needs (satisfied
// by *settings.Settings and *config.Config respectively — kept as interfaces so cmd/ wires them
// without this package importing config for a one-off).
type ClaimHatchSettings interface {
	GetClaimCode() (hash string, generation int, issuedAt string)
	GetClaimConsumedGeneration() int
	SetClaimCode(hash string, generation int, issuedAt string) error
}

type ClaimHatchConfig interface {
	WebClaimGeneration() int
}

// localCode makes a readable one-time code (three 4-char base32-ish groups) without needing the
// hub's Hungarian word list — it is typed once, locally, by the operator.
func localCode() (string, error) {
	const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" // no ambiguous 0/1/i/l/o
	b := make([]byte, 12)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	out := make([]byte, 0, 14)
	for i, v := range b {
		if i > 0 && i%4 == 0 {
			out = append(out, '-')
		}
		out = append(out, alphabet[int(v)%len(alphabet)])
	}
	return string(out), nil
}

// reportClaimLockout pushes the allowlisted claim_lockout event (operator + customer visibility).
func (s *Server) reportClaimLockout(ip string) {
	s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip)
	if s.notifier != nil {
		s.notifier.PushEvent("claim_lockout", "warning",
			"Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva",
			map[string]interface{}{"source": ip})
	}
}
