package core

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/robfig/cron/v3"
)

var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)

// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
// It receives a pointer to the jobs slice because the GUI edits the same slice;
// this keeps the early architecture simple while storage and scheduling are
// still in one desktop process.
type Scheduler struct {
	store    *Store
	jobs     *[]Job
	onChange func(RunRecord)

	mu     sync.Mutex
	ctx    context.Context
	cancel context.CancelFunc
	paused bool
}

func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
	ctx, cancel := context.WithCancel(context.Background())
	s := &Scheduler{
		store:    store,
		jobs:     jobs,
		onChange: onChange,
		ctx:      ctx,
		cancel:   cancel,
	}
	s.resetNextRuns(time.Now())
	return s
}

func (s *Scheduler) Start() {
	// A one-second ticker is accurate enough for cron-style desktop automation
	// and avoids the complexity of maintaining one timer per job. Five-field cron
	// expressions have minute precision, while @every values may be shorter for
	// testing and lightweight local tasks.
	ticker := time.NewTicker(time.Second)
	go func() {
		defer ticker.Stop()
		for {
			select {
			case <-s.ctx.Done():
				return
			case now := <-ticker.C:
				s.tick(now)
			}
		}
	}()
}

func (s *Scheduler) Stop() {
	s.cancel()
}

func (s *Scheduler) SetPaused(paused bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.paused = paused
	now := time.Now()
	// Pause state is reflected into each job's display string so the list view is
	// understandable even before the next scheduler tick.
	for index := range *s.jobs {
		job := &(*s.jobs)[index]
		if !job.Enabled {
			job.NextRun = "Paused"
			continue
		}
		if paused {
			job.NextRun = "Scheduler paused"
			continue
		}
		s.prepareNextRun(job, now)
	}
	_ = s.store.SaveJobs(*s.jobs)
}

func (s *Scheduler) RunNow(index int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	if index < 0 || index >= len(*s.jobs) {
		return false
	}
	// Manual runs share the same runner and log writer as scheduled runs. The
	// Trigger field is the only difference, which keeps History comparable and
	// prevents "Run now" from becoming a separate behavior path.
	return s.startRunLocked(index, "Manual")
}

func (s *Scheduler) RefreshSchedule(index int) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if index < 0 || index >= len(*s.jobs) {
		return
	}
	job := &(*s.jobs)[index]
	if !job.Enabled {
		job.NextRun = "Paused"
		return
	}
	if s.paused {
		job.NextRun = "Scheduler paused"
		return
	}
	s.prepareNextRun(job, time.Now())
}

func (s *Scheduler) tick(now time.Time) {
	var changed bool

	s.mu.Lock()
	if !s.paused {
		for index := range *s.jobs {
			job := &(*s.jobs)[index]
			if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
				continue
			}
			// Run only one due job per tick for now. That avoids overlapping shell
			// commands in the GUI process and keeps the first version predictable;
			// a future worker pool can add concurrency once cancellation and status
			// reporting are more explicit.
			changed = s.startRunLocked(index, "Schedule")
			break
		}
	}
	s.mu.Unlock()
	_ = changed
}

func (s *Scheduler) startRunLocked(index int, trigger string) bool {
	job := &(*s.jobs)[index]
	if job.LastState == "Running" {
		return false
	}

	jobCopy := *job
	job.LastState = "Running"
	job.NextRun = "Running"
	job.Output = runningOutput(jobCopy, trigger, time.Now())
	job.nextDue = time.Time{}
	_ = s.store.SaveJobs(*s.jobs)

	go func() {
		record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)

		s.mu.Lock()
		if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
			current.LastRun = record.Time
			current.LastState = record.State
			current.Output = record.Output
			current.Logs = append([]RunRecord{record}, current.Logs...)
			if len(current.Logs) > 50 {
				current.Logs = current.Logs[:50]
			}
			s.prepareNextRun(current, time.Now())
			_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
			_ = s.store.SaveJobs(*s.jobs)
		}
		s.mu.Unlock()

		if s.onChange != nil {
			s.onChange(record)
		}
	}()
	return true
}

func (s *Scheduler) findJobByIDLocked(id int) *Job {
	for index := range *s.jobs {
		if (*s.jobs)[index].ID == id {
			return &(*s.jobs)[index]
		}
	}
	return nil
}

func runningOutput(job Job, trigger string, started time.Time) string {
	var builder strings.Builder
	builder.WriteString("status:\n")
	builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
	builder.WriteString("trigger:\n")
	builder.WriteString(trigger + "\n\n")
	builder.WriteString("command:\n")
	builder.WriteString(job.Command + "\n\n")
	builder.WriteString("arguments:\n")
	builder.WriteString(logArguments(job.Arguments))
	builder.WriteString("\n\nsuccess_exit_codes:\n")
	builder.WriteString(successExitCodesText(job))
	builder.WriteString("\n\nstart_only:\n")
	builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
	return builder.String()
}

func (s *Scheduler) resetNextRuns(now time.Time) {
	for index := range *s.jobs {
		job := &(*s.jobs)[index]
		if !job.Enabled {
			job.NextRun = "Paused"
			continue
		}
		s.prepareNextRun(job, now)
	}
	_ = s.store.SaveJobs(*s.jobs)
}

func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
	next, ok := nextRunTime(job.Schedule, from)
	if !ok {
		job.NextRun = "Invalid schedule"
		job.nextDue = time.Time{}
		return
	}
	job.nextDue = next
	job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}

func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
	schedule = strings.TrimSpace(schedule)
	if schedule == "" {
		return time.Time{}, false
	}
	if strings.HasPrefix(schedule, "@every ") {
		// @every is kept alongside cron because it is convenient for quick tests
		// and for simple intervals that are awkward to express as five fields.
		interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
		if err != nil || interval <= 0 {
			return time.Time{}, false
		}
		return from.Add(interval), true
	}
	// Standard five-field cron keeps GoSentry compatible with the mental model
	// users already know from Unix cron, while robfig/cron handles edge cases
	// such as ranges, steps, and day-of-week names.
	parsed, err := cronParser.Parse(schedule)
	if err != nil {
		return time.Time{}, false
	}
	return parsed.Next(from), true
}
