package api

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
	"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
	cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
	"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
	"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
	"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
	"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
	"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
	catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
	"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)

// Router handles all /api/* requests.
type Router struct {
	cfg          *config.Config
	configPath   string
	sett         *settings.Settings
	stackMgr     *stacks.Manager
	syncer       *catalogsync.Syncer
	cpuCollector *system.CPUCollector
	backupMgr    *backup.Manager
	metricsStore *metrics.MetricsStore
	updater      *selfupdate.Updater
	notifier     *notify.Notifier
	logger       *log.Logger

	// restart triggers a graceful self-restart (config-apply + the manual restart button).
	// Defaults to a real exit-after-flush so Docker's restart:unless-stopped brings the
	// process back with fresh config; tests inject a recorder via SetRestarter.
	restart func()

	// triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a
	// geo settings change so the hub reflects the new state immediately). Nil = no-op.
	triggerReportPush func()

	// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
	OnGeoRelevantChange func()

	// Asset syncer for on-demand Hub asset sync
	assetsSyncer *assets.Syncer

	// Geo-restriction sync manager
	geoSync *cf.GeoSyncManager

	// App-to-app integration manager (nil if not configured)
	integrationMgr *integrations.Manager

	debug bool
}

// SetDebug enables or disables debug logging for API routing.
func (r *Router) SetDebug(on bool) {
	r.debug = on
}

func (r *Router) dbg(format string, args ...interface{}) {
	if r.debug {
		r.logger.Printf("[DEBUG] [api] "+format, args...)
	}
}

// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
	r.assetsSyncer = as
}

// SetGeoSync sets the geo-restriction sync manager.
func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) {
	r.geoSync = gs
}

// SetIntegrationManager sets the app-to-app integration manager.
func (r *Router) SetIntegrationManager(im *integrations.Manager) {
	r.integrationMgr = im
}

func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
	r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
	r.restart = func() { gracefulSelfRestart(r.logger) }
	return r
}

// SetRestarter overrides the graceful-restart action. Tests inject a recorder so the
// process is not actually killed.
func (r *Router) SetRestarter(fn func()) { r.restart = fn }

// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
// The provided func MUST be non-blocking (it is called from request handlers).
func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn }

// reportPushNow fires the report-push trigger if wired. Called after a state change the
// hub should reflect immediately (geo settings/sync) instead of waiting for the next cycle.
func (r *Router) reportPushNow() {
	if r.triggerReportPush != nil {
		r.triggerReportPush()
	}
}

type apiResponse struct {
	OK      bool        `json:"ok"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
	Message string      `json:"message,omitempty"`
}

// ServeHTTP routes /api/* requests.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	path := strings.TrimPrefix(req.URL.Path, "/api")
	path = strings.TrimSuffix(path, "/")

	r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, path)

	switch {
	// GET /api/stacks
	case path == "/stacks" && req.Method == http.MethodGet:
		r.listStacks(w, req)

	// POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks
	case path == "/stacks/rescan" && req.Method == http.MethodPost:
		r.rescanStacks(w, req)

	// F4: /api/stacks/rescan with a non-POST method must be a clear 405, not fall through to the
	// GET /stacks/{name} lookup below (which returned the misleading "stack not found: rescan").
	case path == "/stacks/rescan":
		w.Header().Set("Allow", http.MethodPost)
		writeJSON(w, http.StatusMethodNotAllowed, apiResponse{OK: false, Error: "method not allowed: use POST /api/stacks/rescan"})

	// GET /api/stacks/{name}
	case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
		r.getStack(w, req, trimSegment(path, "/stacks/"))

	// GET /api/selfupdate/status — must be before hasSuffix-based stack cases
	case path == "/selfupdate/status" && req.Method == http.MethodGet:
		r.selfupdateStatus(w, req)

	// POST /api/selfupdate/check — must be before hasSuffix-based stack cases
	case path == "/selfupdate/check" && req.Method == http.MethodPost:
		r.selfupdateCheck(w, req)

	// POST /api/selfupdate/update — must be before hasSuffix("/update") stack case
	case path == "/selfupdate/update" && req.Method == http.MethodPost:
		r.selfupdateTrigger(w, req)

	// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
	case path == "/config/apply" && req.Method == http.MethodPost:
		r.configApply(w, req)

	// GET /api/config/hash — return current config file hash
	case path == "/config/hash" && req.Method == http.MethodGet:
		r.configHash(w, req)

	// GET /api/config — return raw controller.yaml content
	case path == "/config" && req.Method == http.MethodGet:
		r.configContent(w, req)

	// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
	case path == "/selfrestart" && req.Method == http.MethodPost:
		r.selfRestart(w, req)

	// --- Integration routes (must be before hasSuffix-based stack cases) ---

	// GET /api/integrations/{provider} — list integrations for a provider
	case strings.HasPrefix(path, "/integrations/") && !strings.Contains(strings.TrimPrefix(path, "/integrations/"), "/") && req.Method == http.MethodGet:
		provider := strings.TrimPrefix(path, "/integrations/")
		r.listIntegrations(w, provider)

	// POST /api/integrations/{provider}/{target} — toggle integration
	case strings.HasPrefix(path, "/integrations/") && req.Method == http.MethodPost:
		rest := strings.TrimPrefix(path, "/integrations/")
		parts := strings.SplitN(rest, "/", 2)
		if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
			writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid integration path"})
			return
		}
		r.toggleIntegration(w, req, parts[0], parts[1])

	// GET /api/stacks/{name}/deploy-fields
	case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
		r.getDeployFields(w, req, extractName(path, "/deploy-fields"))

	// POST /api/stacks/{name}/deploy
	case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
		r.deployStack(w, req, extractName(path, "/deploy"))

	// POST /api/stacks/{name}/start
	case hasSuffix(path, "/start") && req.Method == http.MethodPost:
		r.actionStack(w, "start", extractName(path, "/start"))

	// POST /api/stacks/{name}/stop
	case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
		r.actionStack(w, "stop", extractName(path, "/stop"))

	// POST /api/stacks/{name}/restart
	case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
		r.actionStack(w, "restart", extractName(path, "/restart"))

	// POST /api/stacks/{name}/update
	case hasSuffix(path, "/update") && req.Method == http.MethodPost:
		r.actionStack(w, "update", extractName(path, "/update"))

	// POST /api/stacks/{name}/optional-config
	case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
		r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))

	// GET /api/stacks/{name}/logs
	case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
		r.getStackLogs(w, req, extractName(path, "/logs"))

	// GET /api/stacks/{name}/hdd-data
	case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
		r.getStackHDDData(w, req, extractName(path, "/hdd-data"))

	// GET /api/stacks/{name}/backup-data
	case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
		r.getStackBackupData(w, req, extractName(path, "/backup-data"))

	// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
	case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
		r.removeStack(w, req, extractName(path, "/remove"))

	// DELETE /api/stacks/{name}
	case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
		r.deleteStack(w, req, trimSegment(path, "/stacks/"))

	// POST /api/sync — trigger immediate catalog sync
	case path == "/sync" && req.Method == http.MethodPost:
		r.triggerSync(w, req)

	// GET /api/system/info
	case path == "/system/info" && req.Method == http.MethodGet:
		r.systemInfo(w, req)

	// GET /api/backup/status
	case path == "/backup/status" && req.Method == http.MethodGet:
		r.backupStatus(w, req)

	// GET /api/backup/restore-status — async restore progress for the backups-page banner (Part B).
	// Distinct from /backup/status (which proxies the agent's PBS whole-guest status).
	case path == "/backup/restore-status" && req.Method == http.MethodGet:
		r.backupRestoreStatus(w, req)

	// GET /api/backup/snapshots?stack=<name> — restorable keep-side backups for the restore panel
	case path == "/backup/snapshots" && req.Method == http.MethodGet:
		r.backupSnapshots(w, req)

	// POST /api/backup/run
	case path == "/backup/run" && req.Method == http.MethodPost:
		r.triggerBackup(w, req)

	// POST /api/backup/tier2 — run off-drive Tier 2 copies for all HDD apps
	case path == "/backup/tier2" && req.Method == http.MethodPost:
		r.triggerTier2(w, req)

	// GET /api/metrics/system
	case path == "/metrics/system" && req.Method == http.MethodGet:
		r.metricsSystem(w, req)

	// GET /api/metrics/containers/summary
	case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
		r.metricsContainerSummary(w, req)

	// GET /api/metrics/containers/{name}
	case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
		name := strings.TrimPrefix(path, "/metrics/containers/")
		r.metricsContainer(w, req, name)

	// GET /api/metrics/sysinfo
	case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
		r.metricsSysInfo(w, req)

	// POST /api/assets/sync — trigger immediate asset sync from Hub
	case path == "/assets/sync" && req.Method == http.MethodPost:
		r.triggerAssetSync(w, req)

	// GET /api/assets/status — get asset sync status
	case path == "/assets/status" && req.Method == http.MethodGet:
		r.assetSyncStatus(w, req)

	// --- Geo-restriction endpoints ---

	// GET /api/geo/status — current geo settings + sync state
	case path == "/geo/status" && req.Method == http.MethodGet:
		r.geoStatus(w, req)

	// POST /api/geo/settings — update global geo settings
	case path == "/geo/settings" && req.Method == http.MethodPost:
		r.geoUpdateSettings(w, req)

	// POST /api/geo/sync — trigger manual Cloudflare sync
	case path == "/geo/sync" && req.Method == http.MethodPost:
		r.geoTriggerSync(w, req)

	// GET /api/geo/countries — full country list for search UI
	case path == "/geo/countries" && req.Method == http.MethodGet:
		r.geoCountries(w, req)

	// POST /api/stacks/{name}/geo/override — set per-app geo override
	case hasSuffix(path, "/geo/override") && req.Method == http.MethodPost:
		r.geoSetAppOverride(w, req, extractName(path, "/geo/override"))

	// DELETE /api/stacks/{name}/geo/override — remove per-app geo override
	case hasSuffix(path, "/geo/override") && req.Method == http.MethodDelete:
		r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))

	default:
		r.dbg("no matching route: %s %s", req.Method, path)
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
	}
}

// HealthHandler responds to /api/health (no auth required).
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
}

// --- Stack handlers ---

func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}

func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
	r.logger.Printf("[INFO] [api] Manual stack rescan requested")
	if err := r.stackMgr.ScanStacks(); err != nil {
		r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}
	stackCount := len(r.stackMgr.GetStacks())
	r.logger.Printf("[INFO] [api] Stack rescan completed: %d stacks found", stackCount)
	writeJSON(w, http.StatusOK, apiResponse{
		OK:      true,
		Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount),
	})
}

func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
	stack, ok := r.stackMgr.GetStack(name)
	if !ok {
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}

func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
	meta, appCfg, err := r.stackMgr.GetDeployFields(name)
	if err != nil {
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
		return
	}

	data := map[string]interface{}{
		"metadata":   meta,
		"app_config": appCfg,
		"domain":     r.cfg.Customer.Domain,
		"logo_url":   r.cfg.AppLogoURL(meta.Slug),
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}

func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
	limitBody(w, req)
	r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
	r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)

	var body struct {
		Values map[string]string `json:"values"`
	}
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
		return
	}

	// Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its
	// reserved buffer, so customer apps can't fill the volume the infra containers (controller,
	// traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is
	// a safety net, not a security control, so a transient statfs failure must not block all deploys.
	if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
		r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
			name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
		writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
			"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
			hr.AvailGB, hr.ReserveGB)})
		return
	}

	deployReq := stacks.DeployRequest{
		StackName: name,
		Values:    body.Values,
	}

	warning, err := r.stackMgr.DeployStack(deployReq)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "already deployed") {
			status = http.StatusConflict
		}
		if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
			status = http.StatusBadRequest
		}
		writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
		return
	}

	// F6: the deploy runs asynchronously (compose pull/up + health happen after this returns; the UI
	// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
	// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
	// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
	resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
	if warning != "" {
		resp.Data = map[string]string{"warning": warning}
	}
	writeJSON(w, http.StatusAccepted, resp)

	// Push app deployed event to Hub
	if r.notifier != nil {
		displayName := name
		if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
			displayName = s.Meta.DisplayName
		}
		r.notifier.NotifyAppDeployed(name, displayName)
	}

	// Re-sync geo rules (new hostname may need to be added)
	if r.OnGeoRelevantChange != nil {
		go r.OnGeoRelevantChange()
	}

	// Re-apply integrations that target this newly deployed stack
	if r.integrationMgr != nil {
		go r.integrationMgr.OnStackStart(context.Background(), name)
	}
}

// startGatedByMissingDrive reports whether starting `name` must be BLOCKED because the drive its
// HDD_PATH points at is currently disconnected or decommissioned. Returns the storage path for the
// message. SSD-resident apps (no HDD_PATH) are never gated.
func (r *Router) startGatedByMissingDrive(name string) (bool, string) {
	cfg := r.stackMgr.LoadAppConfigByName(name)
	if cfg == nil {
		return false, ""
	}
	hdd := cfg.Env["HDD_PATH"]
	if hdd == "" {
		return false, ""
	}
	for _, sp := range r.sett.GetStoragePaths() {
		if sp.Path == hdd && (sp.Disconnected || sp.Decommissioned) {
			return true, hdd
		}
	}
	return false, ""
}

func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
	r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
	r.dbg("actionStack: action=%s name=%s", action, name)

	// Protected stacks only allow restart — block all other actions
	if r.cfg.IsProtectedStack(name) && action != "restart" {
		writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)})
		return
	}

	// Drive-absent gate: refuse to start an app whose data drive is currently disconnected/decommissioned
	// (the intermediary-mount gate). Starting it would let it write to the empty fail-closed stable path
	// or just crash-loop; block with a clear message until the drive returns (then the gate auto-restarts).
	if action == "start" {
		if gated, hdd := r.startGatedByMissingDrive(name); gated {
			writeJSON(w, http.StatusConflict, apiResponse{
				OK:    false,
				Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
			})
			return
		}
	}

	// Memory check before starting a stopped app
	if action == "start" {
		stackMemMB := r.stackMgr.StackMemoryMB(name)
		if stackMemMB > 0 {
			if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
				reservedMB := r.cfg.System.ReservedMemoryMB
				usableMB := totalMB - reservedMB
				if usableMB < 0 {
					usableMB = 0
				}
				afterMB := usedMB + stackMemMB
				if afterMB > usableMB {
					writeJSON(w, http.StatusConflict, apiResponse{
						OK:    false,
						Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
					})
					return
				}
			}
		}
	}

	var err error
	switch action {
	case "start":
		err = r.stackMgr.StartStack(name)
	case "stop":
		err = r.stackMgr.StopStack(name)
	case "restart":
		err = r.stackMgr.RestartStack(name)
	case "update":
		err = r.stackMgr.UpdateStack(name)
	}

	if err != nil {
		r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "protected") {
			status = http.StatusForbidden
		}
		if strings.Contains(err.Error(), "not found") {
			status = http.StatusNotFound
		}
		writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})

	// Trigger integration lifecycle hooks after successful action
	if r.integrationMgr != nil {
		switch action {
		case "start", "restart":
			go r.integrationMgr.OnStackStart(context.Background(), name)
		case "stop":
			go r.integrationMgr.OnStackStop(context.Background(), name)
		}
	}
}

func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
	limitBody(w, req)
	r.logger.Printf("[INFO] [api] Optional config update requested for stack: %s", name)

	var body struct {
		Values map[string]string `json:"values"`
	}
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
		return
	}

	if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
		r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
}

// --- Integration API handlers ---

func (r *Router) listIntegrations(w http.ResponseWriter, provider string) {
	if r.integrationMgr == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []integrations.StatusInfo{}})
		return
	}
	list := r.integrationMgr.ListForProvider(provider)
	if list == nil {
		list = []integrations.StatusInfo{}
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
}

func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, provider, target string) {
	limitBody(w, req)
	if r.integrationMgr == nil {
		writeJSON(w, http.StatusServiceUnavailable, apiResponse{OK: false, Error: "integrations not available"})
		return
	}

	var body struct {
		Enabled bool `json:"enabled"`
	}
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
		return
	}

	action := "enable"
	if !body.Enabled {
		action = "disable"
	}
	r.logger.Printf("[INFO] [api] Integration %s requested: %s:%s", action, provider, target)

	state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}

	msg := "Integráció engedélyezve"
	if !body.Enabled {
		msg = "Integráció kikapcsolva"
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
}

func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
	lines := 100
	if v := req.URL.Query().Get("lines"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 {
			lines = n
			if lines > 10000 {
				lines = 10000
			}
		}
	}

	output, err := r.stackMgr.GetLogs(name, lines)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}

func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
	resp, err := r.stackMgr.GetStackHDDData(name)
	if err != nil {
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}

func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
	if name == "" {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
		return
	}

	// Compute the felhom-data namespace root for this stack (drive-resident apps: the in-guest
	// mount IS the namespace; SSD-only: <systemDataPath>/felhom-data). Passing the namespace root
	// (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A.
	var nsRoot string
	if r.backupMgr != nil {
		nsRoot = r.backupMgr.AppNamespaceRoot(name)
	}

	resp, err := r.stackMgr.GetStackBackupData(name, nsRoot)
	if err != nil {
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}

func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
	if name == "" {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
		return
	}
	limitBody(w, req)
	r.logger.Printf("[INFO] [api] Remove requested for stack: %s", name)
	r.dbg("removeStack: name=%s", name)

	var body struct {
		RemoveHDDData bool `json:"remove_hdd_data"`
		RemoveBackups bool `json:"remove_backups"`
	}
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		body.RemoveHDDData = false
		body.RemoveBackups = false
	}
	r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)

	// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
	// backup has moved to the host agent; only the app-data DB-dump path is removed here.
	var backupPaths []string
	if body.RemoveBackups && r.backupMgr != nil {
		nsRoot := r.backupMgr.AppNamespaceRoot(name)
		if nsRoot != "" {
			backupPaths = append(backupPaths, backup.AppDBDumpPath(nsRoot, name))
		}
	}

	resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Remove failed for %s: %v", name, err)
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "protected") {
			status = http.StatusForbidden
		}
		if strings.Contains(err.Error(), "not found") {
			status = http.StatusNotFound
		}
		if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
			status = http.StatusConflict
		}
		writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
		return
	}

	// Clean up cross-drive backup config for this stack
	if r.sett != nil {
		if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
			r.logger.Printf("[WARN] [api] Failed to clean cross-drive config for %s: %v", name, err)
		}
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})

	// Push app removed event to Hub
	if r.notifier != nil {
		r.notifier.NotifyAppRemoved(name, name)
	}

	// Clean up integrations for removed stack
	if r.integrationMgr != nil {
		r.integrationMgr.OnStackRemove(context.Background(), name)
	}

	// Re-sync geo rules (hostname removed)
	if r.OnGeoRelevantChange != nil {
		go r.OnGeoRelevantChange()
	}
}

func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
	limitBody(w, req)
	r.logger.Printf("[INFO] [api] Delete requested for stack: %s", name)

	var body struct {
		RemoveHDDData bool `json:"remove_hdd_data"`
	}
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		body.RemoveHDDData = false
	}

	resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Delete failed for %s: %v", name, err)
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "protected") {
			status = http.StatusForbidden
		}
		if strings.Contains(err.Error(), "not found") {
			status = http.StatusNotFound
		}
		if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
			status = http.StatusConflict
		}
		writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
}

func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
	r.logger.Println("[INFO] [api] Manual catalog sync requested")
	result := r.syncer.TriggerSync()
	if !result.OK {
		writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
		return
	}
	r.logger.Printf("[INFO] [api] Catalog sync completed: %s", result.Message)
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
}

func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
	info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
	// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
	// "used" is not observable from the container. Report the controller's accurate committed-app memory
	// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
	if r.stackMgr != nil && info.TotalMemMB > 0 {
		if reqMB, _ := r.stackMgr.CommittedMemory(); reqMB >= 0 {
			used := uint64(reqMB)
			if used > info.TotalMemMB {
				used = info.TotalMemMB
			}
			info.UsedMemMB = used
			info.AvailMemMB = info.TotalMemMB - used
			info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
		}
	}
	syncStatus := r.syncer.Status()
	data := map[string]interface{}{
		"system":      info,
		"sync_status": syncStatus,
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}

// --- Backup handlers ---

func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
	if r.backupMgr == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
			"enabled": false,
		}})
		return
	}

	dbDump := r.backupMgr.GetStatus()
	data := map[string]interface{}{
		"enabled": true,
		"running": r.backupMgr.IsRunning(),
	}

	if dbDump != nil {
		data["db_dump"] = map[string]interface{}{
			"last_run": dbDump.LastRun,
			"success":  dbDump.Success,
			"duration": dbDump.Duration.String(),
			"count":    len(dbDump.Results),
		}
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}

// backupRestoreStatus (Part B) surfaces the async restore-op progress the backups page polls to drive
// its banner (running: <op>/<stack> → terminal last{ok,message}). Display-only; empty when idle.
func (r *Router) backupRestoreStatus(w http.ResponseWriter, _ *http.Request) {
	if r.backupMgr == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"running": false}})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.backupMgr.RestoreStatus()})
}

// validStackParam reports whether a stack name from a request is a safe single path segment
// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because
// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become
// a filesystem path outside the app's namespace root.
func validStackParam(name string) bool {
	if name == "" || name == "." || name == ".." {
		return false
	}
	if strings.ContainsAny(name, "/\\\x00") {
		return false
	}
	return name == filepath.Clean(name)
}

// backupSnapshots lists the restorable keep-side backups for one app — the data source of the
// /backups restore panel's snapshot dropdown (F1: this route was fetched by the template but never
// existed, so the dropdown could never populate and the restore button never enabled).
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
	stack := req.URL.Query().Get("stack")
	if !validStackParam(stack) {
		r.dbg("backupSnapshots: invalid stack param %q", stack)
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
		return
	}
	if r.backupMgr == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
		return
	}

	points, found := r.backupMgr.ListRestorePoints(stack)
	if !found {
		writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + stack})
		return
	}
	r.dbg("backupSnapshots: stack=%s points=%d", stack, len(points))
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: points})
}

// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
	r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
	if r.backupMgr == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
		return
	}

	if r.backupMgr.IsRunning() {
		r.dbg("triggerBackup: backup already running, rejecting")
		writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
		return
	}

	r.logger.Println("[INFO] [api] Manual app-data backup (DB dump) triggered")
	go r.backupMgr.RunDBDumps(context.Background())

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}

// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
	if r.backupMgr == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
		return
	}
	if r.backupMgr.IsRunning() {
		writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
		return
	}
	r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
	go r.backupMgr.RunAllTier2()
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
}

// --- Metrics handlers ---

func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
	if r.metricsStore == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
		return
	}

	from, to := parseTimeRange(req)
	resolution := parseResolution(req, 200)

	samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}

	// Flatten into arrays for Chart.js
	labels := make([]int64, len(samples))
	cpu := make([]float64, len(samples))
	memory := make([]float64, len(samples))
	temp := make([]float64, len(samples))
	load1 := make([]float64, len(samples))

	for i, s := range samples {
		labels[i] = s.Timestamp
		cpu[i] = s.CPUPercent
		memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
		temp[i] = s.TempCelsius
		load1[i] = s.LoadAvg1
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
		"labels": labels,
		"cpu":    cpu,
		"memory": memory,
		"temp":   temp,
		"load1":  load1,
	}})
}

func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
	if r.metricsStore == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
		return
	}

	summary, err := r.metricsStore.QueryContainerSummary()
	if err != nil {
		r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
}

func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
	if r.metricsStore == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
		return
	}

	from, to := parseTimeRange(req)
	resolution := parseResolution(req, 150)

	samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
	if err != nil {
		r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
		return
	}

	labels := make([]int64, len(samples))
	cpu := make([]float64, len(samples))
	memory := make([]float64, len(samples))

	for i, s := range samples {
		labels[i] = s.Timestamp
		cpu[i] = s.CPUPercent
		memory[i] = s.MemUsageMB
	}

	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
		"labels": labels,
		"cpu":    cpu,
		"memory": memory,
	}})
}

func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
	info := metrics.GetStaticInfo()
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
}

// parseTimeRange reads range or from/to query params.
func parseTimeRange(req *http.Request) (from, to time.Time) {
	to = time.Now()

	if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
		switch rangeStr {
		case "1h":
			from = to.Add(-1 * time.Hour)
		case "6h":
			from = to.Add(-6 * time.Hour)
		case "24h":
			from = to.Add(-24 * time.Hour)
		case "7d":
			from = to.Add(-7 * 24 * time.Hour)
		case "30d":
			from = to.Add(-30 * 24 * time.Hour)
		default:
			from = to.Add(-24 * time.Hour) // default 24h
		}
		return
	}

	if fromStr := req.URL.Query().Get("from"); fromStr != "" {
		if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
			from = t
		}
	}
	if toStr := req.URL.Query().Get("to"); toStr != "" {
		if t, err := time.Parse(time.RFC3339, toStr); err == nil {
			to = t
		}
	}
	if from.IsZero() {
		from = to.Add(-24 * time.Hour)
	}
	return
}

// parseResolution reads the resolution query param.
func parseResolution(req *http.Request, defaultVal int) int {
	if v := req.URL.Query().Get("resolution"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 {
			return n
		}
	}
	return defaultVal
}

// --- Helpers ---

func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }

func hasSubpath(path, prefix string) bool {
	rest := strings.TrimPrefix(path, prefix)
	return strings.Contains(rest, "/")
}

func trimSegment(path, prefix string) string {
	return strings.TrimPrefix(path, prefix)
}

func extractName(path, suffix string) string {
	s := strings.TrimPrefix(path, "/stacks/")
	name := strings.TrimSuffix(s, suffix)
	// C7: Reject path traversal characters — name is used in file paths and Docker commands.
	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
		return ""
	}
	return name
}

func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
}

func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
		return
	}
	result := r.updater.CheckForUpdate()
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}

func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
		return
	}
	if err := r.updater.TriggerUpdate("manual"); err != nil {
		writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
		return
	}
	r.logger.Println("[INFO] [api] Manual self-update triggered")
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
}

// --- Config apply handler ---

func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
	r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
	// Read YAML body (limit to 1MB)
	body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
		return
	}
	if len(body) == 0 {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
		return
	}

	// Validate it's parseable YAML by attempting to load it
	if _, err := config.LoadFromBytes(body); err != nil {
		r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
		return
	}

	// No-op guard: if the pushed config is byte-identical to what is already on disk, do
	// nothing — don't rewrite, don't restart. The hub may re-push the same config
	// idempotently, and a self-restart on every push would be a needless flap.
	if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
		r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
		return
	}

	// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
	// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
	// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
	if err := writeConfig0600(r.configPath, body); err != nil {
		r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
		return
	}

	// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
	// effect on restart — singletons such as the Cloudflare client are built once at
	// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
	r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
	flushResponse(w)
	if r.restart != nil {
		r.restart()
	}
}

// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
func writeConfig0600(path string, body []byte) error {
	tmpPath := path + ".tmp"
	if err := os.WriteFile(tmpPath, body, 0600); err != nil {
		return fmt.Errorf("writing temp config: %w", err)
	}
	if err := os.Rename(tmpPath, path); err != nil {
		os.Remove(tmpPath)
		if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
			return fmt.Errorf("writing config: %w", err)
		}
	}
	if err := os.Chmod(path, 0600); err != nil {
		return fmt.Errorf("chmod config 0600: %w", err)
	}
	return nil
}

func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
	hash, err := config.FileHash(r.configPath)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
}

func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
	data, err := os.ReadFile(r.configPath)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
		return
	}
	w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
	w.Write(data)
}

// --- Asset sync handlers ---

func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
	if r.assetsSyncer == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
		return
	}
	r.logger.Println("[INFO] [api] Manual asset sync requested")
	go func() {
		if err := r.assetsSyncer.Sync(context.Background()); err != nil {
			r.logger.Printf("[WARN] [api] Manual asset sync failed: %v", err)
		}
	}()
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
}

func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
	if r.assetsSyncer == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		log.Printf("[ERROR] [api] Failed to write JSON response: %v", err)
	}
}

// limitBody wraps the request body with a size limit (default 1MB).
func limitBody(w http.ResponseWriter, req *http.Request) {
	req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}
