// Per-user system-playlist scheduler (#392 Half B). Replaces the
// 24h-anchored cron loop in system_cron.go (deleted in the same arc).
//
// Each active user gets one gocron daily job at 03:00 in their stored
// timezone. The scheduler reconciles its in-memory job set with the
// "active users" query once per hour so newly-active / no-longer-
// active users are picked up without restarts. New-user registration
// and PUT /api/me/timezone both call Refresh() so changes take effect
// synchronously without waiting for the hourly pass.
//
// Scheduler ownership: cmd/minstrel/main.go constructs one instance
// at startup, calls Start, and stops it on graceful shutdown. The
// server.Server struct holds a pointer so the API layer can call
// Refresh from handlers.

package playlists

import (
	"context"
	"fmt"
	"log/slog"
	"sync"
	"time"

	"github.com/go-co-op/gocron/v2"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
	"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)

// dailyRebuildHour is the local-time hour at which each user's system
// playlists rebuild. 03:00 is well past most "today" listening and
// before morning use; matches the operator framing in the design doc.
const dailyRebuildHour = 3

// reconciliationInterval drives the hourly pass that brings the
// in-memory job set in sync with the live "active users" query.
const reconciliationInterval = time.Hour

// Scheduler owns one gocron daily job per active user. Refresh and
// the hourly reconciliation pass mutate s.jobs under s.mu.
type Scheduler struct {
	gocron  gocron.Scheduler
	pool    *pgxpool.Pool
	logger  *slog.Logger
	dataDir string
	bus     *eventbus.Bus // #968: announce rebuilds so clients self-refresh

	mu   sync.Mutex
	jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
}

// NewScheduler builds an idle scheduler. Caller must invoke Start
// before any builds fire. bus may be nil (tests); rebuild events are
// then skipped.
func NewScheduler(
	pool *pgxpool.Pool,
	logger *slog.Logger,
	dataDir string,
	bus *eventbus.Bus,
) (*Scheduler, error) {
	g, err := gocron.NewScheduler()
	if err != nil {
		return nil, fmt.Errorf("init gocron: %w", err)
	}
	return &Scheduler{
		gocron:  g,
		pool:    pool,
		logger:  logger,
		dataDir: dataDir,
		bus:     bus,
		jobs:    map[pgtype.UUID]uuid.UUID{},
	}, nil
}

// Start clears any stale in_flight rows from a previous crash,
// registers a daily-3am job for every active user at their stored
// timezone, fires a one-shot runOnce to catch up missed schedules,
// and starts the gocron loop.
//
// ctx scopes BuildSystemPlaylists calls fired by the jobs. Cancel
// the parent context to drain in-progress builds on shutdown.
func (s *Scheduler) Start(ctx context.Context) error {
	q := dbq.New(s.pool)
	if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil {
		s.logger.Warn("scheduler: clear stale in_flight failed", "err", err)
		// Non-fatal — TryClaimSystemPlaylistRun will block any
		// concurrent build attempts; stale rows just stay stuck.
	}

	users, err := q.ListActiveUsersWithTimezones(ctx)
	if err != nil {
		return fmt.Errorf("list active users: %w", err)
	}

	s.mu.Lock()
	for _, u := range users {
		if err := s.registerLocked(ctx, u.ID, u.Timezone); err != nil {
			s.logger.Warn("scheduler: register at startup failed",
				"user_id", uuidStringPL(u.ID), "err", err)
		}
	}
	s.mu.Unlock()

	// One-shot startup catch-up: build for every active user right
	// now, regardless of their scheduled time. Mirrors the existing
	// system_cron.go startup runOnce so users who missed yesterday's
	// schedule (process down) get caught up.
	go s.runStartupCatchUp(ctx, users)

	// Hourly reconciliation pass.
	if _, err := s.gocron.NewJob(
		gocron.DurationJob(reconciliationInterval),
		gocron.NewTask(func() {
			s.reconcile(ctx)
		}),
	); err != nil {
		return fmt.Errorf("register reconciliation job: %w", err)
	}

	s.gocron.Start()
	s.logger.Info("scheduler: started", "users", len(users))
	return nil
}

// Refresh removes the user's existing job (if any) and registers a
// fresh one at their current timezone. Safe to call from any
// goroutine. Returns an error only on DB lookup failure; gocron-side
// errors are logged but not propagated since they don't represent
// recoverable conditions for the caller.
func (s *Scheduler) Refresh(ctx context.Context, userID pgtype.UUID) error {
	q := dbq.New(s.pool)
	user, err := q.GetUserByID(ctx, userID)
	if err != nil {
		return fmt.Errorf("get user: %w", err)
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.registerLocked(ctx, userID, user.Timezone)
}

// registerLocked replaces the user's job entry. Caller must hold s.mu.
//
// gocron v2 doesn't expose a per-job location option — WithLocation is
// a SchedulerOption that affects every job. To get per-user timezones
// we use a cron expression with the CRON_TZ= prefix, which the
// underlying robfig/cron parser honors. The expression "CRON_TZ=$tz
// 0 3 * * *" fires at 03:00 in the named zone every day.
func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz string) error {
	if existing, ok := s.jobs[userID]; ok {
		if err := s.gocron.RemoveJob(existing); err != nil {
			s.logger.Warn("scheduler: remove existing job failed",
				"user_id", uuidStringPL(userID), "err", err)
		}
		delete(s.jobs, userID)
	}
	// Fall back to UTC if the stored tz is unparseable so a corrupted
	// DB row doesn't error out registration.
	resolvedTZ := "UTC"
	if _, err := validateTimezone(tz); err == nil {
		resolvedTZ = tz
	}
	cronExpr := fmt.Sprintf("CRON_TZ=%s 0 %d * * *", resolvedTZ, dailyRebuildHour)
	job, err := s.gocron.NewJob(
		gocron.CronJob(cronExpr, false /* withSeconds */),
		gocron.NewTask(func() {
			s.rebuildUserDaily(ctx, userID, time.Now())
		}),
	)
	if err != nil {
		return fmt.Errorf("register daily job: %w", err)
	}
	s.jobs[userID] = job.ID()
	return nil
}

// reconcile syncs the in-memory job set with the live active-users
// query. Newly-active users get jobs; no-longer-active users have
// their jobs removed. Runs hourly.
func (s *Scheduler) reconcile(ctx context.Context) {
	q := dbq.New(s.pool)
	users, err := q.ListActiveUsersWithTimezones(ctx)
	if err != nil {
		s.logger.Warn("scheduler: reconcile list failed", "err", err)
		return
	}

	active := make(map[pgtype.UUID]string, len(users))
	for _, u := range users {
		active[u.ID] = u.Timezone
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	// Add users newly active.
	for id, tz := range active {
		if _, ok := s.jobs[id]; !ok {
			if err := s.registerLocked(ctx, id, tz); err != nil {
				s.logger.Warn("scheduler: reconcile add failed",
					"user_id", uuidStringPL(id), "err", err)
			}
		}
	}

	// Remove users no longer active.
	for id := range s.jobs {
		if _, ok := active[id]; !ok {
			if jobID, exists := s.jobs[id]; exists {
				if err := s.gocron.RemoveJob(jobID); err != nil {
					s.logger.Warn("scheduler: reconcile remove failed",
						"user_id", uuidStringPL(id), "err", err)
				}
				delete(s.jobs, id)
			}
		}
	}
}

// runStartupCatchUp fires BuildSystemPlaylists once for every active
// user at startup, regardless of scheduled time. Matches the
// behavior of the previous system_cron.go startup runOnce so users
// whose scheduled fire was missed during downtime get caught up.
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
	for _, u := range users {
		s.rebuildUserDaily(ctx, u.ID, time.Now())
	}
}

// rebuildUserDaily recomputes the user's taste profile, then rebuilds their
// system playlists + You-might-like rows. Taste runs first so the playlist
// build can read a fresh profile (phase-2 consumption); both steps are
// best-effort and a failure in one is logged without blocking the other.
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
	if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
		s.logger.Warn("scheduler: taste profile rebuild failed",
			"user_id", uuidStringPL(userID), "err", err)
	}
	if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
		s.logger.Warn("scheduler: build failed",
			"user_id", uuidStringPL(userID), "err", err)
		return
	}
	// #968: tell the user's connected clients their home content was
	// regenerated, so a tab/app left open across the rebuild refreshes its
	// system-playlist + You-might-like views (and a stale active queue can
	// re-pull) instead of serving yesterday's snapshot until a manual reload.
	s.publishRebuilt(userID)
}

// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
// No-op when the bus is nil (test construction).
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
	if s.bus == nil {
		return
	}
	s.bus.Publish(eventbus.Event{
		Kind:   "playlist.system_rebuilt",
		UserID: uuidStringPL(userID),
		Data:   map[string]any{},
	})
}

// Stop drains gocron and stops the scheduler loop.
func (s *Scheduler) Stop() {
	if err := s.gocron.Shutdown(); err != nil {
		s.logger.Warn("scheduler: shutdown failed", "err", err)
	}
}

// validateTimezone returns the parsed Location for tz, or an error.
// Callers should use this when accepting input from clients.
func validateTimezone(tz string) (*time.Location, error) {
	if tz == "" {
		return nil, fmt.Errorf("timezone is empty")
	}
	return time.LoadLocation(tz)
}

// validateTimezoneOrUTC returns the parsed Location for tz, or
// time.UTC if parsing fails. Used internally by the scheduler so
// corrupted DB values don't crash the process.
func validateTimezoneOrUTC(tz string) *time.Location {
	loc, err := validateTimezone(tz)
	if err != nil {
		return time.UTC
	}
	return loc
}
