package web

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"sort"

	"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)

// Agent-backed disk management (slice 8C, Phase B.2).
//
// Disk EXECUTION (scan/format/mount/migrate) lives in the host agent now; the
// controller is Docker-only and holds no Proxmox/disk credentials. These handlers
// are THIN proxies: they build an agentapi.Client from cfg.LocalAPI and forward
// list/assign/eject/format to the agent's GET/POST /disks endpoints, returning the
// agent's view as JSON. A data-bearing format is refused by the agent (operator
// authorization required) and surfaced here as HTTP 409.

// ServeDiskAPI dispatches /api/disks and /api/disks/* routes.
// Wired in main.go behind RequireAuth + CsrfProtect.
func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) {
	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] ServeDiskAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
	}
	switch {
	case r.URL.Path == "/api/disks" && r.Method == http.MethodGet:
		s.agentDisksListHandler(w, r)
	case r.URL.Path == "/api/disks/candidates" && r.Method == http.MethodGet:
		s.agentDiskCandidatesHandler(w, r)
	case r.URL.Path == "/api/disks/assign" && r.Method == http.MethodPost:
		s.agentDiskAssignHandler(w, r)
	case r.URL.Path == "/api/disks/eject" && r.Method == http.MethodPost:
		s.agentDiskEjectHandler(w, r)
	case r.URL.Path == "/api/disks/format" && r.Method == http.MethodPost:
		s.agentDiskFormatHandler(w, r)
	default:
		http.NotFound(w, r)
	}
}

// agentClient returns the shared pinned client for the host agent's per-guest local API.
// Returns a clear error if the local API is not configured (unprovisioned guest).
//
// The client is built ONCE and reused (cfg.LocalAPI is static per process — a config-apply triggers a
// graceful self-restart). Reusing one *agentapi.Client (one pooled http.Transport) eliminates the
// per-call Transport leak that accumulated idle ESTABLISHED sockets to the agent's :8443 and exhausted
// the ephemeral source-port range after ~5 days of uptime. *agentapi.Client/*http.Client are safe for
// concurrent use, so the (19) callers need no extra locking. The empty-endpoint guard stays OUTSIDE the
// Once so it is re-checked each call (defensive; the endpoint is static); only a real build result
// (client, or a construction error like a bad fingerprint — neither changes without a restart) is memoized.
func (s *Server) agentClient() (*agentapi.Client, error) {
	if s.cfg.LocalAPI.Endpoint == "" {
		return nil, errors.New("agent not configured")
	}
	s.agentCliOnce.Do(func() {
		s.agentCli, s.agentCliErr = agentapi.New(
			s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint)
	})
	return s.agentCli, s.agentCliErr
}

// ProbeAgentChannel runs one controller→agent channel health probe using the PRODUCTION memoized
// client (NOT a fresh one — spike SPIKE-controller-agent-channel-health-2026-06-29: it self-heals,
// reflects exactly what the disk UI sees, and avoids the per-call transport leak the singleton fixed).
// It returns whether the failure was a CONSTRUCTION error (agentClient() couldn't build — a latching
// config fault, distinct from a runtime channel failure) and the error (nil = channel up). The probe
// is GET /storage (cheap, read-only — the same call probeLocalAPI uses at startup). This is the
// channelhealth.Probe seam.
func (s *Server) ProbeAgentChannel(ctx context.Context) (constructionErr bool, err error) {
	client, cerr := s.agentClient()
	if cerr != nil {
		return true, cerr
	}
	_, serr := client.Storage(ctx)
	return false, serr
}

// writeDiskJSON writes the standard {ok,data,error} envelope used by the disk API.
func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	resp := map[string]interface{}{"ok": ok}
	if errMsg != "" {
		resp["error"] = errMsg
	}
	if data != nil {
		resp["data"] = data
	}
	_ = json.NewEncoder(w).Encode(resp)
}

// agentDisksListHandler proxies GET /api/disks → agent GET /disks.
func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
	client, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	resp, err := client.Disks(r.Context())
	if err != nil {
		s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	// Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would
	// otherwise reorder on every reload (CLAUDE.md lesson #3). The customer's manageable drives go on
	// top, in a stable order: user-data first, then system, then backup, alpha by name within a tier.
	sortDisksForView(resp.Disks)
	writeDiskJSON(w, http.StatusOK, true, "", resp)
}

// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
// already excludes claimed/OS/enrolled disks (fail-safe), so the controller passes the list through
// untouched — no controller-side filtering.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
	client, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	resp, err := client.ListCandidates(r.Context())
	if err != nil {
		s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", resp)
}

// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →
// unrecognized; alphabetical by storage name within each tier). A stable Go-side contract beats
// relying on map iteration order or template JS alone.
func sortDisksForView(disks []agentapi.DiskInfo) {
	sort.SliceStable(disks, func(i, j int) bool {
		if ri, rj := diskRoleRank(disks[i].Role), diskRoleRank(disks[j].Role); ri != rj {
			return ri < rj
		}
		return disks[i].Name < disks[j].Name
	})
}

// diskRoleRank ranks a role for the overview ordering (lower sorts first).
func diskRoleRank(role string) int {
	switch role {
	case "user-data":
		return 0
	case "system":
		return 1
	case "backup":
		return 2
	default:
		return 3
	}
}

// agentDiskAssignHandler proxies POST /api/disks/assign → agent POST /disks/assign.
func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) {
	var req struct {
		UUID    string `json:"uuid"`
		Where   string `json:"where"`
		FSType  string `json:"fstype"`
		Options string `json:"options"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
		return
	}
	if req.UUID == "" || req.Where == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "uuid and where are required", nil)
		return
	}
	client, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil {
		s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{
		"uuid": req.UUID, "where": req.Where,
	})
}

// agentDiskEjectHandler proxies POST /api/disks/eject → agent POST /disks/eject.
func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Where string `json:"where"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
		return
	}
	if req.Where == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "where is required", nil)
		return
	}
	client, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	resp, err := client.EjectDisk(r.Context(), req.Where)
	if err != nil {
		s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", resp)
}

// agentDiskFormatHandler proxies POST /api/disks/format → agent POST /disks/format.
// A data-bearing format refusal (ErrFormatRefused) is surfaced as HTTP 409 so the UI
// can show "operator authorization required".
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Device    string `json:"device"`
		FSType    string `json:"fstype"`
		Confirmed bool   `json:"confirmed"`
		DurableID string `json:"durable_id"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
		return
	}
	if req.Device == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "device is required", nil)
		return
	}
	client, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID)
	if errors.Is(err, agentapi.ErrNeedsConfirmation) {
		s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device)
		writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
		return
	}
	if errors.Is(err, agentapi.ErrFormatRefused) {
		s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
		writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
		return
	}
	if err != nil {
		s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", resp)
}
