package web

import (
	"crypto/subtle"
	"html/template"
	"net/http"
	"strings"
	"sync"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// selfbind.go — the CUSTOMER side of self-bind (v0.66.0, R-27 slice 1): the PUBLIC /bind/<token> page.
// A logged-out customer opens the emailed capability link and binds their own freshly-installed
// appliance by proving TWO factors — the console pairing code (physical possession of the box) and
// their retrieval passphrase (customer identity). No hub login exists; the URL token IS the auth.
//
// THE TRAP (spec §9.2): /bind/ is the ONE new public prefix, exempted from operator auth + CSRF at
// the two gate sites the /login exemption occupies. isPublicBindPath is the SINGLE definition of that
// prefix — both gate sites and the route dispatch call it, so widening it is one visible change (and
// the red-proof targets exactly this function). It is matched TIGHTLY (trailing slash → no sibling
// prefix like /bindsecret; the ServeMux cleans .. before we see the path → no traversal reach).
//
// No-oracle rules on this surface: the page NEVER renders/enumerates any appliance data; a wrong code
// and a wrong passphrase produce ONE identical generic failure; both factors are compared
// unconditionally before the decision; and only attempt COUNTS are logged (never the secrets, never
// the raw token — a hash prefix at most).

// isPublicBindPath reports whether a path is the public customer self-bind surface. THE single
// definition of the /bind/ public prefix (THE TRAP §9.2) — do not inline a second copy anywhere.
func isPublicBindPath(path string) bool {
	return strings.HasPrefix(path, "/bind/")
}

// --- per-IP rate limiter (web-package sibling of api.ipRateLimiter; the type there is unexported) ---

type bindBucket struct {
	tokens float64
	last   time.Time
}

type bindRateLimiter struct {
	mu        sync.Mutex
	perMinute float64
	buckets   map[string]*bindBucket
	now       func() time.Time
}

func newBindRateLimiter(perMinute int) *bindRateLimiter {
	if perMinute <= 0 {
		perMinute = 30
	}
	return &bindRateLimiter{perMinute: float64(perMinute), buckets: make(map[string]*bindBucket), now: time.Now}
}

func (rl *bindRateLimiter) allow(ip string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()
	now := rl.now()
	b, ok := rl.buckets[ip]
	if !ok {
		rl.buckets[ip] = &bindBucket{tokens: rl.perMinute - 1, last: now}
		return true
	}
	b.tokens += now.Sub(b.last).Seconds() * (rl.perMinute / 60.0)
	if b.tokens > rl.perMinute {
		b.tokens = rl.perMinute
	}
	b.last = now
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

// bindClientIP extracts the client IP behind the ingress (first XFF hop, else RemoteAddr) — buckets
// only; the ingress geo-gate is the real access control.
func bindClientIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		if i := strings.IndexByte(xff, ','); i > 0 {
			return strings.TrimSpace(xff[:i])
		}
		return strings.TrimSpace(xff)
	}
	if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 {
		return r.RemoteAddr[:i]
	}
	return r.RemoteAddr
}

// --- the page ---

type bindPageData struct {
	State  string // "form" | "success" | "expired" | "consumed" | "locked"
	Token  string // echoed into the form action (the capability itself; already in the URL)
	Failed bool   // generic factor-check failure (form state only)
}

var bindTemplate = template.Must(template.New("bind").Parse(bindPageHTML))

func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	if err := bindTemplate.Execute(w, data); err != nil {
		s.logger.Printf("[ERROR] rendering /bind page: %v", err)
	}
}

// handleBind serves the public self-bind page. GET renders the current link state; POST validates
// both factors and, on success, stages the bind (same BindAppliance the operator uses; provenance =
// customer self-bind). Reached only via isPublicBindPath — auth + CSRF exempt at the gate sites.
func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
	if s.bindLimiter != nil && !s.bindLimiter.allow(bindClientIP(r)) {
		s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired"})
		return
	}
	token := strings.TrimPrefix(r.URL.Path, "/bind/")
	// A trailing segment only — reject anything with further path structure (defence in depth atop
	// the ServeMux path-clean; the token is a flat hex string).
	if token == "" || strings.Contains(token, "/") {
		s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired"})
		return
	}
	hash := selfBindHash(token)
	tok, err := s.store.SelfBindTokenByHash(hash)
	if err != nil {
		s.logger.Printf("[ERROR] /bind lookup failed: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	now := time.Now()

	// Terminal link states — identical for GET and POST, no factor check attempted. An unknown token
	// (nil) is folded into "expired": no oracle for "was this link ever real".
	switch {
	case tok == nil || tok.Expired(now):
		s.renderBind(w, http.StatusOK, bindPageData{State: "expired"})
		return
	case tok.Consumed():
		s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"})
		return
	case tok.Locked:
		s.renderBind(w, http.StatusOK, bindPageData{State: "locked"})
		return
	}

	if r.Method != http.MethodPost {
		s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token})
		return
	}

	// --- POST: validate BOTH factors unconditionally, then decide (no oracle) ---
	normCode := configgen.NormalizePairingCode(r.FormValue("pairing_code"))
	normPass := configgen.NormalizePassphrase(r.FormValue("passphrase"))

	// Factor 2 (passphrase) — the customer's retrieval passphrase, constant-time compared. Computed
	// even when the customer/appliance is absent so the two paths are indistinguishable by timing.
	var storedPass string
	if cc, cerr := s.store.GetCustomerConfig(tok.CustomerID); cerr == nil && cc != nil {
		storedPass = configgen.NormalizePassphrase(cc.RetrievalPassword)
	}
	passOK := storedPass != "" && subtle.ConstantTimeCompare([]byte(normPass), []byte(storedPass)) == 1

	// Factor 1 (pairing code) — the ONE bindable appliance carrying that console code.
	appliance, aerr := s.store.ApplianceByPairingCode(normCode)
	if aerr != nil {
		s.logger.Printf("[ERROR] /bind appliance lookup failed: %v", aerr)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	codeOK := appliance != nil

	if !codeOK || !passOK {
		attempts, locked, rerr := s.store.RecordSelfBindAttempt(hash)
		if rerr != nil {
			s.logger.Printf("[ERROR] /bind recording attempt: %v", rerr)
		}
		// COUNTS only — never which factor failed, never the secrets, never the raw token.
		s.logger.Printf("[WARN] self-bind attempt %d/%d failed for token %s… (customer %s)", attempts, store.SelfBindMaxAttempts, hash[:8], tok.CustomerID)
		if locked {
			s.renderBind(w, http.StatusOK, bindPageData{State: "locked"})
			return
		}
		s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true})
		return
	}

	// Both factors passed. Consume one-shot FIRST (atomic gate against a double-bind race).
	consumed, cerr := s.store.ConsumeSelfBindToken(hash)
	if cerr != nil {
		s.logger.Printf("[ERROR] /bind consuming token: %v", cerr)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if !consumed {
		// Lost the race (a concurrent request consumed it) — it is already being bound.
		s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"})
		return
	}
	if err := s.store.BindAppliance(appliance.ID, tok.CustomerID, "appliance", ""); err != nil {
		// Rare: the appliance became unbindable (operator discarded it) between lookup and bind. The
		// token is spent; surface a neutral generic failure rather than an appliance-state oracle.
		s.logger.Printf("[WARN] self-bind: BindAppliance %d → %s failed after factor match: %v", appliance.ID, tok.CustomerID, err)
		s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true})
		return
	}
	if _, err := s.store.SaveEvent(tok.CustomerID, "appliance_bound", "info",
		"Az ügyfél saját maga kötötte össze az új eszközt (bare-metal telepítés); a hozzáférést a doboz a következő lekérdezéskor megkapja.", "", "customer_selfbind"); err != nil {
		s.logger.Printf("[WARN] self-bind: save event for %s: %v", tok.CustomerID, err)
	}
	s.logger.Printf("[INFO] self-bind SUCCESS: appliance %d bound to customer %s by customer self-service (token %s…)", appliance.ID, tok.CustomerID, hash[:8])
	s.renderBind(w, http.StatusOK, bindPageData{State: "success"})
}

// bindPageHTML is the self-contained public page. It CANNOT link /style.css (that route is
// operator-auth gated), so all styling is inline — mirroring the login page. Design tokens: navy
// surface, 2px radius, hairline rules, exception color for the failure banner. Hungarian, adult tone,
// no emoji. It renders NO appliance data in any state.
const bindPageHTML = `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Felhom — Doboz összekötése</title>
<style>
  :root { --navy:#0A2540; --ink:#0A2540; --muted:#5b6b7d; --line:#d8e0e8; --brand:#0083D8; --exc:#c0392b; --bg:#f4f7fa; }
  * { box-sizing: border-box; }
  body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); line-height:1.5; }
  .wrap { max-width:460px; margin:3rem auto; padding:0 1rem; }
  .card { background:#fff; border:1px solid var(--line); border-radius:2px; padding:1.75rem; }
  h1 { font-size:1.25rem; margin:0 0 .25rem; color:var(--navy); }
  h1 span { color:var(--brand); }
  .lead { color:var(--muted); font-size:.92rem; margin:.25rem 0 1.25rem; }
  label { display:block; font-weight:600; font-size:.9rem; margin:1rem 0 .35rem; }
  .hint { color:var(--muted); font-size:.8rem; margin:.15rem 0 0; }
  input[type=text] { width:100%; padding:.6rem .7rem; border:1px solid var(--line); border-radius:2px; font-size:1rem; }
  input.code { text-transform:uppercase; letter-spacing:.12em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
  button { margin-top:1.5rem; width:100%; padding:.7rem; background:var(--brand); color:#fff; border:none; border-radius:2px; font-size:1rem; cursor:pointer; }
  button:hover { background:#006cb0; }
  .banner { border:1px solid var(--exc); color:var(--exc); background:#fbeae8; border-radius:2px; padding:.6rem .75rem; font-size:.88rem; margin:0 0 1rem; }
  .note { border-top:1px solid var(--line); margin-top:1.5rem; padding-top:1rem; color:var(--muted); font-size:.82rem; }
  .foot { text-align:center; color:var(--muted); font-size:.75rem; margin-top:1.25rem; }
  .ok { color:#1e7e34; }
</style>
</head>
<body>
<div class="wrap">
  <div class="card">
    <h1>Felhom <span>doboz</span> összekötése</h1>
    {{if eq .State "form"}}
      <p class="lead">Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a tulajdonosi jelmondatodat.</p>
      {{if .Failed}}<div class="banner">A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a tulajdonosi jelmondatot, majd próbáld újra.</div>{{end}}
      <form method="POST" action="/bind/{{.Token}}">
        <label for="pairing_code">Párosító kód</label>
        <input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="ABC-234">
        <p class="hint">A doboz monitorán jelenik meg, a telepítés után.</p>
        <label for="passphrase">Tulajdonosi jelmondat</label>
        <input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="öt szó, kötőjellel vagy szóközzel">
        <p class="hint">Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.</p>
        <button type="submit">Összekötés</button>
      </form>
      <p class="note">Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
    {{else if eq .State "success"}}
      <p class="lead ok">Sikeres összekötés.</p>
      <p>A doboz kb. egy percen belül folytatja a telepítést. Ezt az oldalt bezárhatod — a beállítás a háttérben befejeződik, és a vezérlőpultod hamarosan elérhető lesz.</p>
    {{else if eq .State "consumed"}}
      <p class="lead">Ez a hivatkozás már fel lett használva.</p>
      <p>A doboz összekötése megtörtént. Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
    {{else if eq .State "locked"}}
      <p class="lead">Ez a hivatkozás zárolva van.</p>
      <p>Túl sok sikertelen próbálkozás történt. Biztonsági okból a hivatkozás zárolódott — kérjük, vedd fel a kapcsolatot az ügyfélszolgálattal a doboz összekötéséhez.</p>
    {{else}}
      <p class="lead">Ez a hivatkozás érvénytelen vagy lejárt.</p>
      <p>A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.</p>
    {{end}}
  </div>
  <p class="foot">Felhom.eu</p>
</div>
</body>
</html>`
