package server

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/jackc/pgx/v5/pgxpool"

	"time"

	"git.fabledsword.com/bvandeusen/minstrel/internal/api"
	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/config"
	"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
	"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
	"git.fabledsword.com/bvandeusen/minstrel/internal/library"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
	"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
	"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
	"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
	"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
	"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
	"git.fabledsword.com/bvandeusen/minstrel/web"
)

// lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern
// (used elsewhere in this package so admin Lidarr config edits take effect
// without restart) into the tracks.LidarrUnmonitorer interface that
// internal/tracks expects. When the factory returns nil (Lidarr disabled)
// we surface a sentinel error — tracks.Service treats UnmonitorTrack
// failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed`
// flag in the response and the destructive part still runs.
type lidarrUnmonitorAdapter struct {
	fn func() *lidarr.Client
}

var errLidarrDisabled = errors.New("lidarr disabled")

func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
	c := a.fn()
	if c == nil {
		return errLidarrDisabled
	}
	return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
}

// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
// interface so tests can stub it without touching the DB. The progressCb
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
// tally writes; the HTTP handler passes nil for fire-and-forget triggers.
type ScanTrigger interface {
	Scan(ctx context.Context, progressCb func(library.Stats)) (library.Stats, error)
}

type Server struct {
	Logger            *slog.Logger
	Pool              *pgxpool.Pool
	Scanner           ScanTrigger
	SubsonicCfg       subsonic.Config
	EventsCfg         config.EventsConfig
	RecommendationCfg config.RecommendationConfig
	// DataDir is the on-disk root for cached artifacts (currently
	// playlist cover collages under <DataDir>/playlist_covers/). Empty
	// strings are tolerated by tests that don't exercise persisted-cover
	// codepaths; production callers should pass a writable directory.
	DataDir        string
	BrandingCfg    config.BrandingConfig
	CoverEnricher  *coverart.Enricher
	CoverSettings  *coverart.SettingsService
	TagSettings    *tags.SettingsService
	LibraryScanner *library.Scanner
	ScanCfg        library.RunScanConfig
	// Bus is the live-event bus shared with background workers (the
	// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
	// When nil, Router() constructs a local fallback (test contexts).
	Bus *eventbus.Bus
	// PlaylistScheduler fires per-user daily system-playlist builds at
	// 03:00 in each user's stored timezone (#392 Half B). Constructed
	// in cmd/minstrel/main.go and threaded into the API handlers so
	// PUT /api/me/timezone and POST /api/auth/register can call
	// Refresh synchronously.
	PlaylistScheduler *playlists.Scheduler
	// RecSettings is the DB-backed recommendation tuning lab (#1250):
	// scoring-weight profiles + taste-build knobs. Constructed in
	// cmd/minstrel/main.go (it pushes daily-mix weights into package
	// playlists at boot); the API layer reads radio weights per request
	// and serves the admin tuning endpoints from it. Router() constructs
	// a fallback when nil (tests).
	RecSettings *recsettings.Service
	// StreamSecret is the HMAC key used by /api/cast/stream-token to
	// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
	// to verify them. Sourced from config.Config.StreamSecret. Tests that
	// leave it nil leave the cookie path intact and reject all signed
	// tokens (HMAC of empty key matches nothing a client could mint).
	StreamSecret []byte
}

func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
	return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
}

func (s *Server) Router() http.Handler {
	r := chi.NewRouter()

	// Built before the router because the access log needs it, and the access
	// log covers /healthz and the SPA — which exist whether or not there's a
	// pool. netsettings.New handles a nil pool by returning a default-valued
	// service, so this needs no branch and no later reassignment; hoisting it
	// here keeps the accessor a plain method value instead of a closure over
	// a variable mutated after the middleware is already registered.
	netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger)
	if nsErr != nil {
		s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr)
	}

	r.Use(middleware.RequestID)
	r.Use(requestLog(s.Logger, netSettings.Hops))
	r.Use(middleware.Recoverer)

	r.Get("/healthz", s.handleHealthz)

	if s.Pool != nil {
		writer := playevents.NewWriter(
			s.Pool, s.Logger,
			time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
			s.EventsCfg.SkipMaxCompletionRatio,
			s.EventsCfg.SkipMaxDurationPlayedMs,
		)
		lidarrCfg := lidarrconfig.New(s.Pool)
		// Per-call client factory: re-reads config so an admin save in
		// /admin/integrations takes effect immediately without restart.
		// Returns nil when Lidarr is disabled, which the Service-layer
		// methods translate to ErrLidarrDisabled.
		lidarrClientFn := func() *lidarr.Client {
			cfg, err := lidarrCfg.Get(context.Background())
			if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
				return nil
			}
			return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
		}
		lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
		lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
		tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
		playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
		smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
		// Live-event bus for SSE subscribers (#392). Constructed per-process;
		// producers in playevents / lidarrrequests / scanner publish into
		// the same instance. Background workers (lidarr reconciler, scan
		// scheduler) live in cmd/minstrel/main.go where they're constructed
		// before Router() runs; they read s.Bus directly so they share this
		// process's bus. When Router() runs before main set the bus (tests,
		// or future contexts) we fall back to a fresh local instance.
		bus := s.Bus
		if bus == nil {
			bus = eventbus.New()
		}
		recSettings := s.RecSettings
		if recSettings == nil {
			// Test contexts construct Server directly without main.go's
			// boot wiring; reconcile here so radio + the admin tuning
			// endpoints work against the same pool.
			var err error
			recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
			if err != nil {
				s.Logger.Error("server: recsettings boot failed", "err", err)
			}
		}
		api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings)
		// /api/admin/scan is the only admin route owned by the server package
		// (it needs the Scanner). Register it as a single inline-middleware
		// route — using r.Route("/api/admin", ...) here would create a second
		// subtree that shadows every admin route registered by api.Mount.
		if s.Scanner != nil {
			r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()).
				Post("/api/admin/scan", s.handleAdminScan)
		}
		subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
	}

	spa := web.Handler(s.BrandingCfg)
	r.NotFound(func(w http.ResponseWriter, req *http.Request) {
		p := req.URL.Path
		if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusNotFound)
			_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
			return
		}
		spa.ServeHTTP(w, req)
	})
	return r
}

func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"status":             "ok",
		"version":            ServerVersion,
		"min_client_version": MinClientVersion,
	})
}

// handleAdminScan runs a scan synchronously and returns the resulting stats.
// Kept synchronous for v1: libraries are small and the operator can tell when
// it's done. Async jobs with status polling are a later-milestone concern.
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
	stats, err := s.Scanner.Scan(r.Context(), nil)
	w.Header().Set("Content-Type", "application/json")
	if err != nil {
		s.Logger.Error("admin scan failed", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stats": stats})
		return
	}
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(stats)
}
