package reposync

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

// CronSchedule is the deliberately small, deterministic five-field cron
// implementation used by repository sync. It supports wildcards, lists,
// ranges, and steps. Times are evaluated in UTC so every worker agrees on the
// same due instant.
type CronSchedule struct {
	minute, hour, day, month, weekday cronField
}

type cronField struct {
	allowed map[int]struct{}
}

// ParseCron parses "minute hour day-of-month month day-of-week". Like common
// cron implementations, day-of-month and day-of-week are ORed when both are
// restricted.
func ParseCron(expression string) (CronSchedule, error) {
	parts := strings.Fields(expression)
	if len(parts) != 5 {
		return CronSchedule{}, fmt.Errorf("cron expression must contain five fields")
	}
	limits := [][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
	fields := make([]cronField, 5)
	for i, part := range parts {
		field, err := parseCronField(part, limits[i][0], limits[i][1])
		if err != nil {
			return CronSchedule{}, fmt.Errorf("cron field %d: %w", i+1, err)
		}
		if i == 4 {
			if _, ok := field.allowed[7]; ok {
				field.allowed[0] = struct{}{}
				delete(field.allowed, 7)
			}
		}
		fields[i] = field
	}
	return CronSchedule{minute: fields[0], hour: fields[1], day: fields[2], month: fields[3], weekday: fields[4]}, nil
}

func parseCronField(raw string, min, max int) (cronField, error) {
	field := cronField{allowed: make(map[int]struct{})}
	for _, listPart := range strings.Split(raw, ",") {
		listPart = strings.TrimSpace(listPart)
		if listPart == "" {
			return cronField{}, fmt.Errorf("empty list item")
		}
		base, step := listPart, 1
		if strings.Contains(listPart, "/") {
			pieces := strings.Split(listPart, "/")
			if len(pieces) != 2 {
				return cronField{}, fmt.Errorf("invalid step %q", listPart)
			}
			base = pieces[0]
			parsed, err := strconv.Atoi(pieces[1])
			if err != nil || parsed < 1 {
				return cronField{}, fmt.Errorf("invalid step %q", pieces[1])
			}
			step = parsed
		}

		start, end := min, max
		switch {
		case base == "*":
		case strings.Contains(base, "-"):
			pieces := strings.Split(base, "-")
			if len(pieces) != 2 {
				return cronField{}, fmt.Errorf("invalid range %q", base)
			}
			var err error
			start, err = strconv.Atoi(pieces[0])
			if err != nil {
				return cronField{}, fmt.Errorf("invalid range %q", base)
			}
			end, err = strconv.Atoi(pieces[1])
			if err != nil {
				return cronField{}, fmt.Errorf("invalid range %q", base)
			}
		case step != 1:
			return cronField{}, fmt.Errorf("step requires wildcard or range")
		default:
			value, err := strconv.Atoi(base)
			if err != nil {
				return cronField{}, fmt.Errorf("invalid value %q", base)
			}
			start, end = value, value
		}
		if start < min || end > max || start > end {
			return cronField{}, fmt.Errorf("value outside %d-%d", min, max)
		}
		for value := start; value <= end; value += step {
			field.allowed[value] = struct{}{}
		}
	}
	return field, nil
}

func (s CronSchedule) Matches(value time.Time) bool {
	value = value.UTC()
	_, minute := s.minute.allowed[value.Minute()]
	_, hour := s.hour.allowed[value.Hour()]
	_, month := s.month.allowed[int(value.Month())]
	_, day := s.day.allowed[value.Day()]
	_, weekday := s.weekday.allowed[int(value.Weekday())]
	dayRestricted := len(s.day.allowed) != 31
	weekdayRestricted := len(s.weekday.allowed) != 7
	dayMatches := day && weekday
	if dayRestricted && weekdayRestricted {
		dayMatches = day || weekday
	}
	return minute && hour && month && dayMatches
}

// Next returns the first matching minute strictly after after. A bounded
// search keeps invalid/impossible schedules from spinning forever.
func (s CronSchedule) Next(after time.Time) (time.Time, error) {
	candidate := after.UTC().Truncate(time.Minute).Add(time.Minute)
	deadline := candidate.AddDate(5, 0, 0)
	for !candidate.After(deadline) {
		if s.Matches(candidate) {
			return candidate, nil
		}
		candidate = candidate.Add(time.Minute)
	}
	return time.Time{}, fmt.Errorf("cron schedule has no occurrence in the next five years")
}
