package scheduler

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

	"gitea.mixdep.ru/mix/gosentry/src/domain"
	"gitea.mixdep.ru/mix/gosentry/src/runner"
	"gitea.mixdep.ru/mix/gosentry/src/storage"
)

// 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    *storage.Store
	jobs     *[]domain.Job
	runtimes map[int]*domain.JobRuntime
	onChange func(domain.RunRecord)

	mu        sync.Mutex
	ctx       context.Context
	cancel    context.CancelFunc
	paused    bool
	schedules map[int]domain.Schedule // parsed once per job on load/edit
}

// NewScheduler shares the durable jobs slice and the transient runtime map with
// the GUI. Both still point at the same in-memory state for now; Phase 3 moves
// ownership behind an application service.
func NewScheduler(store *storage.Store, jobs *[]domain.Job, runtimes map[int]*domain.JobRuntime, onChange func(domain.RunRecord)) *Scheduler {
	ctx, cancel := context.WithCancel(context.Background())
	s := &Scheduler{
		store:     store,
		jobs:      jobs,
		runtimes:  runtimes,
		onChange:  onChange,
		ctx:       ctx,
		cancel:    cancel,
		schedules: make(map[int]domain.Schedule),
	}
	s.resetNextRuns(time.Now())
	return s
}

// runtimeFor returns the runtime state for a job, lazily creating it if the map
// has no entry yet. This keeps the scheduler robust if a job is added to the
// shared slice without a matching runtime.
func (s *Scheduler) runtimeFor(job *domain.Job) *domain.JobRuntime {
	runtime, ok := s.runtimes[job.ID]
	if !ok || runtime == nil {
		runtime = domain.NewRuntime(*job)
		s.runtimes[job.ID] = runtime
	}
	return runtime
}

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]
		runtime := s.runtimeFor(job)
		if !job.Enabled {
			runtime.NextRun = "Paused"
			continue
		}
		if paused {
			runtime.NextRun = "Scheduler paused"
			continue
		}
		s.prepareNextRun(job, runtime, 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]
	runtime := s.runtimeFor(job)
	s.parseJobSchedule(job) // re-parse in case the schedule string changed
	if !job.Enabled {
		runtime.NextRun = "Paused"
		return
	}
	if s.paused {
		runtime.NextRun = "Scheduler paused"
		return
	}
	s.prepareNextRun(job, runtime, 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]
			runtime := s.runtimeFor(job)
			if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.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]
	runtime := s.runtimeFor(job)
	if runtime.LastState == "Running" {
		return false
	}

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

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

		s.mu.Lock()
		if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
			currentRuntime := s.runtimeFor(current)
			currentRuntime.LastRun = record.Time
			currentRuntime.LastState = record.State
			currentRuntime.Output = record.Output
			currentRuntime.Logs = append([]domain.RunRecord{record}, currentRuntime.Logs...)
			if len(currentRuntime.Logs) > 50 {
				currentRuntime.Logs = currentRuntime.Logs[:50]
			}
			s.prepareNextRun(current, currentRuntime, time.Now())
			_ = runner.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) *domain.Job {
	for index := range *s.jobs {
		if (*s.jobs)[index].ID == id {
			return &(*s.jobs)[index]
		}
	}
	return nil
}

func runningOutput(job domain.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(runner.LogArguments(job.Arguments))
	builder.WriteString("\n\nsuccess_exit_codes:\n")
	builder.WriteString(runner.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]
		runtime := s.runtimeFor(job)
		s.parseJobSchedule(job) // parse once on load
		if !job.Enabled {
			runtime.NextRun = "Paused"
			continue
		}
		s.prepareNextRun(job, runtime, now)
	}
	_ = s.store.SaveJobs(*s.jobs)
}

// parseJobSchedule caches a parsed domain.Schedule for the job. Invalid
// schedule strings are silently dropped from the cache so prepareNextRun can
// distinguish them from valid ones.
func (s *Scheduler) parseJobSchedule(job *domain.Job) {
	sched, err := domain.Parse(job.Schedule)
	if err != nil {
		delete(s.schedules, job.ID)
		return
	}
	s.schedules[job.ID] = sched
}

func (s *Scheduler) prepareNextRun(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
	sched, ok := s.schedules[job.ID]
	if !ok {
		runtime.NextRun = "Invalid schedule"
		runtime.NextDue = time.Time{}
		return
	}
	runtime.NextDue = sched.Next(from)
	runtime.NextRun = runtime.NextDue.Format("2006-01-02 15:04:05")
}
