package web

import (
	"bytes"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
	"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
	"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
	"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
	"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
	"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
	"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
	"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
	"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
	"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)

type Server struct {
	cfg          *config.Config
	stackMgr     *stacks.Manager
	cpuCollector *system.CPUCollector
	backupMgr    *backup.Manager
	scheduler    *scheduler.Scheduler
	settings     *settings.Settings
	alertManager *AlertManager
	notifier     *notify.Notifier
	updater      *selfupdate.Updater
	logger       *log.Logger
	version      string
	encKey       []byte // AES-256 key for decrypting app.yaml values
	tmpl         *template.Template

	sessions       map[string]*session
	sessionsMu     sync.RWMutex
	loginAttempts  map[string]*loginAttempt
	loginAttemptMu sync.Mutex
	done           chan struct{}
	closeOnce      sync.Once

	// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
	fileBrowserMu sync.Mutex

	// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
	// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
	// this kills the per-call http.Transport leak that exhausted the controller's ephemeral ports to
	// the agent's :8443 after ~5 days of uptime (see agentClient()).
	agentCli     *agentapi.Client
	agentCliErr  error
	agentCliOnce sync.Once

	// Hub push status callback — set via SetHubPushStatus for monitoring page
	hubPushStatusFn func() HubPushStatusData

	// Asset syncer for Hub-managed assets (optional)
	assetsSyncer *assets.Syncer

	// App-to-app integration manager (optional).
	// M25: atomic because the constructor launches the SyncFileBrowserMounts
	// goroutine (which reads this) BEFORE SetIntegrationManager runs in main.go —
	// so a plain field would be a data race (the init-only happens-before that
	// covers the other Set* fields does NOT hold for this one).
	integrationMgr atomic.Pointer[integrations.Manager]

	// App export/import engine (optional)
	appExporter *appexport.Exporter

	// Whole-guest backup trigger (the quiesce loop; optional — nil on an unprovisioned guest or when
	// quiesce is disabled). Drives the "Mentés most" button: the CONTROLLER owns quiescing, so the
	// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
	backupTrigger BackupTrigger

	// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
	// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
	mailShim MailShimController

	// Debug mode support
	logBuffer      *LogBuffer
	debugCallbacks *DebugCallbacks
	startTime      time.Time
}

// MailShimController is the lifecycle handle the settings toggle uses to start/stop the
// app-email SMTP shim at runtime. Satisfied by *mailrelay.Lifecycle (kept as an interface
// to avoid a web→mailrelay import cycle risk and to allow a fake in tests).
type MailShimController interface {
	Apply(enabled bool) error
	Running() bool
}

// SetMailShim wires the app-email shim lifecycle (optional).
func (s *Server) SetMailShim(c MailShimController) { s.mailShim = c }

func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
	s := &Server{
		cfg:           cfg,
		stackMgr:      stackMgr,
		cpuCollector:  cpuCollector,
		backupMgr:     backupMgr,
		scheduler:     sched,
		settings:      sett,
		alertManager:  alertMgr,
		notifier:      notif,
		updater:       updater,
		logger:        logger,
		version:       version,
		sessions:      make(map[string]*session),
		loginAttempts: make(map[string]*loginAttempt),
		done:          make(chan struct{}),
	}

	if cfg.Logging.Level == "debug" {
		logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
		logger.Printf("[DEBUG] [web] NewServer: backup=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
			backupMgr != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
	}

	s.loadTemplates()
	go s.cleanupSessions()
	// Drive-absent gate reconcile (intermediary-mount model): the periodic absent/return detector that
	// replaced the retired slice-8C watchdog. Stops+blocks apps whose drive vanished, auto-restarts them
	// when it returns. No-op when the agent is unreachable.
	go s.driveGateLoop()

	// Log auth source on startup
	if sett != nil && sett.GetPasswordHash() != "" {
		logger.Printf("[INFO] [web] Auth: using password from settings.json")
	} else if cfg.Web.PasswordHash != "" {
		logger.Printf("[INFO] [web] Auth: using password from controller.yaml")
	} else {
		logger.Printf("[INFO] [web] Auth: no password configured — dashboard is open")
	}

	// Sync FileBrowser config on startup to ensure mounts and sources are current.
	// After a restore, a flag file signals that the database should be reset
	// (stale source prefs from initial install). Consume the flag and reset.
	fbResetFlag := filepath.Join(cfg.Paths.DataDir, ".fb-reset")
	if _, err := os.Stat(fbResetFlag); err == nil {
		os.Remove(fbResetFlag)
		go s.SyncFileBrowserMountsReset()
	} else {
		go s.SyncFileBrowserMounts()
	}

	return s
}

// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
// Must be called before ListenAndServe (all Set* methods are init-time only).
func (s *Server) SetEncryptionKey(key []byte) {
	s.encKey = key
}

func (s *Server) loadTemplates() {
	s.tmpl = template.Must(
		template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
	)
	if s.isDebug() {
		names := s.tmpl.Templates()
		s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
	}
}

// HubPushStatusData holds hub push status for the monitoring page.
type HubPushStatusData struct {
	LastAttempt time.Time
	LastSuccess time.Time
	LastError   string
	Consecutive int
}

// SetHubPushStatus sets the hub push status callback for the monitoring page.
func (s *Server) SetHubPushStatus(fn func() HubPushStatusData) {
	s.hubPushStatusFn = fn
}

// SetAssetsSyncer sets the Hub asset syncer for resolving app assets.
func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
	s.assetsSyncer = as
}

// SetIntegrationManager sets the app-to-app integration manager.
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
	s.integrationMgr.Store(mgr)
}

// SetLogBuffer sets the in-memory log ring buffer for the debug log viewer.
func (s *Server) SetLogBuffer(lb *LogBuffer) {
	s.logBuffer = lb
}

// SetDebugCallbacks sets the callbacks for debug endpoints that need main.go wiring.
func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
	s.debugCallbacks = dc
}

// SetAppExporter sets the app export/import engine.
func (s *Server) SetAppExporter(e *appexport.Exporter) {
	s.appExporter = e
}

// BackupTrigger forces an app-consistent whole-guest backup NOW (the quiesce loop satisfies it).
// Returns quiesce.ErrBackupInProgress when a cycle is already running (single-flight).
type BackupTrigger interface {
	TriggerNow() error
}

// SetBackupTrigger wires the whole-guest backup trigger (the quiesce loop) for the "Mentés most"
// button. Optional — left nil on an unprovisioned guest or when quiesce is disabled (the button then
// renders disabled with an explanatory note).
func (s *Server) SetBackupTrigger(t BackupTrigger) {
	s.backupTrigger = t
}

// SetStartTime records the controller start time for uptime calculation.
func (s *Server) SetStartTime(t time.Time) {
	s.startTime = t
}

// isDebug returns true if the controller is running in debug mode.
func (s *Server) isDebug() bool {
	return s.cfg.Logging.Level == "debug"
}

// ServeDebugAPI handles /api/debug/* routes (JSON API for debug operations).
// Called from the mux carve-out; debug mode check is done here.
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
	if !s.isDebug() {
		http.NotFound(w, r)
		return
	}
	s.handleDebugAPI(w, r)
}

// ServeHTTP handles all non-API web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
	}

	switch {
	case path == "/" || path == "/dashboard":
		s.dashboardHandler(w, r)
	case path == "/stacks":
		s.stacksHandler(w, r)
	case path == "/backups":
		s.backupsHandler(w, r)
	case path == "/monitoring":
		s.monitoringHandler(w, r)
	case path == "/settings":
		s.settingsHandler(w, r)
	case path == "/storage" && r.Method == http.MethodGet:
		s.storagePageHandler(w, r)
	case path == "/storage/network" && r.Method == http.MethodGet:
		s.storageNetworkPageHandler(w, r)
	case path == "/settings/notifications" && r.Method == http.MethodGet:
		s.settingsNotificationsPageHandler(w, r)
	case path == "/settings/security" && r.Method == http.MethodGet:
		s.settingsSecurityPageHandler(w, r)
	case path == "/settings/password" && r.Method == http.MethodPost:
		s.settingsPasswordHandler(w, r)
	case path == "/settings/notifications" && r.Method == http.MethodPost:
		s.settingsNotificationsHandler(w, r)
	case path == "/settings/notifications/test" && r.Method == http.MethodPost:
		s.settingsNotificationsTestHandler(w, r)
	case path == "/settings/app-email" && r.Method == http.MethodPost:
		s.settingsAppEmailHandler(w, r)
	case path == "/settings/storage/add" && r.Method == http.MethodPost:
		s.settingsStorageAddHandler(w, r)
	case path == "/settings/storage/remove" && r.Method == http.MethodPost:
		s.settingsStorageRemoveHandler(w, r)
	case path == "/settings/storage/default" && r.Method == http.MethodPost:
		s.settingsStorageDefaultHandler(w, r)
	case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
		s.settingsStorageSchedulableHandler(w, r)
	case path == "/settings/storage/label" && r.Method == http.MethodPost:
		s.settingsStorageLabelHandler(w, r)
	case path == "/storage/init" && r.Method == http.MethodGet:
		s.storageWizardPageHandler(w, r, "storage_init")
	case path == "/storage/attach" && r.Method == http.MethodGet:
		s.storageWizardPageHandler(w, r, "storage_attach")
	// D1: the wizard pages moved under /storage — permanent redirects keep old links working.
	case path == "/settings/storage/init" && r.Method == http.MethodGet:
		http.Redirect(w, r, "/storage/init", http.StatusMovedPermanently)
	case path == "/settings/storage/attach" && r.Method == http.MethodGet:
		http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
	case path == "/backup/restore" && r.Method == http.MethodPost:
		s.backupRestoreHandler(w, r)
	// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
	case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
		s.backupTier2RestoreHandler(w, r)
	// Off-box (NAS) restic-SFTP backup (Part B)
	case path == "/backup/offbox/config" && r.Method == http.MethodPost:
		s.offboxConfigHandler(w, r)
	case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
		s.offboxToggleHandler(w, r)
	case path == "/backup/offbox/run" && r.Method == http.MethodPost:
		s.offboxRunHandler(w, r)
	case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
		s.offboxRestoreHandler(w, r)
	// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
	case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
		s.offboxConfirmEscrowHandler(w, r)
	case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
		s.offboxInjectPasswordHandler(w, r)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
		name := strings.TrimPrefix(path, "/stacks/")
		name = strings.TrimSuffix(name, "/export")
		s.exportPageHandler(w, r, name)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
		name := strings.TrimPrefix(path, "/stacks/")
		name = strings.TrimSuffix(name, "/logs")
		s.logsHandler(w, r, name)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
		name := strings.TrimPrefix(path, "/stacks/")
		name = strings.TrimSuffix(name, "/deploy")
		s.deployHandler(w, r, name)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
		name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
		s.tier2ConfigPageHandler(w, r, name)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
		name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
		s.tier2ConfigSaveHandler(w, r, name)
	case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/app-email") && r.Method == http.MethodPost:
		name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/app-email")
		s.appEmailToggleHandler(w, r, name)
	case path == "/import":
		s.importPageHandler(w, r)
	case path == "/static/style.css":
		s.serveCSSHandler(w, r)
	case path == "/static/chart.min.js":
		s.serveChartJSHandler(w, r)
	case strings.HasPrefix(path, "/static/fonts/"):
		s.serveFontHandler(w, r, strings.TrimPrefix(path, "/static/fonts/"))
	case path == "/static/felhom-logo.svg":
		s.serveLogoHandler(w, r)
	case path == "/static/favicon.svg":
		s.serveFaviconHandler(w, r)
	case strings.HasPrefix(path, "/static/assets/"):
		s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
	case strings.HasPrefix(path, "/apps/"):
		slug := strings.TrimPrefix(path, "/apps/")
		s.appDetailHandler(w, r, slug)
	case path == "/debug":
		if !s.isDebug() {
			http.NotFound(w, r)
			return
		}
		s.debugPageHandler(w, r)
	default:
		s.logger.Printf("[WARN] [web] 404 Not Found: %s %s", r.Method, path)
		http.NotFound(w, r)
	}
}

// CatchAllMiddleware intercepts requests to non-controller hosts and serves
// a branded error page (for stopped/undeployed app subdomains). Requests to
// the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
	controllerHost := "felhom." + s.cfg.Customer.Domain
	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host := r.Host
		if idx := strings.LastIndex(host, ":"); idx != -1 {
			host = host[:idx]
		}
		// Pass through: controller host, localhost (healthcheck/internal), or empty
		if strings.EqualFold(host, controllerHost) || host == "" ||
			host == "localhost" || host == "127.0.0.1" {
			next.ServeHTTP(w, r)
			return
		}
		if s.isDebug() {
			s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
		}
		s.serveCatchAll(w, r, host)
	})
}

// serveCatchAll renders a branded page for requests reaching a stopped/undeployed
// app subdomain. Served without auth since the user has no session on this host.
func (s *Server) serveCatchAll(w http.ResponseWriter, r *http.Request, host string) {
	domain := s.cfg.Customer.Domain
	subdomain := ""
	suffix := "." + domain
	if strings.HasSuffix(host, suffix) {
		subdomain = strings.TrimSuffix(host, suffix)
	}

	data := map[string]interface{}{
		"Domain":        domain,
		"ControllerURL": "https://felhom." + domain,
		"Host":          host,
	}

	if subdomain != "" {
		if stack, ok := s.findStackBySubdomain(subdomain); ok {
			data["AppName"] = stack.Meta.DisplayName
			data["AppSlug"] = stack.Meta.Slug
			data["AppLogoURL"] = s.cfg.AppLogoURL(stack.Meta.Slug)
			if stack.Deployed {
				data["Status"] = "stopped"
				data["StatusText"] = "Az alkalmazás jelenleg le van állítva"
			} else {
				data["Status"] = "not_deployed"
				data["StatusText"] = "Az alkalmazás nincs telepítve"
			}
		} else {
			data["Status"] = "unknown"
			data["StatusText"] = "Ez az oldal nem található"
		}
	} else {
		data["Status"] = "unknown"
		data["StatusText"] = "Ez az oldal nem található"
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusNotFound)
	if err := s.tmpl.ExecuteTemplate(w, "catchall", data); err != nil {
		s.logger.Printf("[ERROR] [web] Catch-all template error: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
	}
}

// findStackBySubdomain looks up the stack that owns the given subdomain.
func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
	for _, stack := range s.stackMgr.GetStacks() {
		// Check deployed app.yaml SUBDOMAIN env first
		if stack.Deployed {
			if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
				if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
					return &stack, true
				}
			}
		}
		// Fallback to metadata subdomain
		if stack.Meta.Subdomain == subdomain {
			return &stack, true
		}
	}
	return nil, false
}

// primaryHDDPath returns the default storage path, or the legacy config value.
func (s *Server) primaryHDDPath() string {
	if p := s.settings.GetDefaultStoragePath(); p != "" {
		return p
	}
	return s.cfg.Paths.HDDPath
}

func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
	var buf bytes.Buffer
	if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	buf.WriteTo(w)
}

// executeTemplate renders a template with CSRF data auto-injected into the data map.
// Use this instead of render() for all authenticated page handlers.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
	if data == nil {
		data = make(map[string]interface{})
	}
	data["CSRFField"] = s.csrfField(r)
	data["CSRFToken"] = s.csrfToken(r)
	var buf bytes.Buffer
	if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	buf.WriteTo(w)
}

// --- Static file / asset serving ---

func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
	data, err := templateFS.ReadFile("templates/style.css")
	if err != nil {
		http.Error(w, "CSS not found", 500)
		return
	}
	w.Header().Set("Content-Type", "text/css; charset=utf-8")
	w.Header().Set("Cache-Control", "public, max-age=3600")
	w.Write(data)
}

func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	w.Write(chartJS)
}

// serveFontHandler serves the vendored woff2 files embedded in the binary
// (self-hosted fonts — no Google Fonts CDN dependency on offline nodes).
func (s *Server) serveFontHandler(w http.ResponseWriter, r *http.Request, filename string) {
	filename = filepath.Base(filename)
	data, err := fontFS.ReadFile("static/fonts/" + filename)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "font/woff2")
	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	w.Write(data)
}

func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
	// Try synced asset first (allows logo updates via Hub without rebuild)
	if s.assetsSyncer != nil {
		path := s.assetsSyncer.Resolve("felhom-logo.svg")
		if _, err := os.Stat(path); err == nil {
			w.Header().Set("Cache-Control", "public, max-age=86400")
			http.ServeFile(w, r, path)
			return
		}
	}
	// Fallback to embedded logo
	w.Header().Set("Content-Type", "image/svg+xml")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	fmt.Fprint(w, FelhomLogoSVG)
}

func (s *Server) serveFaviconHandler(w http.ResponseWriter, r *http.Request) {
	// Try synced asset first
	if s.assetsSyncer != nil {
		path := s.assetsSyncer.Resolve("felhom-favicon.svg")
		if _, err := os.Stat(path); err == nil {
			w.Header().Set("Cache-Control", "public, max-age=86400")
			http.ServeFile(w, r, path)
			return
		}
	}
	// Fallback to embedded favicon
	w.Header().Set("Content-Type", "image/svg+xml")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	fmt.Fprint(w, FelhomFaviconSVG)
}

// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
const assetsDir = "/usr/share/felhom/assets"

func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
	filename = filepath.Base(filename)

	var path string
	if s.assetsSyncer != nil {
		path = s.assetsSyncer.Resolve(filename)
	} else {
		path = filepath.Join(assetsDir, filename)
	}

	if _, err := os.Stat(path); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Cache-Control", "public, max-age=86400")
	http.ServeFile(w, r, path)
}
