package scheduler

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

	"git.casaderoll.de/michael/urbm/internal/model"
)

type ConfigProvider func() model.Config
type Enqueue func(model.Job) error
type EnqueueMaintenance func(string, string) (model.Run, error)
type LastRun func(string) time.Time

type Scheduler struct {
	config      ConfigProvider
	enqueue     Enqueue
	maintenance EnqueueMaintenance
	lastRun     LastRun
	log         *slog.Logger
	mu          sync.Mutex
	last        map[string]time.Time
}

func New(config ConfigProvider, enqueue Enqueue, maintenance EnqueueMaintenance, lastRun LastRun, log *slog.Logger) *Scheduler {
	return &Scheduler{config: config, enqueue: enqueue, maintenance: maintenance, lastRun: lastRun, log: log, last: map[string]time.Time{}}
}

func (s *Scheduler) Run(ctx context.Context) {
	s.catchUp(time.Now())
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	s.tick(time.Now())
	for {
		select {
		case <-ctx.Done():
			return
		case now := <-ticker.C:
			s.tick(now)
		}
	}
}

func (s *Scheduler) tick(now time.Time) {
	config := s.config()
	minute := now.Truncate(time.Minute)
	for _, job := range config.Jobs {
		if !job.Enabled || job.Schedule.Cron == "" {
			continue
		}
		at, expression, ok := scheduledTime(job, minute, s.log)
		if !ok || !expression.Matches(at) {
			continue
		}
		job := job
		s.once("job:"+job.ID, minute, func() error { return s.enqueue(job) })
	}
	s.scheduleMaintenance(config, minute, "check", config.Settings.CheckCron)
	s.scheduleMaintenance(config, minute, "prune", config.Settings.PruneCron)
}

func (s *Scheduler) catchUp(now time.Time) {
	config := s.config()
	window := time.Duration(config.Settings.CatchUpWindowHrs) * time.Hour
	if window <= 0 {
		return
	}
	for _, job := range config.Jobs {
		if !job.Enabled || job.Schedule.Cron == "" {
			continue
		}
		loc := time.Local
		if job.Schedule.Timezone != "" {
			if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
				loc = loaded
			}
		}
		expression, err := Parse(job.Schedule.Cron)
		if err != nil {
			continue
		}
		lastScheduled := previous(expression, now.In(loc), window)
		if !lastScheduled.IsZero() && s.lastRun(job.ID).Before(lastScheduled.UTC()) {
			if err := s.enqueue(job); err != nil {
				s.log.Warn("catch-up enqueue failed", "jobId", job.ID, "error", err)
			}
		}
	}
}

func (s *Scheduler) scheduleMaintenance(config model.Config, minute time.Time, action, spec string) {
	if spec == "" {
		return
	}
	expression, err := Parse(spec)
	if err != nil {
		s.log.Warn("invalid maintenance schedule", "action", action, "error", err)
		return
	}
	if !expression.Matches(minute) {
		return
	}
	for _, repo := range config.Repositories {
		repoID := repo.ID
		s.once(action+":"+repoID, minute, func() error {
			_, err := s.maintenance(repoID, action)
			return err
		})
	}
}

func (s *Scheduler) once(key string, minute time.Time, action func() error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.last[key].Equal(minute) {
		return
	}
	s.last[key] = minute
	if err := action(); err != nil {
		s.log.Warn("schedule enqueue failed", "key", key, "error", err)
	}
}

func scheduledTime(job model.Job, minute time.Time, log *slog.Logger) (time.Time, Expression, bool) {
	expression, err := Parse(job.Schedule.Cron)
	if err != nil {
		log.Warn("invalid job schedule", "jobId", job.ID, "error", err)
		return time.Time{}, Expression{}, false
	}
	loc := time.Local
	if job.Schedule.Timezone != "" {
		if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
			loc = loaded
		} else {
			log.Warn("invalid job timezone", "jobId", job.ID, "error", err)
		}
	}
	return minute.In(loc), expression, true
}

func previous(expression Expression, now time.Time, window time.Duration) time.Time {
	current := now.Truncate(time.Minute)
	limit := current.Add(-window)
	for !current.Before(limit) {
		if expression.Matches(current) {
			return current
		}
		current = current.Add(-time.Minute)
	}
	return time.Time{}
}
