// Package workflowsched fires workflow runs on a cron schedule.
//
// Only robfig/cron's parser is used — Parse and Next. Its own scheduler is
// not, because this work runs under the housekeeping leader lock and has to
// stop the moment leadership is lost.
package workflowsched

import (
	"errors"
	"fmt"
	"time"

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

// ErrBadSchedule covers both a malformed expression and an unknown timezone.
// Handlers map it to 400 — both are the caller's mistake, and both are much
// cheaper to find at save time than at 2am.
var ErrBadSchedule = errors.New("invalid schedule")

// Standard 5-field cron: minute hour dom month dow. Deliberately no seconds
// field and no descriptors — a schedule a person cannot read back is a
// schedule nobody can audit.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)

func ParseSchedule(expr, tz string) (cron.Schedule, error) {
	if tz == "" {
		return nil, fmt.Errorf("%w: a timezone is required", ErrBadSchedule)
	}
	if _, err := time.LoadLocation(tz); err != nil {
		return nil, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz)
	}
	sched, err := cronParser.Parse(expr)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrBadSchedule, err)
	}
	return sched, nil
}

// NextOccurrence returns the first firing strictly after from, computed in the
// schedule's own zone so that a DST boundary moves the wall-clock time the way
// a person expects rather than drifting by an hour for half the year.
func NextOccurrence(expr, tz string, from time.Time) (time.Time, error) {
	sched, err := ParseSchedule(expr, tz)
	if err != nil {
		return time.Time{}, err
	}
	loc, err := time.LoadLocation(tz)
	if err != nil {
		return time.Time{}, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz)
	}
	return sched.Next(from.In(loc)), nil
}

type Decision string

const (
	Fire        Decision = "fire"
	SkipMissed  Decision = "missed"
	SkipRunning Decision = "already_running"
)

// GraceWindow is how late an occurrence may be and still run. A job missed by
// ten minutes during a deploy should still run; one missed by two days should
// not fire at lunchtime.
const GraceWindow = time.Hour

// Decide is the whole fire/skip policy, kept pure so it can be tested without
// a database and read without following a loop.
//
// The missed check comes first: an occurrence that is already too old to run
// should be recorded as missed regardless of what is running now, or a slow
// run would relabel a stale occurrence as a fresh conflict.
func Decide(due, now time.Time, runActive bool) Decision {
	if now.Sub(due) > GraceWindow {
		return SkipMissed
	}
	if runActive {
		return SkipRunning
	}
	return Fire
}
