package api

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"

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

// R-21 slice C — the universal secret-free ISO. A box booted from the GENERIC ISO registers itself
// (unauthenticated) and receives a random APPLIANCE TOKEN — its only pre-day-0 credential. It then
// polls (token-authed) until the operator binds it to a customer; ONE poll delivers the customer-id +
// retrieval passphrase, and every later poll → 410. The token is stored only as sha256; the endpoints
// are minimal (no enumeration oracle) and register is per-IP rate-limited.

const maxApplianceBytes = 64 << 10 // register payload: uuid + a few MACs + 3 SSH host keys + hw summary

// ipRateLimiter is a per-IP token bucket for the one unauthenticated endpoint. Reuses tokenBucket
// (mail.go); in-memory (lost on restart, acceptable — same posture as the mail limiter).
type ipRateLimiter struct {
	mu        sync.Mutex
	perMinute int
	buckets   map[string]*tokenBucket
	now       func() time.Time
}

func newIPRateLimiter(perMinute int) *ipRateLimiter {
	if perMinute <= 0 {
		perMinute = 20
	}
	return &ipRateLimiter{perMinute: perMinute, buckets: make(map[string]*tokenBucket), now: time.Now}
}

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

// clientIP extracts the real client IP behind the nginx/cloudflared ingress: the first
// X-Forwarded-For hop, else RemoteAddr. Only used for rate-limiting (a spoofed XFF just picks a
// different bucket — the geo gate at the ingress is the real access control).
func clientIP(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 host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
		return host
	}
	return r.RemoteAddr
}

func sha256hex(s string) string {
	sum := sha256.Sum256([]byte(s))
	return hex.EncodeToString(sum[:])
}

// normalizeMACSet lowercases, trims, drops all-zero/empty MACs, dedups and SORTS — so the mac_set is
// a stable key regardless of interface enumeration order (the (uuid, mac_set) tiebreaker).
func normalizeMACSet(macs []string) string {
	seen := map[string]bool{}
	var out []string
	for _, m := range macs {
		m = strings.ToLower(strings.TrimSpace(m))
		if m == "" || m == "00:00:00:00:00:00" {
			continue
		}
		if !seen[m] {
			seen[m] = true
			out = append(out, m)
		}
	}
	sort.Strings(out)
	return strings.Join(out, ",")
}

type applianceRegisterReq struct {
	UUID           string          `json:"uuid"`
	MACs           []string        `json:"macs"`
	SSHHostPubkeys []string        `json:"ssh_host_pubkeys"`
	HW             json.RawMessage `json:"hw"`
}

// handleApplianceRegister — POST /api/v1/appliance/register (UNAUTHENTICATED, per-IP rate-limited,
// idempotent by (uuid, mac_set)). Returns a fresh random appliance token (the box's only credential).
func (h *Handler) handleApplianceRegister(w http.ResponseWriter, r *http.Request) {
	if h.applianceLimiter != nil && !h.applianceLimiter.allow(clientIP(r)) {
		http.Error(w, "rate limited", http.StatusTooManyRequests)
		return
	}
	var req applianceRegisterReq
	if err := json.NewDecoder(io.LimitReader(r.Body, maxApplianceBytes)).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	uuid := strings.TrimSpace(req.UUID)
	macSet := normalizeMACSet(req.MACs)
	if uuid == "" || macSet == "" {
		http.Error(w, "uuid and at least one MAC are required", http.StatusBadRequest)
		return
	}
	sshKeys := strings.Join(sanitizeLines(req.SSHHostPubkeys), "\n")
	hwSummary := ""
	if len(req.HW) > 0 {
		hwSummary = string(req.HW)
	}

	token, err := configgen.RandomHex(32) // 256-bit
	if err != nil {
		h.logger.Printf("[ERROR] appliance register: token mint: %v", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	// v0.66.0 (R-27): a stable 6-char pairing code the box prints on its console + the customer types
	// into the self-bind page. The candidate is used only on first insert; a re-register keeps the code.
	candidateCode, err := configgen.RandomPairingCode()
	if err != nil {
		h.logger.Printf("[ERROR] appliance register: pairing-code mint: %v", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	isNew, pairingCode, err := h.store.RegisterAppliance(uuid, macSet, sshKeys, hwSummary, sha256hex(token), candidateCode)
	if err != nil {
		h.logger.Printf("[ERROR] appliance register (uuid=%s): %v", uuid, err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if isNew {
		// Provenance is the appliance_registrations row itself (first_seen) — there is no customer to
		// scope an events row to yet. Token withheld (fingerprint would leak a guess vector; log the id-free fact).
		h.logger.Printf("[INFO] appliance registered: new unclaimed box (uuid=%s macs=%d ssh_keys=%d)", uuid, strings.Count(macSet, ",")+1, len(sanitizeLines(req.SSHHostPubkeys)))
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	// pairing_code is additive — a pre-v0.66.0 bootstrap ignores it and stays operator-bind-only.
	json.NewEncoder(w).Encode(map[string]any{
		"appliance_token": token, "poll_interval_sec": 30, "pairing_code": configgen.FormatPairingCode(pairingCode),
	})
}

// handleAppliancePoll — GET /api/v1/appliance/poll (Bearer appliance-token). One-shot delivery:
//
//	unknown/discarded token → 404 (no oracle)   registered (unbound) → 204 (keep polling)
//	bound (staged, this poll wins) → 200 + creds  already delivered / lost race → 410
func (h *Handler) handleAppliancePoll(w http.ResponseWriter, r *http.Request) {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	token := strings.TrimPrefix(auth, "Bearer ")
	appl, err := h.store.ApplianceByToken(sha256hex(token))
	if err != nil {
		h.logger.Printf("[ERROR] appliance poll lookup: %v", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if appl == nil || appl.Status == store.ApplianceDiscarded {
		http.Error(w, "not found", http.StatusNotFound) // no oracle: unknown == discarded
		return
	}
	switch appl.Status {
	case store.ApplianceRegistered:
		w.WriteHeader(http.StatusNoContent) // bound not yet — keep polling
		return
	case store.ApplianceDelivered:
		http.Error(w, "already delivered", http.StatusGone)
		return
	case store.ApplianceBound:
		// One-shot: only the winning poll flips bound→delivered.
		ok, err := h.store.MarkApplianceDelivered(sha256hex(token))
		if err != nil {
			h.logger.Printf("[ERROR] appliance poll deliver: %v", err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		if !ok {
			http.Error(w, "already delivered", http.StatusGone) // lost the race
			return
		}
		cc, err := h.store.GetCustomerConfig(appl.CustomerID)
		if err != nil || cc == nil {
			h.logger.Printf("[ERROR] appliance deliver: bound customer %q missing: %v", appl.CustomerID, err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		mode := appl.InstallMode
		if mode == "" {
			mode = "appliance"
		}
		// Audit: now a customer exists to scope the event to.
		if _, serr := h.store.SaveEvent(appl.CustomerID, "appliance_credential_delivered", "info",
			"Új eszköz (bare-metal telepítés) megkapta a hozzáférést és megkezdi a beállítást.", "", "hub"); serr != nil {
			h.logger.Printf("[WARN] appliance deliver: save event: %v", serr)
		}
		h.logger.Printf("[INFO] appliance credentials DELIVERED once to appliance %d (customer=%s mode=%s; passphrase withheld)", appl.ID, appl.CustomerID, mode)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]string{
			"customer_id":          appl.CustomerID,
			"retrieval_passphrase": cc.RetrievalPassword,
			"mode":                 mode,
			"extra_args":           appl.ExtraArgs,
		})
		return
	default:
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
}

// sanitizeLines trims + drops empty entries (SSH host key lines).
func sanitizeLines(in []string) []string {
	var out []string
	for _, s := range in {
		if s = strings.TrimSpace(s); s != "" {
			out = append(out, s)
		}
	}
	return out
}
