package api

import (
	"bytes"
	"crypto/subtle"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
	"time"

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

// ConfigTemplateProvider returns the controller.yaml template for config generation.
type ConfigTemplateProvider interface {
	Template() string
}

// Handler handles API endpoints for report ingest and customer queries.
type Handler struct {
	store            *store.Store
	apiKey           string
	resendAPIKey     string
	fromEmail        string
	logger           *log.Logger
	httpClient       *http.Client
	templateProvider ConfigTemplateProvider
	dispatcher       *notify.Dispatcher
	assetsMgr        *assets.Manager
}

// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
	return &Handler{
		store:            store,
		apiKey:           apiKey,
		resendAPIKey:     resendAPIKey,
		fromEmail:        fromEmail,
		logger:           logger,
		httpClient:       &http.Client{Timeout: 10 * time.Second},
		templateProvider: templateProvider,
	}
}

// SetDispatcher sets the notification dispatcher for event-triggered emails.
func (h *Handler) SetDispatcher(d *notify.Dispatcher) {
	h.dispatcher = d
}

// SetAssetManager sets the asset manager for serving app assets to controllers.
func (h *Handler) SetAssetManager(am *assets.Manager) {
	h.assetsMgr = am
}

// checkAuth verifies the Bearer token against the global API key or a per-customer API key.
// Returns true if authorized.
func (h *Handler) checkAuth(r *http.Request) bool {
	_, _, ok := h.checkAuthCustomer(r)
	return ok
}

// checkAuthCustomer verifies the Bearer token and returns the authenticated customer identity.
// For per-customer keys: returns (customerID, false, true).
// For global key: returns ("", true, true) — caller must allow any customer_id.
// On failure: returns ("", false, false).
func (h *Handler) checkAuthCustomer(r *http.Request) (customerID string, isGlobal bool, ok bool) {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		return "", false, false
	}
	token := strings.TrimPrefix(auth, "Bearer ")

	// Check global key first
	if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
		return "", true, true
	}

	// Check per-customer key
	cfg, err := h.store.GetCustomerConfigByAPIKey(token)
	if err != nil || cfg == nil {
		return "", false, false
	}
	return cfg.CustomerID, false, true
}

// checkAuthHost resolves a Bearer token to a HOST identity (the agent's auth
// path). It is a sibling of checkAuthCustomer — the controller path is unchanged.
//   - global key  -> ("", "", true, true)    caller trusts body.host_id
//   - per-host key -> (hostID, customerID, false, true)
//   - failure      -> ("", "", false, false)
func (h *Handler) checkAuthHost(r *http.Request) (hostID, customerID string, isGlobal, ok bool) {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		return "", "", false, false
	}
	token := strings.TrimPrefix(auth, "Bearer ")

	// Global key first (same constant-time compare as checkAuthCustomer).
	if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
		return "", "", true, true
	}

	host, err := h.store.GetHostByAPIKey(token)
	if err != nil || host == nil {
		return "", "", false, false
	}
	return host.HostID, host.CustomerID, false, true
}

// ServeHTTP routes API requests.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	path := strings.TrimPrefix(r.URL.Path, "/api/v1")

	switch {
	case r.Method == http.MethodPost && path == "/report":
		h.handleReport(w, r)
	case r.Method == http.MethodPost && path == "/host-report":
		h.handleHostReport(w, r)
	case r.Method == http.MethodPost && path == "/admin/hosts":
		h.handleAdminCreateHost(w, r)
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
		h.handleHostEscrowPut(w, r, hostID)
	// DR capstone (slice 10D). Recovery-mode toggle (global key); re-enroll + restore-directive
	// (gated on recovery mode — no old key needed, the box is lost).
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
		h.handleSetRecoveryMode(w, r, hostID)
	case r.Method == http.MethodDelete && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
		h.handleClearRecoveryMode(w, r, hostID)
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/re-enroll"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/re-enroll")
		h.handleReEnroll(w, r, hostID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/restore-directive"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/restore-directive")
		h.handleGetRestoreDirective(w, r, hostID)
	// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
		h.handleGetDesiredState(w, r, hostID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/jobs"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/jobs")
		h.handleGetJobs(w, r, hostID)
	// Job completion (slice 10B) — per-host-key, self-scoped: DELETE /hosts/{id}/jobs/{job_id}.
	case r.Method == http.MethodDelete && strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/jobs/"):
		rest := strings.TrimPrefix(path, "/hosts/")
		if i := strings.Index(rest, "/jobs/"); i > 0 {
			h.handleDeleteJob(w, r, rest[:i], rest[i+len("/jobs/"):])
		} else {
			http.NotFound(w, r)
		}
	// Admin-set (slice 10A) — global/operator key only; bumps the generation.
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/desired-state"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/desired-state")
		h.handleAdminSetDesiredState(w, r, hostID)
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/jobs"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/jobs")
		h.handleAdminEnqueueJob(w, r, hostID)
	case r.Method == http.MethodPost && path == "/event":
		h.handleEvent(w, r)
	case r.Method == http.MethodPost && path == "/notify":
		h.handleNotify(w, r)
	case r.Method == http.MethodPost && path == "/infra-backup":
		h.handleInfraBackupPush(w, r)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/") && strings.HasSuffix(path, "/versions"):
		customerID := strings.TrimPrefix(path, "/infra-backup/")
		customerID = strings.TrimSuffix(customerID, "/versions")
		h.handleInfraBackupVersions(w, r, customerID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"):
		h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
	case r.Method == http.MethodPost && path == "/preferences":
		h.handleSavePreferences(w, r)
	case r.Method == http.MethodGet && path == "/customers":
		h.handleCustomers(w, r)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
		parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/")
		customerID := parts[0]
		if len(parts) > 1 && parts[1] == "history" {
			h.handleCustomerHistory(w, r, customerID)
		} else {
			h.handleCustomer(w, r, customerID)
		}
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/recovery/"):
		customerID := strings.TrimPrefix(path, "/recovery/")
		h.handleRecovery(w, r, customerID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
		customerID := strings.TrimPrefix(path, "/config/")
		h.handleConfigRetrieve(w, r, customerID)
	case r.Method == http.MethodGet && path == "/assets/manifest":
		h.handleAssetsManifest(w, r)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"):
		filename := strings.TrimPrefix(path, "/assets/file/")
		h.handleAssetFile(w, r, filename)
	default:
		http.NotFound(w, r)
	}
}

func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
	authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// Extract customer_id from JSON
	var payload struct {
		CustomerID string `json:"customer_id"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	// Validate customer_id matches authenticated customer (unless global key)
	if !isGlobal && authCustomerID != payload.CustomerID {
		http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
		return
	}

	if err := h.store.SaveReport(payload.CustomerID, body); err != nil {
		h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Parse and save app telemetry (backward-compatible — old controllers won't have this field)
	var telemetryPayload struct {
		AppTelemetry []store.AppTelemetryRecord `json:"app_telemetry"`
	}
	if err := json.Unmarshal(body, &telemetryPayload); err == nil && len(telemetryPayload.AppTelemetry) > 0 {
		if err := h.store.SaveAppTelemetry(payload.CustomerID, time.Now(), telemetryPayload.AppTelemetry); err != nil {
			h.logger.Printf("[WARN] Failed to save app telemetry for %s: %v", payload.CustomerID, err)
		}
	}

	h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))

	// Build response with optional customer_blocked flag
	resp := map[string]interface{}{"status": "ok"}
	if custCfg, err := h.store.GetCustomerConfig(payload.CustomerID); err == nil && custCfg != nil {
		if custCfg.Status == "blocked" {
			resp["customer_blocked"] = true
		}
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// defaultHostPollSeconds is the cadence the hub hands every agent this slice (no
// per-host override UI yet — that is a later slice).
const defaultHostPollSeconds = 900

// maxHostReportBytes bounds a host-report body. Larger than the controller path's
// 1 MiB because host reports carry the full guest list + (later) storage/backup
// arrays. We read one byte past it and reject explicitly (413) rather than letting
// LimitReader silently truncate — a truncated-but-valid JSON would otherwise be
// accepted as a partial report, dropping guests from the mirror.
const maxHostReportBytes = 4 << 20 // 4 MiB

// hostReportPayload is the subset of the agent host-report (slice-3 contract,
// §3 / agent spec §4) the hub needs for denorm + guest reality. The remaining fields
// (backups/restore_tests/pbs_snapshots/audit_tail) are ignored, so an empty or absent
// collection is accepted without error.
//
// storage_targets (slice 5) is now parsed: the agent populates it, and the hub accepts
// + persists it. Persistence is the full report_json row (which carries the targets
// verbatim) plus the denorm counts below — the RICH manifest schema (desired class/role/
// policy/creds) is hub-owned and lands in slice 10; this slice only mirrors what the agent
// observes.
type hostReportPayload struct {
	HostID       string `json:"host_id"`
	AgentVersion string `json:"agent_version"`
	Host         struct {
		CPUPercent    float64 `json:"cpu_percent"`
		MemoryPercent float64 `json:"memory_percent"`
		DiskPercent   float64 `json:"disk_percent"`
	} `json:"host"`
	Guests []struct {
		VMID              int    `json:"vmid"`
		Name              string `json:"name"`
		Status            string `json:"status"`
		ControllerVersion string `json:"controller_version"`
	} `json:"guests"`
	StorageTargets []hostStorageTarget `json:"storage_targets"`
	Backups        []hostBackup        `json:"backups"`       // slice 6
	RestoreTests   []hostRestoreTest   `json:"restore_tests"` // slice 6
	PBSSnapshots   []hostPBSSnapshot   `json:"pbs_snapshots"` // slice 6 Phase B
	Cloudflared    struct {
		Status string `json:"status"`
	} `json:"cloudflared"`
}

// hostPBSSnapshot mirrors the agent's hub.PBSSnapshot wire contract (slice 6 Phase B). The
// hub persists it via report_json and surfaces a FAILED verify prominently (the loudest
// offsite-DR signal — same treatment as a failed restore-test).
type hostPBSSnapshot struct {
	Namespace   string `json:"namespace"`
	BackupType  string `json:"backup_type"`
	BackupID    string `json:"backup_id"`
	BackupTime  string `json:"backup_time"`
	SizeBytes   int64  `json:"size_bytes"`
	Owner       string `json:"owner"`
	Protected   bool   `json:"protected"`
	Encrypted   bool   `json:"encrypted"`
	VerifyState string `json:"verify_state"`
	VerifyUPID  string `json:"verify_upid,omitempty"`
}

// hostBackup / hostRestoreTest mirror the agent's hub.Backup / hub.RestoreTest wire
// contract field-for-field (slice 6, doc 03 §8). DUPLICATED contract — the golden stays
// byte-identical with felhom-agent's copy and the key-set tests guard drift. The hub
// persists these via report_json (no new columns this slice) and surfaces a FAILED
// restore-test prominently (the loudest DR signal). The rich backup policy is slice 10.
type hostBackup struct {
	TargetID         string   `json:"target_id"`
	VMID             int      `json:"vmid"`
	Archive          string   `json:"archive"`
	Mode             string   `json:"mode"`
	CrashConsistent  bool     `json:"crash_consistent"`
	SizeBytes        int64    `json:"size_bytes"`
	Success          bool     `json:"success"`
	Error            string   `json:"error,omitempty"`
	StartedAt        string   `json:"started_at"`
	DurationSeconds  float64  `json:"duration_seconds"`
	UncoveredVolumes []string `json:"uncovered_volumes"`
}

type hostRestoreTest struct {
	SourceArchive   string  `json:"source_archive"`
	SourceTier      string  `json:"source_tier"`
	ScratchVMID     int     `json:"scratch_vmid"`
	Pass            bool    `json:"pass"`
	Verified        string  `json:"verified"`
	Error           string  `json:"error,omitempty"`
	TestedAt        string  `json:"tested_at"`
	DurationSeconds float64 `json:"duration_seconds"`
	// Warnings are the guest-start task's warning line(s) on a PASS (e.g. the systemd-nesting
	// advisory). The verdict is liveness-only, so a passed restore-test can carry warnings.
	Warnings []string `json:"warnings,omitempty"`
	// WarningsRecognized is true iff every warning is the known-benign anchor. Absent ⇒ false,
	// which is the SAFE default: the hub then treats it as an unrecognized warning (the louder
	// path), so a missing flag can only over-notice, never hide a real warning.
	WarningsRecognized bool `json:"warnings_recognized,omitempty"`
}

// hostStorageTarget mirrors the agent's hub.StorageTarget wire contract field-for-field.
// It is a DUPLICATED contract (no shared types module yet); testdata/host-report.golden.json
// must stay byte-identical with felhom-agent's copy and the key-set test guards drift.
// The hub does not act on these yet beyond persisting + counting them (slice 10 adds the
// authoritative manifest), but mirroring the full shape keeps the cross-repo contract honest.
type hostStorageTarget struct {
	Name          string  `json:"name"`
	Type          string  `json:"type"`
	DurableID     string  `json:"durable_id"`
	State         string  `json:"state"`
	Reachable     bool    `json:"reachable"`
	TotalBytes    int64   `json:"total_bytes"`
	UsedBytes     int64   `json:"used_bytes"`
	AvailBytes    int64   `json:"avail_bytes"`
	UsedFraction  float64 `json:"used_fraction"`
	Content       string  `json:"content"`
	MountPath     string  `json:"mount_path"`
	BackingDevice string  `json:"backing_device"`
	ClassHint     string  `json:"class_hint"`
	Role          string  `json:"role"`
	ThinPool      *struct {
		DataUsedFraction     float64  `json:"data_used_fraction"`
		MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
	} `json:"thin_pool,omitempty"`
	Smart struct {
		Health               string `json:"health"`
		TemperatureC         *int   `json:"temperature_c"`
		PowerOnHours         *int   `json:"power_on_hours"`
		ReallocatedSectors   *int   `json:"reallocated_sectors"`
		PendingSectors       *int   `json:"pending_sectors"`
		OfflineUncorrectable *int   `json:"offline_uncorrectable"`
		CriticalWarning      *int   `json:"critical_warning"`
		MediaErrors          *int   `json:"media_errors"`
		PercentageUsed       *int   `json:"percentage_used"`
	} `json:"smart"`
}

// handleHostReport ingests the agent's host-report (the heartbeat) and returns the
// control envelope (agent spec §5).
func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
	hostID, custID, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, maxHostReportBytes+1))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	if len(body) > maxHostReportBytes {
		http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
		return
	}
	var rep hostReportPayload
	if err := json.Unmarshal(body, &rep); err != nil || rep.HostID == "" {
		http.Error(w, "Invalid payload: host_id required", http.StatusBadRequest)
		return
	}

	if isGlobal {
		// Global-key bootstrap: trust body.host_id but require the host to exist
		// (it must be minted first) and resolve its customer from the row.
		host, err := h.store.GetHost(rep.HostID)
		if err != nil {
			h.logger.Printf("[ERROR] host lookup failed for %s: %v", rep.HostID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		if host == nil {
			http.Error(w, "Unknown host_id (mint via /admin/hosts first)", http.StatusBadRequest)
			return
		}
		hostID, custID = rep.HostID, host.CustomerID
	} else if rep.HostID != hostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}

	running := 0
	for _, g := range rep.Guests {
		if g.Status == "running" {
			running++
		}
	}
	denorm := store.HostReportDenorm{
		AgentVersion:      rep.AgentVersion,
		CPUPercent:        rep.Host.CPUPercent,
		MemoryPercent:     rep.Host.MemoryPercent,
		DiskPercent:       rep.Host.DiskPercent,
		GuestTotal:        len(rep.Guests),
		GuestRunning:      running,
		CloudflaredStatus: rep.Cloudflared.Status,
	}
	if err := h.store.SaveHostReport(hostID, custID, body, denorm); err != nil {
		h.logger.Printf("[ERROR] Failed to save host-report from %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	for _, g := range rep.Guests {
		status := g.Status
		if status == "" {
			status = "unknown"
		}
		guest := &store.Guest{
			GuestID:           store.GuestID(hostID, g.VMID),
			CustomerID:        custID,
			HostID:            hostID,
			VMID:              g.VMID,
			DisplayName:       g.Name,
			Status:            status,
			ControllerVersion: g.ControllerVersion,
		}
		if err := h.store.UpsertGuestFromReport(guest); err != nil {
			// A guest upsert failure must not drop the whole report (liveness).
			h.logger.Printf("[WARN] Failed to upsert guest %s: %v", guest.GuestID, err)
		}
	}

	// storage_targets (slice 5): persisted as part of report_json above. Count + surface
	// disconnected ones in the log (the slice-10 manifest will reconcile them; for now the
	// signal is the visibility — a disconnected target is the storage analog of host-down).
	disconnected := 0
	for _, st := range rep.StorageTargets {
		if st.State == "disconnected" {
			disconnected++
		}
	}
	if disconnected > 0 {
		h.logger.Printf("[WARN] host %s reports %d disconnected storage target(s) of %d",
			hostID, disconnected, len(rep.StorageTargets))
	}

	// restore_tests (slice 6): a FAILED self-restore-test is the loudest DR signal there is
	// — surface it prominently. A PASS that carried start warnings (e.g. the systemd-nesting
	// advisory) is surfaced too: INFO when every warning is recognized-benign, escalated to
	// WARN when an UNRECOGNIZED warning stood out (as loud as a failed PBS verify is for
	// backups), so a real restore warning can't hide behind a green pass. A backup whose
	// vzdump failed is also worth a warning.
	for _, rt := range rep.RestoreTests {
		switch {
		case !rt.Pass:
			h.logger.Printf("[WARN] host %s restore-test FAILED: archive=%s tier=%s scratch=%d err=%q",
				hostID, rt.SourceArchive, rt.SourceTier, rt.ScratchVMID, rt.Error)
		case len(rt.Warnings) == 0:
			// clean pass — nothing to surface here (counted in the summary line below).
		case rt.WarningsRecognized:
			h.logger.Printf("[INFO] host %s restore-test passed WITH WARNINGS (recognized): archive=%s tier=%s warnings=%v",
				hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
		default:
			h.logger.Printf("[WARN] host %s restore-test passed WITH UNRECOGNIZED WARNINGS: archive=%s tier=%s warnings=%v",
				hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
		}
	}
	for _, bk := range rep.Backups {
		if !bk.Success {
			h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q",
				hostID, bk.TargetID, bk.VMID, bk.Error)
		}
	}
	// pbs_snapshots (slice 6 Phase B): a FAILED PBS verify is the loudest offsite-DR signal.
	for _, ps := range rep.PBSSnapshots {
		if ps.VerifyState == "failed" {
			h.logger.Printf("[WARN] host %s PBS verify FAILED: %s/%s ns=%s owner=%s",
				hostID, ps.BackupType, ps.BackupID, ps.Namespace, ps.Owner)
		}
	}

	h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d backups, %d restore-tests, %d pbs-snapshots, %d bytes)",
		hostID, len(rep.Guests), len(rep.StorageTargets), len(rep.Backups), len(rep.RestoreTests), len(rep.PBSSnapshots), len(body))

	blocked := false
	if cc, err := h.store.GetCustomerConfig(custID); err == nil && cc != nil && cc.Status == "blocked" {
		blocked = true
	}

	// Control envelope (slice 10A): the cheap change-notification. desired_generation is the
	// host's current generation (the agent re-fetches the full desired-state only when it
	// advances past its cached one); has_signed_ops flags a non-empty signed-jobs queue (the
	// agent fetches/executes them in 10B). Both degrade safely to their slice-4 defaults on a
	// store error — a heartbeat must never fail on the control channel.
	var desiredGen int64
	if host, err := h.store.GetHost(hostID); err == nil && host != nil {
		desiredGen = host.DesiredGeneration
	}
	hasSignedOps := false
	if n, err := h.store.CountSignedJobs(hostID); err == nil && n > 0 {
		hasSignedOps = true
	}
	resp := map[string]interface{}{
		"status":                "ok",
		"poll_interval_seconds": defaultHostPollSeconds,
		"blocked":               blocked,
		"desired_generation":    desiredGen,
		"has_signed_ops":        hasSignedOps,
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// handleAdminCreateHost mints a host identity (host_id + per-host api_key).
//
// PROVISIONAL (slice-3 bootstrap): global-key only, so the demo agent can
// authenticate before enrollment (slices 7–8) exists. Enrollment will mint host
// identity + pin signing keys; this endpoint should be removed/locked down then
// (tracked under doc 05 §11 auth-tightening at cutover).
func (h *Handler) handleAdminCreateHost(w http.ResponseWriter, r *http.Request) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		CustomerID  string `json:"customer_id"`
		HostID      string `json:"host_id"`
		DisplayName string `json:"display_name"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}
	cc, err := h.store.GetCustomerConfig(req.CustomerID)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cc == nil {
		http.Error(w, "Unknown customer_id", http.StatusBadRequest)
		return
	}

	hostID := req.HostID
	if hostID == "" {
		sfx, err := configgen.RandomHex(3) // 6 hex chars — human-legible for the demo
		if err != nil {
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		hostID = req.CustomerID + "-" + sfx
	}
	apiKey, err := configgen.RandomHex(32)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if err := h.store.UpsertHost(&store.Host{HostID: hostID, CustomerID: req.CustomerID, APIKey: apiKey}); err != nil {
		h.logger.Printf("[ERROR] Failed to mint host for %s: %v", req.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] provisional host mint: %s (customer %s)", hostID, req.CustomerID)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
}

// escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob
// (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct
// (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts
// them (it has no recovery code).
type escrowUploadRequest struct {
	BlobB64        string `json:"blob_b64"`        // base64 of the opaque R-wrapped blob (ciphertext)
	KeyFingerprint string `json:"key_fingerprint"` // for operator display only
	Posture        string `json:"posture"`         // e.g. "zero_knowledge"
	CreatedAt      string `json:"created_at"`      // RFC3339
	// Slice 10D.1 — optional DR bundle, stored alongside the K-escrow (both opaque/non-secret).
	IdentityBlobB64 string          `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token}
	DirectiveJSON   json.RawMessage `json:"directive,omitempty"`         // non-secret directive (pbs repo/ns, expected fp, tunnel id)
}

// handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key
// (a host may only write its own escrow; the global operator key is also accepted). The hub keeps
// the ciphertext and never opens it. Last-write-wins (rotation). No serving this slice (slice 10).
func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	// A per-host key may only write ITS OWN escrow; the global key may write any.
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap; the blob is ~hundreds of bytes
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req escrowUploadRequest
	if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
		http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
		return
	}
	blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
	if err != nil || len(blob) == 0 {
		http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
		return
	}
	createdAt := req.CreatedAt
	if createdAt == "" {
		createdAt = time.Now().UTC().Format(time.RFC3339)
	}
	// Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this.
	if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt); err != nil {
		h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	// Slice 10D.1: optionally store the IDENTITY escrow blob + the non-secret DR directive alongside
	// the K-escrow (both opaque / non-secret — no usable secret hub-side). Additive: a slice-7
	// upload without these is unchanged.
	if req.IdentityBlobB64 != "" {
		idBlob, derr := base64.StdEncoding.DecodeString(req.IdentityBlobB64)
		if derr != nil || len(idBlob) == 0 {
			http.Error(w, "Invalid payload: identity_blob_b64 not valid base64", http.StatusBadRequest)
			return
		}
		directive := req.DirectiveJSON
		if len(directive) == 0 || !json.Valid(directive) {
			directive = json.RawMessage("{}")
		}
		if err := h.store.SaveHostDRBundle(pathHostID, idBlob, string(directive)); err != nil {
			h.logger.Printf("[ERROR] Failed to store DR bundle for host %s: %v", pathHostID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes + directive)", pathHostID, len(idBlob))
	}
	h.logger.Printf("[INFO] stored opaque escrow blob for host %s (%d bytes, posture=%s, fp=%s)",
		pathHostID, len(blob), req.Posture, req.KeyFingerprint)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleGetDesiredState serves a host its authoritative desired-state (slice 10A). Per-host key,
// SELF-SCOPED: a host reads ONLY its own (the global operator key may read any). The agent fetches
// this when the heartbeat envelope's desired_generation has advanced past its cached one. The
// response carries the generation the state corresponds to, so the agent caches it atomically.
func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil {
		h.logger.Printf("[ERROR] desired-state lookup for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	desired := host.DesiredJSON
	if strings.TrimSpace(desired) == "" {
		desired = "{}"
	}
	resp := map[string]interface{}{
		"generation":    host.DesiredGeneration,
		"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// handleGetJobs serves a host its pending signed-op blobs (slice 10A). Per-host key, SELF-SCOPED.
// The blobs are OPAQUE (the hub never forged or opened them); the agent verifies + executes them
// in 10B. 10A only serves the queue.
func (h *Handler) handleGetJobs(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	jobs, err := h.store.GetSignedJobs(pathHostID)
	if err != nil {
		h.logger.Printf("[ERROR] jobs lookup for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	out := make([]map[string]string, 0, len(jobs))
	for _, j := range jobs {
		out = append(out, map[string]string{
			"job_id":     j.JobID,
			"blob_b64":   base64.StdEncoding.EncodeToString(j.Blob),
			"created_at": j.CreatedAt,
		})
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]interface{}{"jobs": out})
}

// handleDeleteJob clears a processed job from a host's queue (slice 10B). Per-host key,
// SELF-SCOPED (a host clears only its own jobs; the global key may clear any). Idempotent.
func (h *Handler) handleDeleteJob(w http.ResponseWriter, r *http.Request, pathHostID, jobID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" || jobID == "" {
		http.Error(w, "Missing host_id or job_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	if err := h.store.DeleteSignedJob(pathHostID, jobID); err != nil {
		h.logger.Printf("[ERROR] delete job %s for %s: %v", jobID, pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] host %s cleared signed-op job %s (executed or rejected)", pathHostID, jobID)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleAdminSetDesiredState sets a host's desired-state (slice 10A). GLOBAL/operator key ONLY —
// a per-host key cannot author its own intent. The body is the desired-state JSON (opaque to the
// hub: it stores + serves bytes, never validates/interprets the schema — the agent/CLI owns it).
// Writing BUMPS desired_generation so the next heartbeat signals the agent to re-fetch.
func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	// Validate it is well-formed JSON (the hub does not interpret the schema, but a malformed
	// blob would break the agent's parse — reject it at the door).
	if !json.Valid(body) {
		http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
		return
	}
	gen, err := h.store.SetHostDesired(pathHostID, body)
	if err == sql.ErrNoRows {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	if err != nil {
		h.logger.Printf("[ERROR] set desired-state for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] admin-set desired-state for host %s (generation now %d, %d bytes)", pathHostID, gen, len(body))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "generation": gen})
}

// handleAdminEnqueueJob appends an opaque signed-op blob to a host's queue (slice 10A). GLOBAL key
// ONLY. The blob is pre-signed off-hub (the hub holds no signing key); the hub stores it verbatim.
// This is the minimal operator path to seed the queue so HasSignedOps/serving are exercisable; the
// rich operator/signing UX is later. Execution is 10B.
func (h *Handler) handleAdminEnqueueJob(w http.ResponseWriter, r *http.Request, pathHostID string) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil || host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		JobID   string `json:"job_id"`
		BlobB64 string `json:"blob_b64"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
		http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
		return
	}
	blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
	if err != nil || len(blob) == 0 {
		http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
		return
	}
	if req.JobID == "" {
		req.JobID, _ = configgen.RandomHex(8)
	}
	if err := h.store.EnqueueSignedJob(pathHostID, req.JobID, blob); err != nil {
		h.logger.Printf("[ERROR] enqueue job for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] enqueued signed-op job %s for host %s (%d bytes)", req.JobID, pathHostID, len(blob))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "job_id": req.JobID})
}

// allowedEventTypes lists all valid event_type values the Hub accepts.
var allowedEventTypes = map[string]bool{
	// Controller-pushed events
	"controller_started":          true,
	"controller_updated":          true,
	"backup_completed":            true,
	"backup_failed":               true,
	"db_dump_completed":           true,
	"db_dump_failed":              true,
	"backup_integrity_ok":         true,
	"backup_integrity_failed":     true,
	"crossdrive_completed":        true,
	"crossdrive_failed":           true,
	"storage_disconnected":        true,
	"storage_reconnected":         true,
	"disk_warning":                true,
	"disk_critical":               true,
	"health_degraded":             true,
	"health_critical":             true,
	"health_recovered":            true,
	"app_deployed":                true,
	"app_removed":                 true,
	"disaster_recovery_started":   true,
	"disaster_recovery_completed": true,
	// Hub-generated events
	"node_stale":     true,
	"node_down":      true,
	"node_recovered": true,
	// Hub-generated host-domain events (v0.7.0, slice 3)
	"host_stale":             true,
	"host_down":              true,
	"host_recovered":         true,
	"expected_backup_missed": true,
	"expected_dbdump_missed": true,
	// Special
	"test": true,
}

// handleEvent processes structured events from controllers (new endpoint, replaces /notify for updated controllers).
func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
	authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID string          `json:"customer_id"`
		EventType  string          `json:"event_type"`
		Severity   string          `json:"severity"`
		Message    string          `json:"message"`
		Details    json.RawMessage `json:"details"`
	}
	if err := json.Unmarshal(body, &payload); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	if payload.CustomerID == "" || payload.EventType == "" {
		http.Error(w, "customer_id and event_type are required", http.StatusBadRequest)
		return
	}

	// Validate customer_id matches authenticated customer (unless global key)
	if !isGlobal && authCustomerID != payload.CustomerID {
		http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
		return
	}

	// Validate event_type
	if !allowedEventTypes[payload.EventType] {
		http.Error(w, fmt.Sprintf("Invalid event_type: %s", payload.EventType), http.StatusBadRequest)
		return
	}

	// Validate/default severity
	switch payload.Severity {
	case "info", "warning", "error":
	default:
		payload.Severity = "info"
	}

	// Store details as JSON string
	detailsStr := "{}"
	if len(payload.Details) > 0 && string(payload.Details) != "null" {
		detailsStr = string(payload.Details)
	}

	_, err = h.store.SaveEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
	if err != nil {
		h.logger.Printf("[ERROR] Failed to save event from %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Event from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)

	// Dispatch notifications (non-blocking)
	if h.dispatcher != nil {
		go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"ok":true}`))
}

func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
	customers, err := h.store.GetCustomers()
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customers: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	type customerJSON struct {
		ID                 string     `json:"id"`
		Name               string     `json:"name"`
		ControllerVersion  string     `json:"controller_version"`
		ControllerURL      string     `json:"controller_url,omitempty"`
		HealthStatus       string     `json:"health_status"`
		LastSeen           time.Time  `json:"last_seen"`
		CPUPercent         float64    `json:"cpu_percent"`
		MemoryPercent      float64    `json:"memory_percent"`
		ContainerTotal     int        `json:"container_total"`
		ContainerRunning   int        `json:"container_running"`
		BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
	}

	result := make([]customerJSON, 0, len(customers))
	for _, c := range customers {
		result = append(result, customerJSON{
			ID:                 c.CustomerID,
			Name:               c.CustomerName,
			ControllerVersion:  c.ControllerVersion,
			ControllerURL:      c.ControllerURL,
			HealthStatus:       c.HealthStatus,
			LastSeen:           c.ReceivedAt,
			CPUPercent:         c.CPUPercent,
			MemoryPercent:      c.MemoryPercent,
			ContainerTotal:     c.ContainerTotal,
			ContainerRunning:   c.ContainerRunning,
			BackupLastSnapshot: c.BackupLastSnapshot,
		})
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
	customer, err := h.store.GetCustomer(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if customer == nil {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	// Return the full report JSON directly
	w.Write([]byte(customer.ReportJSON))
}

func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) {
	period := r.URL.Query().Get("period")
	var since time.Duration
	switch period {
	case "7d":
		since = 7 * 24 * time.Hour
	case "30d":
		since = 30 * 24 * time.Hour
	default:
		since = 24 * time.Hour
	}

	history, err := h.store.GetCustomerHistory(customerID, since)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	type historyEntry struct {
		ReceivedAt    time.Time `json:"received_at"`
		HealthStatus  string    `json:"health_status"`
		CPUPercent    float64   `json:"cpu_percent"`
		MemoryPercent float64   `json:"memory_percent"`
	}

	result := make([]historyEntry, 0, len(history))
	for _, h := range history {
		result = append(result, historyEntry{
			ReceivedAt:    h.ReceivedAt,
			HealthStatus:  h.HealthStatus,
			CPUPercent:    h.CPUPercent,
			MemoryPercent: h.MemoryPercent,
		})
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

// handleNotify processes notification events from customer controllers.
func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID string `json:"customer_id"`
		EventType  string `json:"event_type"`
		Severity   string `json:"severity"`
		Message    string `json:"message"`
		Details    string `json:"details"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" || payload.EventType == "" {
		http.Error(w, "Invalid payload: customer_id and event_type required", http.StatusBadRequest)
		return
	}

	h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)

	// Check if customer is blocked
	if h.store.IsCustomerBlocked(payload.CustomerID) {
		h.logger.Printf("[INFO] Notification suppressed for blocked customer %s", payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "customer blocked", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"blocked"}`))
		return
	}

	// Look up customer notification preferences
	prefs, err := h.store.GetNotificationPrefs(payload.CustomerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get notification prefs for %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Check if customer has email configured and event type is enabled
	if prefs == nil || prefs.Email == "" {
		h.logger.Printf("[INFO] No email configured for %s, skipping notification", payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "no email configured", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_email"}`))
		return
	}

	// Check if event type is in the enabled list (test events always pass)
	eventEnabled := payload.EventType == "test"
	for _, e := range prefs.EnabledEvents {
		if e == payload.EventType {
			eventEnabled = true
			break
		}
	}
	if !eventEnabled {
		h.logger.Printf("[INFO] Event %s not enabled for %s, skipping", payload.EventType, payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "event not enabled", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"event_disabled"}`))
		return
	}

	// Send email via Resend API
	if h.resendAPIKey == "" {
		h.logger.Printf("[WARN] Resend API key not configured, cannot send notification email")
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "resend api key not configured", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_api_key"}`))
		return
	}

	subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details)
	sendErr := h.sendResendEmail(prefs.Email, subject, emailBody)
	if sendErr != nil {
		h.logger.Printf("[ERROR] Failed to send notification email to %s: %v", prefs.Email, sendErr)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "failed", sendErr.Error(), "customer")
		http.Error(w, "Failed to send email", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Notification email sent to %s for %s/%s", prefs.Email, payload.CustomerID, payload.EventType)
	h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "sent", "", "customer")

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok","sent":true}`))
}

// handleSavePreferences stores notification preferences pushed from a customer controller.
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID    string   `json:"customer_id"`
		Email         string   `json:"email"`
		EnabledEvents []string `json:"enabled_events"`
		CooldownHours int      `json:"cooldown_hours"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents, payload.CooldownHours); err != nil {
		h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleInfraBackupPush stores an infrastructure snapshot from a controller.
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID string `json:"customer_id"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	if err := h.store.SaveInfraBackup(payload.CustomerID, body); err != nil {
		h.logger.Printf("[ERROR] Failed to save infra backup for %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Infra backup saved for %s (%d bytes)", payload.CustomerID, len(body))
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleInfraBackupGet returns the infrastructure backup for a customer.
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	data, err := h.store.GetInfraBackup(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get infra backup for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if data == nil {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

// handleInfraBackupVersions returns a list of backup versions for a customer.
// Auth: Bearer token.
func (h *Handler) handleInfraBackupVersions(w http.ResponseWriter, r *http.Request, customerID string) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	versions, err := h.store.ListInfraBackupVersions(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to list infra backup versions for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if versions == nil {
		versions = []store.InfraBackupVersion{}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(versions)
}

// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery.
// Auth: X-Retrieval-Password header (same as config retrieval).
func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) {
	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}

	cfg, err := h.store.GetCustomerConfig(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Recovery: failed to get customer config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	// Generate controller.yaml
	var configYAML string
	if h.templateProvider != nil {
		yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
		if err != nil {
			h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		configYAML = yamlOutput
	}

	// Fetch infra backup (optional — may not exist for new customers)
	var infraBackup json.RawMessage
	hasInfraBackup := false

	// Support ?version=ID for selecting a specific backup version
	if versionStr := r.URL.Query().Get("version"); versionStr != "" {
		var versionID int64
		if _, err := fmt.Sscanf(versionStr, "%d", &versionID); err == nil {
			if data, err := h.store.GetInfraBackupByID(versionID); err == nil && data != nil {
				infraBackup = data
				hasInfraBackup = true
			}
		}
	} else {
		if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
			infraBackup = data
			hasInfraBackup = true
		}
	}

	// Include version list for version picker
	var backupVersions []store.InfraBackupVersion
	if versions, err := h.store.ListInfraBackupVersions(customerID); err == nil {
		backupVersions = versions
	}

	resp := struct {
		CustomerID     string                     `json:"customer_id"`
		ConfigYAML     string                     `json:"config_yaml"`
		InfraBackup    json.RawMessage            `json:"infra_backup"`
		HasInfraBackup bool                       `json:"has_infra_backup"`
		BackupVersions []store.InfraBackupVersion `json:"backup_versions,omitempty"`
	}{
		CustomerID:     customerID,
		ConfigYAML:     configYAML,
		InfraBackup:    infraBackup,
		HasInfraBackup: hasInfraBackup,
		BackupVersions: backupVersions,
	}

	h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v, versions=%d)", customerID, hasInfraBackup, len(backupVersions))
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resp)
}

// handleConfigRetrieve returns a generated controller.yaml for a customer.
// Auth: X-Retrieval-Password header (not Bearer token).
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}

	cfg, err := h.store.GetCustomerConfig(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	// Constant-time comparison to prevent timing attacks
	if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	if h.templateProvider == nil {
		http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
		return
	}

	yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
	w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
	w.Write([]byte(yamlOutput))
}

// sendResendEmail sends an email via the Resend HTTP API.
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
	payload := map[string]interface{}{
		"from":    h.fromEmail,
		"to":      []string{to},
		"subject": subject,
		"text":    textBody,
	}

	jsonData, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshaling email payload: %w", err)
	}

	req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(jsonData))
	if err != nil {
		return fmt.Errorf("creating request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+h.resendAPIKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("sending request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return fmt.Errorf("resend API returned %d: %s", resp.StatusCode, string(respBody))
	}

	return nil
}

// formatNotificationEmail creates a Hungarian email subject and body.
func formatNotificationEmail(customerID, eventType, severity, message, details string) (string, string) {
	severityLabel := map[string]string{
		"info":     "Információ",
		"warning":  "Figyelmeztetés",
		"error":    "Hiba",
		"critical": "Kritikus",
	}
	label := severityLabel[severity]
	if label == "" {
		label = severity
	}

	subject := fmt.Sprintf("[Felhom] %s: %s", label, message)

	now := time.Now().Format("2006-01-02 15:04")
	emailText := fmt.Sprintf(`Kedves Ügyfél!

A Felhom rendszered a következő figyelmeztetést jelezte:

%s

Részletek:
- Szerver: %s
- Időpont: %s
- Szint: %s
- Típus: %s`, message, customerID, now, label, eventType)

	if details != "" {
		emailText += fmt.Sprintf("\n- Megjegyzés: %s", details)
	}

	emailText += `

Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.

Üdvözlettel,
Felhom.eu monitoring`

	return subject, emailText
}

// --- Asset endpoints ---

func (h *Handler) handleAssetsManifest(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if h.assetsMgr == nil {
		http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
		return
	}

	data, err := h.assetsMgr.MarshalManifestJSON()
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filename string) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if h.assetsMgr == nil {
		http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
		return
	}

	h.assetsMgr.ServeFile(w, r, filename)
}
