package app

import (
	"context"
	"sync"
	"time"

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

// Service is the application-service layer: the single owner of GoSentry's
// in-memory state. It holds the durable jobs slice, the transient runtime map
// keyed by Job.ID, and a reference to the store that persists them. All access
// to that state goes through a mutex so the GUI and the scheduler can no longer
// race on a shared *[]Job.
//
// State ownership and the locking contract were established in T3.1; the
// event/observer machinery in T3.2. T3.3 added the state-mutating intents
// (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause,
// UpdateSettings) in operations.go: the Service is the sole writer of job and
// runtime state, persisting through the store and announcing changes via events.
//
// T3.4 makes the Service drive scheduling too. It owns the timing loop through a
// scheduler.Scheduler that calls RunDue on every tick; the scheduler holds no
// job state and never touches the slice directly. The old shared *[]domain.Job
// between GUI and scheduler is gone — both go through the Service.
//
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
// it; unexported helpers ending in "Locked" assume the caller already holds it.
// The Service must never call back into the UI (or any code that might re-enter
// the Service) while holding mu — in particular emit() is always called after
// mu is released.
type Service struct {
	mu       sync.Mutex
	store    *storage.Store
	jobs     []domain.Job
	runtimes map[int]*domain.JobRuntime

	// schedules caches a parsed Schedule per job ID so timing math does not
	// re-parse the schedule string on every use. paused is the global pause flag.
	// Both are guarded by mu.
	schedules map[int]domain.Schedule
	paused    bool

	// runJob is the run seam. It defaults to runner.RunJob and is overridden in
	// tests with a fake so the run paths can be exercised without spawning real
	// processes. ctx is the lifecycle context passed to runs; Start replaces it
	// with a cancelable context so Stop can abort in-flight runs, and until Start
	// it is context.Background().
	runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
	ctx    context.Context

	// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
	// Both are guarded by mu.
	sched  *scheduler.Scheduler
	cancel context.CancelFunc

	// observers and their guard live in events.go. dispatchMu is separate from mu
	// so that emitting an event never requires (or is held under) the state lock:
	// the Service must release mu before dispatching, per the locking contract.
	dispatchMu sync.Mutex
	observers  []Observer
}

// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
// store is the Service's sole channel to persistence.
func NewService(store *storage.Store, jobs []domain.Job) *Service {
	s := &Service{
		store:     store,
		jobs:      jobs,
		runtimes:  domain.NewRuntimes(jobs),
		schedules: make(map[int]domain.Schedule, len(jobs)),
		runJob:    runner.RunJob,
		ctx:       context.Background(),
	}
	// Parse every schedule once, then compute each job's first next-run so the
	// Service is ready to schedule the moment it exists — mirroring the old
	// scheduler's reset-on-construction. No lock is needed: construction is
	// single-threaded, before Start launches the timing loop.
	now := time.Now()
	for index := range s.jobs {
		job := &s.jobs[index]
		s.parseScheduleLocked(job)
		s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
	}
	return s
}

// Start begins scheduling. It installs a cancelable run context and a timing
// loop driven by the given clock; every tick calls RunDue. Pass
// scheduler.NewRealClock() in production. Start is expected once, during setup,
// before any concurrent use.
func (s *Service) Start(clock scheduler.Clock) {
	s.mu.Lock()
	ctx, cancel := context.WithCancel(context.Background())
	s.ctx = ctx
	s.cancel = cancel
	s.sched = scheduler.NewScheduler(clock, s.RunDue)
	sched := s.sched
	s.mu.Unlock()

	sched.Start()
}

// Stop halts scheduling and cancels the run context so in-flight runs see a
// canceled context. It is safe to call when Start was never called.
func (s *Service) Stop() {
	s.mu.Lock()
	sched := s.sched
	cancel := s.cancel
	s.mu.Unlock()

	if sched != nil {
		sched.Stop()
	}
	if cancel != nil {
		cancel()
	}
}

// Open loads the store and constructs a Service from it in one step. It is the
// convenience entry point for the application; tests inject a pre-built store
// via NewService instead.
func Open() (*Service, error) {
	store, jobs, err := storage.OpenStore()
	if err != nil {
		return nil, err
	}
	return NewService(store, jobs), nil
}

// Store returns the underlying store. It is exposed so callers that still need
// resolved paths and config (the GUI, during the transition) can reach them;
// later phases narrow this surface.
func (s *Service) Store() *storage.Store {
	return s.store
}

// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
// from mutating Service-owned state behind its back: the Service stays the sole
// writer.
func (s *Service) Jobs() []domain.Job {
	s.mu.Lock()
	defer s.mu.Unlock()

	jobs := make([]domain.Job, len(s.jobs))
	copy(jobs, s.jobs)
	return jobs
}

// Runtime returns the transient runtime state for a job ID, or nil if no job
// with that ID is loaded. The returned pointer is the live runtime; reads of it
// are only safe while no concurrent mutation is in flight. The scheduler now
// drives the Service rather than sharing state, so the remaining concurrent
// reader is the UI listener, which T4.1 marshals onto the main thread.
func (s *Service) Runtime(id int) *domain.JobRuntime {
	s.mu.Lock()
	defer s.mu.Unlock()

	return s.runtimes[id]
}
