// Package server wires the HTTP API and the embedded frontend into a single
// http.Server bound to the Tailscale interface (or 127.0.0.1 when dev_mode
// is enabled in config).
package server

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io/fs"
	"net"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	chimw "github.com/go-chi/chi/v5/middleware"
	"github.com/rs/zerolog"

	"github.com/operator/command-center/internal/auth"
	"github.com/operator/command-center/internal/config"
	"github.com/operator/command-center/internal/eventbus"
	"github.com/operator/command-center/internal/integrations/feralssh"
	"github.com/operator/command-center/internal/observability"
)

// SchedulerRefresher is the subset of *scrape.Scheduler the server uses. An
// interface is used so the server package does not import internal/scrape
// (which would create no cycle today, but keeps the surface narrow).
type SchedulerRefresher interface {
	Refresh(trackerID string) error
}

// TrackerCookieFetcher is the subset of *scrape.Hygiene the server uses to
// fetch tracker-gated URLs (the /api/torrents/add-from-url endpoint pulls a
// private .torrent file with the stored session cookie attached). Defined
// as an interface here so the server package doesn't have to import
// internal/scrape directly.
type TrackerCookieFetcher interface {
	Do(ctx context.Context, req *http.Request, trackerID string) (*http.Response, error)
}

// Server is the wired HTTP service. Construct one with New, then call Start.
// Shutdown is graceful; a context cancellation is the signal to exit.
type Server struct {
	cfg           *config.SystemConfig
	logger        zerolog.Logger
	health        *observability.HealthChecker
	events        *observability.EventRecorder
	sqlite        *sql.DB
	scheduler     SchedulerRefresher
	auth          *AuthDeps
	clients       ClientsRegistry
	bus           *eventbus.Bus
	webhooks      *WebhookDeps
	automation    *AutomationDeps
	notifications *NotificationDeps
	intelligence  *IntelligenceDeps
	simulation    *SimulationDeps
	tqm           *TqmDeps
	budgets       *BudgetsDeps
	polish        *PolishDeps
	metrics       *observability.Metrics
	cookieFetcher TrackerCookieFetcher
	feeds         *FeedsDeps
	secretsWrite  SecretsWriter
	diskReportToken string // bearer token for the /api/disk/report ingress (CC_DISK_TOKEN); "" disables
	portSyncToken   string // bearer token for the /api/portsync/report ingress (CC_PORTSYNC_TOKEN); "" disables
	jellyfinX     *JellyfinXConfig // Jellyfin X-library toggle config; nil disables
	radarr        *RadarrConfig // Radarr search/add config; nil disables
	feralSSH      *feralssh.Client // SSH client to Feral seedbox for the Tree feature; nil disables /api/tree/*
	webFS         fs.FS // sub-fs rooted at the dist directory; nil disables frontend serving
	bindAddr      string

	httpServer *http.Server
}

// Options bundles the dependencies of Server. All fields except WebFS,
// SQLite, Scheduler, and Auth are required. nil-able fields disable the
// corresponding feature surface (useful in tests).
type Options struct {
	Config        *config.SystemConfig
	Logger        zerolog.Logger
	Health        *observability.HealthChecker
	Events        *observability.EventRecorder
	SQLite        *sql.DB
	Scheduler     SchedulerRefresher
	Auth          *AuthDeps
	Clients       ClientsRegistry
	Bus           *eventbus.Bus
	Webhooks      *WebhookDeps
	Automation    *AutomationDeps
	Notifications *NotificationDeps
	Intelligence  *IntelligenceDeps
	Simulation    *SimulationDeps
	Tqm           *TqmDeps
	Budgets       *BudgetsDeps
	Polish        *PolishDeps
	Metrics       *observability.Metrics
	// CookieFetcher is the hygiene primitive used to attach tracker session
	// cookies on outbound HTTP requests issued by the server (currently:
	// /api/torrents/add-from-url's .torrent fetch). nil disables tracker-
	// cookie attachment; the endpoint falls back to a plain http.Get.
	CookieFetcher TrackerCookieFetcher
	// Feeds is the RSS-feed config + secrets-resolver bundle. nil disables
	// the /api/feeds surface (feed list + on-demand fetch).
	Feeds *FeedsDeps
	// SecretsWriter exposes Set + Delete on the age-encrypted secrets
	// store so the /api/secrets/{key} routes can place / refresh
	// credentials from the UI instead of requiring an SSH session.
	SecretsWriter SecretsWriter
	// DiskReportToken is the bearer token the Feral du-cron uses to POST
	// /api/disk/report. Empty disables the ingress (handler 503s).
	DiskReportToken string
	// PortSyncToken is the bearer token the home-PC Proton port-sync script
	// uses to POST /api/portsync/report. Empty disables the ingress (503s).
	PortSyncToken string
	// JellyfinX configures the toggleable adult-content Jellyfin library.
	// nil (or empty URL/token) disables the /api/jellyfin/x routes.
	JellyfinX *JellyfinXConfig
	// Radarr configures the in-CC movie search/add. nil disables /api/radarr.
	Radarr *RadarrConfig
	// FeralSSH is the SSH client to the Feral seedbox the Tree feature uses
	// for hardlink-aware deletes outside qBit's download dir. nil disables
	// the /api/tree/* surface (handlers return 503).
	FeralSSH *feralssh.Client
	// WebFS is a filesystem rooted at the frontend bundle's top directory
	// (so /index.html lives at "/index.html"). nil disables frontend serving;
	// the root path then returns 404 with a hint.
	WebFS fs.FS
}

// New resolves the bind address (Tailscale interface or 127.0.0.1 fallback in
// dev_mode) and returns a Server ready to Start. Returns an error if the
// Tailscale interface is required but not present.
func New(opts Options) (*Server, error) {
	if opts.Config == nil {
		return nil, errors.New("server: nil config")
	}
	if opts.Health == nil {
		return nil, errors.New("server: nil health checker")
	}

	addr, err := resolveBindAddr(opts.Config, opts.Logger)
	if err != nil {
		return nil, err
	}

	return &Server{
		cfg:           opts.Config,
		logger:        opts.Logger.With().Str("component", "server").Logger(),
		health:        opts.Health,
		events:        opts.Events,
		sqlite:        opts.SQLite,
		scheduler:     opts.Scheduler,
		auth:          opts.Auth,
		clients:       opts.Clients,
		bus:           opts.Bus,
		webhooks:      opts.Webhooks,
		automation:    opts.Automation,
		notifications: opts.Notifications,
		intelligence:  opts.Intelligence,
		simulation:    opts.Simulation,
		tqm:           opts.Tqm,
		budgets:       opts.Budgets,
		polish:        opts.Polish,
		metrics:       opts.Metrics,
		cookieFetcher: opts.CookieFetcher,
		feeds:         opts.Feeds,
		secretsWrite:  opts.SecretsWriter,
		diskReportToken: opts.DiskReportToken,
		portSyncToken:   opts.PortSyncToken,
		jellyfinX:     opts.JellyfinX,
		radarr:        opts.Radarr,
		feralSSH:      opts.FeralSSH,
		webFS:         opts.WebFS,
		bindAddr:      addr,
	}, nil
}

// BindAddr returns the host:port the server will listen on. Useful for
// startup logging.
func (s *Server) BindAddr() string { return s.bindAddr }

// Start runs the HTTP server until ctx is canceled, then shuts it down with a
// short grace period. Returns any non-shutdown error.
func (s *Server) Start(ctx context.Context) error {
	h := s.buildHandler()
	s.httpServer = &http.Server{
		Addr:         s.bindAddr,
		Handler:      h,
		ReadTimeout:  time.Duration(s.cfg.Listen.ReadTimeoutSeconds) * time.Second,
		WriteTimeout: time.Duration(s.cfg.Listen.WriteTimeoutSeconds) * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() {
		s.logger.Info().Str("addr", s.bindAddr).Msg("listening")
		err := s.httpServer.ListenAndServe()
		if !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		} else {
			errCh <- nil
		}
	}()

	select {
	case <-ctx.Done():
		shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		s.logger.Info().Msg("shutting down http server")
		if err := s.httpServer.Shutdown(shutCtx); err != nil {
			return fmt.Errorf("server: shutdown: %w", err)
		}
		return <-errCh
	case err := <-errCh:
		return err
	}
}

// buildHandler is split out for testability (see server_test.go).
func (s *Server) buildHandler() http.Handler {
	r := chi.NewRouter()

	r.Use(chimw.RequestID)
	r.Use(chimw.RealIP)
	r.Use(chimw.Recoverer)
	r.Use(s.requestLogger)
	if s.auth != nil {
		r.Use(auth.NewMiddleware(s.auth.Sessions).Handler)
	}

	r.Route("/api", func(api chi.Router) {
		api.Route("/system", func(sys chi.Router) {
			sys.Get("/health", s.handleHealth)
			sys.Get("/events", s.handleEvents)
		})
		if s.auth != nil {
			api.Route("/auth", func(a chi.Router) {
				a.Get("/status", s.handleAuthStatus)
				a.Get("/me", s.handleAuthMe)
				a.Post("/logout", s.handleAuthLogout)
				a.Post("/recovery", s.handleRecovery)
				a.Route("/webauthn", func(wa chi.Router) {
					wa.Post("/register/start", s.handleWebAuthnRegisterStart)
					wa.Post("/register/finish", s.handleWebAuthnRegisterFinish)
					wa.Post("/login/start", s.handleWebAuthnLoginStart)
					wa.Post("/login/finish", s.handleWebAuthnLoginFinish)
				})
				a.Get("/devices", s.handleListDevices)
				a.Delete("/devices/{id}", s.handleDeleteDevice)
			})
		}
		if s.sqlite != nil {
			api.Route("/trackers", func(t chi.Router) {
				t.Get("/", s.handleListTrackers)
				t.Get("/{id}", s.handleGetTracker)
				t.Get("/{id}/snapshots", s.handleTrackerSnapshots)
				t.Post("/{id}/refresh", s.handleTrackerRefresh)
			})
			api.Route("/torrents", func(t chi.Router) {
				t.Get("/", s.handleListTorrents)
				t.Post("/add", s.handleAddTorrent)
				// add-from-url: GET for share-sheet / iOS Shortcut friendliness
				// (one-tap from a tracker page), POST for the in-UI form +
				// bookmarklet. Both accept the same parameters; GET reads them
				// from the query string, POST from a JSON body.
				t.Get("/add-from-url", s.handleAddFromURL)
				t.Post("/add-from-url", s.handleAddFromURL)
				t.Get("/{hash}", s.handleGetTorrent)
				t.Get("/{hash}/history", s.handleTorrentHistory)
				t.Get("/{hash}/trackers", s.handleTorrentTrackers)
				t.Post("/{hash}/pause", s.handlePauseTorrent)
				t.Post("/{hash}/resume", s.handleResumeTorrent)
				t.Post("/{hash}/recheck", s.handleRecheckTorrent)
				t.Post("/{hash}/category", s.handleSetCategory)
				t.Post("/{hash}/tags", s.handleSetTags)
				t.Delete("/{hash}", s.handleDeleteTorrent)
			})
			if s.feeds != nil {
				api.Route("/feeds", func(f chi.Router) {
					f.Get("/", s.handleListFeeds)
					f.Get("/{id}/items", s.handleFetchFeedItems)
				})
			}
		}
		if s.webhooks != nil {
			api.Route("/webhooks", func(wh chi.Router) {
				wh.Get("/", s.handleListWebhooks)
				wh.Post("/", s.handleCreateWebhook)
				wh.Patch("/{id}", s.handlePatchWebhook)
				wh.Delete("/{id}", s.handleDeleteWebhook)
				wh.Post("/{id}/rotate", s.handleRotateWebhook)
			})
		}
		if s.sqlite != nil {
			api.Route("/automation", func(a chi.Router) {
				a.Get("/tools", s.handleListAutomationTools)
				a.Get("/tools/{id}/status", s.handleAutomationToolStatus)
				a.Get("/filters", s.handleListFilters)
				a.Get("/filters/{id}", s.handleGetFilter)
				a.Get("/releases", s.handleRecentReleases)
			})
		}
		if s.sqlite != nil {
			api.Route("/push", func(p chi.Router) {
				p.Get("/public-key", s.handlePushPublicKey)
				p.Post("/subscribe", s.handlePushSubscribe)
				p.Delete("/subscribe/{id}", s.handlePushUnsubscribe)
				p.Get("/subscriptions", s.handlePushSubscriptions)
			})
			api.Route("/disk", func(d chi.Router) {
				// /report is token-authed + WebAuthn-exempt (see auth middleware)
				// so the Feral du-cron can POST without a passkey session.
				d.Post("/report", s.handleDiskReport)
				d.Get("/", s.handleDiskLatest)
			})
			api.Route("/portsync", func(p chi.Router) {
				// /report (push events) + /status (per-run heartbeat) are
				// token-authed + WebAuthn-exempt so the home-PC Proton port-sync
				// script can POST without a passkey. GET / is session-gated for
				// the dashboard.
				p.Post("/report", s.handlePortSyncReport)
				p.Post("/status", s.handlePortSyncStatusReport)
				p.Get("/", s.handlePortSyncStatus)
			})
			// Jellyfin X-library visibility toggle (WebAuthn-gated — only the
			// authenticated operator can flip it).
			api.Route("/jellyfin", func(j chi.Router) {
				j.Get("/x", s.handleJellyfinXState)
				j.Post("/x", s.handleJellyfinXToggle)
			})
			api.Route("/radarr", func(rr chi.Router) {
				rr.Get("/search", s.handleRadarrSearch)
				rr.Post("/add", s.handleRadarrAdd)
				rr.Get("/releases", s.handleRadarrReleases)
				rr.Post("/grab", s.handleRadarrGrab)
			})
			// Tree — hybrid (qBit-grouped + filesystem) browser with
			// hardlink-aware "delete permanently" via qBit API + SSH to Feral.
			api.Route("/tree", func(tr chi.Router) {
				tr.Get("/torrents", s.handleTreeTorrents)
				tr.Get("/files", s.handleTreeFiles)
				tr.Post("/inspect", s.handleTreeInspect)
				tr.Post("/delete", s.handleTreeDelete)
			})
			api.Route("/notifications", func(n chi.Router) {
				n.Get("/rules", s.handleListRules)
				n.Post("/test", s.handleTestNotification)
				n.Get("/log", s.handleNotificationLog)
			})
			api.Route("/intelligence", func(i chi.Router) {
				i.Get("/ratio-velocity", s.handleRatioVelocity)
				i.Get("/h-and-r-risk", s.handleHRRisk)
				i.Get("/dead-swarms", s.handleDeadSwarms)
				i.Get("/disk-forecast", s.handleDiskForecast)
				i.Get("/recommendations", s.handleRecommendations)
			})
			api.Route("/decisions", func(d chi.Router) {
				d.Get("/", s.handleListDecisions)
				d.Get("/{id}", s.handleGetDecision)
				d.Post("/{id}/acknowledge", s.handleDecisionAction("acknowledged"))
				d.Post("/{id}/apply", s.handleDecisionAction("applied"))
				d.Post("/{id}/dismiss", s.handleDecisionAction("dismissed"))
			})
			if s.simulation != nil {
				api.Route("/simulation", func(sim chi.Router) {
					sim.Get("/runs", s.handleListSimRuns)
					sim.Post("/runs", s.handleCreateSimRun)
					sim.Get("/runs/{id}", s.handleGetSimRun)
					sim.Delete("/runs/{id}", s.handleDeleteSimRun)
					sim.Post("/runs/{id}/promote", s.handlePromoteSimRun)
				})
			}
			if s.tqm != nil {
				api.Route("/tqm", func(t chi.Router) {
					t.Get("/recent-runs", s.handleTqmRecent)
					t.Post("/dry-run", s.handleTqmDryRun)
				})
				api.Route("/cross-seed", func(cs chi.Router) {
					cs.Get("/activity", s.handleCrossseedActivity)
					cs.Post("/search", s.handleCrossseedSearch)
				})
			}
			if s.budgets != nil {
				api.Route("/budgets", func(b chi.Router) {
					b.Get("/", s.handleListBudgets)
					b.Get("/{name}/state", s.handleBudgetState)
					b.Get("/{name}/history", s.handleBudgetHistory)
				})
			}
			if s.polish != nil {
				api.Route("/emergency", func(e chi.Router) {
					e.Get("/state", s.handleEmergencyState)
					e.Post("/activate", s.handleEmergencyActivate)
					e.Post("/deactivate", s.handleEmergencyDeactivate)
				})
				// Phase 15 adds three /api/system/* leaves alongside Phase 0's
				// /api/system/{health,events}. chi rejects duplicate Route()
				// blocks; register the leaves directly here.
				api.Post("/system/backup", s.handleBackupNow)
				api.Get("/system/audit-log", s.handleAuditLog)
				api.Get("/system/exports", s.handleSystemExport)
			}
			// Secrets inventory + per-key write/delete. List is always
			// available; Set/Delete only when a SecretsWriter is wired.
			api.Get("/secrets/", s.handleListSecrets)
			if s.secretsWrite != nil {
				api.Post("/secrets/{key}", s.handleSetSecret)
				api.Delete("/secrets/{key}", s.handleDeleteSecret)
			}
		}
	})

	// Phase 14: Prometheus /metrics. Unauthenticated; the Tailscale-only
	// network model is the trust boundary (D40).
	if s.metrics != nil {
		r.Get("/metrics", s.metrics.Handler())
	}

	// WebSocket: outside /api so the auth middleware doesn't apply.
	// Phase 3 ships /ws unauthenticated; the Tailscale-only network model
	// is the boundary. Phase 4 keeps this contract — the SSE multiplex at
	// /sse/events is the authenticated counterpart, gated by the middleware.
	if s.bus != nil {
		r.Get("/ws", s.handleWebSocket)
	}

	// Public webhook ingress. HMAC/token validation inside the handler.
	// Mounted at the chi root so the auth middleware does NOT apply.
	if s.webhooks != nil {
		r.Post("/webhook/{type}/{id}", s.handleWebhookIngress)
	}

	// Authenticated SSE multiplex.
	if s.bus != nil {
		r.Get("/sse/events", s.handleSSE)
	}

	if s.webFS != nil {
		fileServer := http.FileServer(http.FS(s.webFS))
		// SPA fallback: serve a real file when one exists at the path
		// (assets, sw.js, manifest, icons); otherwise hand back index.html so
		// the client-side router owns deep links and page refreshes (e.g.
		// /settings, /torrents) instead of the file server returning 404.
		r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			name := strings.TrimPrefix(req.URL.Path, "/")
			if name != "" {
				if info, err := fs.Stat(s.webFS, name); err == nil && !info.IsDir() {
					fileServer.ServeHTTP(w, req)
					return
				}
			}
			// Unknown route → SPA entrypoint. Don't cache the shell so a new
			// deploy's index.html (with fresh asset hashes) is always fetched.
			req.URL.Path = "/"
			w.Header().Set("Cache-Control", "no-cache")
			fileServer.ServeHTTP(w, req)
		}))
	} else {
		r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
			http.Error(w, "frontend bundle not embedded; run scripts/build.sh", http.StatusNotFound)
		})
	}
	return r
}

// resolveBindAddr returns the host:port the server should listen on.
//   - dev_mode=true:  ALWAYS 127.0.0.1 (the Tailscale check is skipped — see
//                     DECISIONS.md D7). This guarantees a predictable bind
//                     address in development regardless of which virtual
//                     adapters happen to be present.
//   - dev_mode=false: the configured Tailscale interface MUST be present;
//                     its absence is fatal.
func resolveBindAddr(cfg *config.SystemConfig, logger zerolog.Logger) (string, error) {
	if cfg.DevMode {
		logger.Warn().Msg("dev_mode: binding to 127.0.0.1 — NEVER enable dev_mode in production")
		return net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.Listen.Port)), nil
	}
	host, err := FindTailscaleAddr(cfg.Listen.TailscaleInterfacePattern)
	if err != nil {
		return "", fmt.Errorf("server: tailscale interface (pattern %q) not present and dev_mode is off: %w",
			cfg.Listen.TailscaleInterfacePattern, err)
	}
	return net.JoinHostPort(host, strconv.Itoa(cfg.Listen.Port)), nil
}
