// Minimal 5-field cron parser for control-logic cron triggers.
//
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
//   - any value
//     */n      every n (step over the whole range)
//     a-b      inclusive range
//     a-b/n    range with step
//     a,b,c    comma-separated list of the above
//     N        a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
// matches when EITHER matches, following Vixie cron semantics.
package controllogic

import (
	"fmt"
	"strconv"
	"strings"
	"time"
)

// Schedule is a parsed 5-field cron specification.
type Schedule struct {
	minute  uint64 // bitmask 0..59
	hour    uint64 // bitmask 0..23
	dom     uint64 // bitmask 1..31
	month   uint64 // bitmask 1..12
	dow     uint64 // bitmask 0..6
	domStar bool   // day-of-month field was "*"
	dowStar bool   // day-of-week field was "*"
}

// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
func ParseSchedule(spec string) (*Schedule, error) {
	fields := strings.Fields(spec)
	if len(fields) != 5 {
		return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
	}
	s := &Schedule{}
	var err error
	if s.minute, err = parseField(fields[0], 0, 59); err != nil {
		return nil, fmt.Errorf("minute: %w", err)
	}
	if s.hour, err = parseField(fields[1], 0, 23); err != nil {
		return nil, fmt.Errorf("hour: %w", err)
	}
	if s.dom, err = parseField(fields[2], 1, 31); err != nil {
		return nil, fmt.Errorf("day-of-month: %w", err)
	}
	if s.month, err = parseField(fields[3], 1, 12); err != nil {
		return nil, fmt.Errorf("month: %w", err)
	}
	if s.dow, err = parseDOW(fields[4]); err != nil {
		return nil, fmt.Errorf("day-of-week: %w", err)
	}
	s.domStar = strings.TrimSpace(fields[2]) == "*"
	s.dowStar = strings.TrimSpace(fields[4]) == "*"
	return s, nil
}

// Match reports whether t (truncated to the minute) satisfies the schedule.
func (s *Schedule) Match(t time.Time) bool {
	if s.minute&bit(t.Minute()) == 0 {
		return false
	}
	if s.hour&bit(t.Hour()) == 0 {
		return false
	}
	if s.month&bit(int(t.Month())) == 0 {
		return false
	}
	domMatch := s.dom&bit(t.Day()) != 0
	dowMatch := s.dow&bit(int(t.Weekday())) != 0
	// Vixie semantics: when both day fields are restricted, match either.
	switch {
	case s.domStar && s.dowStar:
		return true
	case s.domStar:
		return dowMatch
	case s.dowStar:
		return domMatch
	default:
		return domMatch || dowMatch
	}
}

func bit(n int) uint64 { return uint64(1) << uint(n) }

func parseDOW(field string) (uint64, error) {
	// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
	mask, err := parseField(field, 0, 7)
	if err != nil {
		return 0, err
	}
	if mask&bit(7) != 0 {
		mask = (mask &^ bit(7)) | bit(0)
	}
	return mask, nil
}

func parseField(field string, lo, hi int) (uint64, error) {
	var mask uint64
	for _, part := range strings.Split(field, ",") {
		m, err := parsePart(strings.TrimSpace(part), lo, hi)
		if err != nil {
			return 0, err
		}
		mask |= m
	}
	if mask == 0 {
		return 0, fmt.Errorf("empty field %q", field)
	}
	return mask, nil
}

func parsePart(part string, lo, hi int) (uint64, error) {
	if part == "" {
		return 0, fmt.Errorf("empty term")
	}
	step := 1
	rangePart := part
	if idx := strings.IndexByte(part, '/'); idx >= 0 {
		rangePart = part[:idx]
		st, err := strconv.Atoi(part[idx+1:])
		if err != nil || st < 1 {
			return 0, fmt.Errorf("bad step %q", part[idx+1:])
		}
		step = st
	}

	start, end := lo, hi
	if rangePart != "*" {
		if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
			a, err1 := strconv.Atoi(rangePart[:idx])
			b, err2 := strconv.Atoi(rangePart[idx+1:])
			if err1 != nil || err2 != nil {
				return 0, fmt.Errorf("bad range %q", rangePart)
			}
			start, end = a, b
		} else {
			v, err := strconv.Atoi(rangePart)
			if err != nil {
				return 0, fmt.Errorf("bad value %q", rangePart)
			}
			start, end = v, v
		}
	}

	if start < lo || end > hi || start > end {
		return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
	}

	var mask uint64
	for v := start; v <= end; v += step {
		mask |= bit(v)
	}
	return mask, nil
}
