package workflow

import (
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"time"
	_ "time/tzdata"

	"github.com/bmatcuk/doublestar/v4"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/robfig/cron/v3"
	"gopkg.in/yaml.v3"
)

// - when a repo is modified, it results in the trigger of a "Pipeline"
// - a repo could consist of several workflow files
//   * .tangled/workflows/test.yml
//   * .tangled/workflows/lint.yml
// - therefore a pipeline consists of several workflows, these execute in parallel
// - each workflow consists of some execution steps, these execute serially

type (
	Pipeline []Workflow

	// this is simply a structural representation of the workflow file
	Workflow struct {
		Name      string       `yaml:"-"` // name of the workflow file
		Engine    string       `yaml:"engine"`
		RunsOn    []string     `yaml:"runs_on"`
		When      []Constraint `yaml:"when"`
		CloneOpts CloneOpts    `yaml:"clone"`
		Audience  string       `yaml:"audience"`
		Raw       string       `yaml:"-"`
	}

	Constraint struct {
		Event    StringList `yaml:"event"`
		Types    StringList `yaml:"types"`    // optional; only applies to pull_request events. defaults to opened, reopened and synchronize
		Branch   StringList `yaml:"branch"`   // required for pull_request; for push, either branch or tag must be specified
		Tag      StringList `yaml:"tag"`      // optional; only applies to push events
		Paths    StringList `yaml:"paths"`    // optional; only run if any changed file matches a glob pattern
		Schedule []Schedule `yaml:"schedule"` // required for schedule events
	}

	Schedule struct {
		Cron     string `yaml:"cron"`
		Timezone string `yaml:"timezone"` // optional; IANA timezone, defaults to UTC
	}

	CloneOpts struct {
		Skip              bool  `yaml:"skip"`
		Depth             int   `yaml:"depth"`
		IncludeSubmodules *bool `yaml:"submodules"`
		Tags              *bool `yaml:"tags"`
	}

	StringList []string

	TriggerKind string
)

const (
	WorkflowDir = ".tangled/workflows"

	TriggerKindPush        TriggerKind = "push"
	TriggerKindPullRequest TriggerKind = "pull_request"
	TriggerKindManual      TriggerKind = "manual"
	TriggerKindSchedule    TriggerKind = "schedule"

	// pull_request lifecycle actions, carried in the trigger metadata and
	// matched against a constraint's `types` list.
	PullRequestActionOpened      = "opened"
	PullRequestActionReopened    = "reopened"
	PullRequestActionClosed      = "closed"
	PullRequestActionMerged      = "merged"
	PullRequestActionSynchronize = "synchronize"
)

// DefaultPullRequestActions is the set of pull_request actions a constraint
// matches when it does not specify an explicit `types` list. This preserves
// the historic behaviour of firing on PR creation and resubmission, plus
// reopen, while leaving close/merge opt-in.
var DefaultPullRequestActions = []string{
	PullRequestActionOpened,
	PullRequestActionReopened,
	PullRequestActionSynchronize,
}

func (t TriggerKind) String() string {
	return strings.ReplaceAll(string(t), "_", " ")
}

var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)

// HasHashedCron reports whether a comma-separated cron term starts with
// Jenkins-style hashed syntax. It intentionally reports malformed uses of H
// such as Hfoo as well, so a caller can warn before ParseCron returns its error.
func HasHashedCron(expression string) bool {
	for _, field := range strings.Fields(expression) {
		for _, term := range strings.Split(field, ",") {
			if strings.HasPrefix(term, "H") {
				return true
			}
		}
	}
	return false
}

// ParseCron parses a five-field cron expression in timezone using seed to
// deterministically expand Jenkins-style hashed fields.
func ParseCron(expression, timezone, seed string) (cron.Schedule, error) {
	return parseCron(expression, timezone, seed, true)
}

func parseCron(expression, timezone, seed string, validateOccurrence bool) (cron.Schedule, error) {
	if len(strings.Fields(expression)) != 5 {
		return nil, fmt.Errorf("expected exactly 5 fields")
	}

	if HasHashedCron(expression) {
		fields := strings.Fields(expression)
		for i, field := range fields {
			expanded, err := expandHashedCronField(field, i, seed)
			if err != nil {
				return nil, err
			}
			fields[i] = expanded
		}
		expression = strings.Join(fields, " ")
	}

	if timezone == "" {
		timezone = "UTC"
	}
	location, err := time.LoadLocation(timezone)
	if err != nil {
		return nil, fmt.Errorf("invalid timezone %q: %w", timezone, err)
	}
	schedule, err := cronParser.Parse("CRON_TZ=" + location.String() + " " + expression)
	if err != nil {
		return nil, err
	}
	if validateOccurrence {
		if err := validateCronHasOccurrence(schedule); err != nil {
			return nil, err
		}
	}
	return schedule, nil
}

// cronFieldBounds returns the bounds accepted for a hashed term. Jenkins
// limits an unbounded day-of-month hash to 1..28 so it is safe in every month.
func cronFieldBounds(field int, explicitRange bool) (int, int, error) {
	switch field {
	case 0:
		return 0, 59, nil
	case 1:
		return 0, 23, nil
	case 2:
		if !explicitRange {
			return 1, 28, nil
		}
		return 1, 31, nil
	case 3:
		return 1, 12, nil
	case 4:
		return 0, 6, nil
	default:
		return 0, 0, fmt.Errorf("invalid cron field index %d", field)
	}
}

func hashedCronValue(seed string, field int) uint64 {
	digest := sha256.Sum256([]byte(seed + "\x00" + strconv.Itoa(field)))
	return binary.BigEndian.Uint64(digest[:8])
}

func parseHashedCronNumber(value string) (int, error) {
	if value == "" {
		return 0, fmt.Errorf("number is required")
	}
	for i := range value {
		if value[i] < '0' || value[i] > '9' {
			return 0, fmt.Errorf("%q is not a non-negative integer", value)
		}
	}
	number, err := strconv.Atoi(value)
	if err != nil {
		return 0, fmt.Errorf("invalid number %q: %w", value, err)
	}
	return number, nil
}

func expandHashedCronField(field string, fieldIndex int, seed string) (string, error) {
	terms := strings.Split(field, ",")
	hashed := false
	for i, term := range terms {
		if !strings.HasPrefix(term, "H") {
			continue
		}
		hashed = true
		expanded, err := expandHashedCronTerm(term, fieldIndex, seed)
		if err != nil {
			return "", err
		}
		terms[i] = expanded
	}
	if !hashed {
		return field, nil
	}
	return strings.Join(terms, ","), nil
}

func expandHashedCronTerm(term string, fieldIndex int, seed string) (string, error) {
	if !strings.HasPrefix(term, "H") {
		return "", fmt.Errorf("malformed hashed cron term %q", term)
	}

	rest := term[1:]
	explicitRange := false
	lo, hi, err := cronFieldBounds(fieldIndex, false)
	if err != nil {
		return "", err
	}

	if strings.HasPrefix(rest, "(") {
		close := strings.IndexByte(rest, ')')
		if close < 0 {
			return "", fmt.Errorf("malformed hashed cron term %q: missing ')'", term)
		}
		rangeParts := strings.Split(rest[1:close], "-")
		if len(rangeParts) != 2 {
			return "", fmt.Errorf("malformed hashed cron term %q: expected H(lo-hi)", term)
		}
		lo, err = parseHashedCronNumber(rangeParts[0])
		if err != nil {
			return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err)
		}
		hi, err = parseHashedCronNumber(rangeParts[1])
		if err != nil {
			return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err)
		}
		explicitRange = true
		boundLo, boundHi, err := cronFieldBounds(fieldIndex, explicitRange)
		if err != nil {
			return "", err
		}
		if lo < boundLo || hi > boundHi {
			return "", fmt.Errorf("hashed cron range %q is outside %d-%d", term, boundLo, boundHi)
		}
		if lo > hi {
			return "", fmt.Errorf("hashed cron range %q is reversed", term)
		}
		rest = rest[close+1:]
	} else if rest != "" && !strings.HasPrefix(rest, "/") {
		return "", fmt.Errorf("malformed hashed cron term %q", term)
	}

	step := 0
	if rest != "" {
		if !strings.HasPrefix(rest, "/") || len(rest) == 1 {
			return "", fmt.Errorf("malformed hashed cron term %q: expected /step", term)
		}
		step, err = parseHashedCronNumber(rest[1:])
		if err != nil {
			return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err)
		}
		if step == 0 {
			return "", fmt.Errorf("hashed cron step must be positive")
		}
	}

	hash := hashedCronValue(seed, fieldIndex)
	width := hi - lo + 1
	if step == 0 || step == 1 {
		return strconv.Itoa(lo + int(hash%uint64(width))), nil
	}
	if step > width {
		return "", fmt.Errorf("hashed cron step %d exceeds range %d-%d", step, lo, hi)
	}

	offset := int(hash % uint64(step))
	values := make([]string, 0, (width+step-1)/step)
	for value := lo + offset; value <= hi; value += step {
		values = append(values, strconv.Itoa(value))
	}
	return strings.Join(values, ","), nil
}

func validateCronHasOccurrence(schedule cron.Schedule) error {
	anchor := time.Now().Add(-time.Minute)
	if schedule.Next(anchor).IsZero() {
		return fmt.Errorf("expression has no satisfiable occurrence")
	}
	return nil
}

const (
	// ValidateScheduleInterval examines a bounded future window of roughly 400
	// days and at most 4096 merged occurrences total. This best-effort bound
	// covers dense and ordinary sparse calendars while preventing pathological
	// schedules from hanging validation.
	scheduleValidationHorizon        = 400 * 24 * time.Hour
	scheduleValidationMaxOccurrences = 4096
)

type scheduleCursor struct {
	index    int
	schedule cron.Schedule
	next     time.Time
}

// ValidateScheduleInterval merges actual future occurrences from all schedules,
// collapsing identical instants, and rejects distinct occurrences that are
// closer than minimum. It scans roughly 400 days and at most 4096 merged
// occurrences, returning nil when either bound is reached.
func ValidateScheduleInterval(schedules []cron.Schedule, minimum time.Duration, after time.Time) error {
	if minimum <= 0 {
		return fmt.Errorf("minimum schedule interval must be positive")
	}

	horizon := after.Add(scheduleValidationHorizon)
	cursors := make([]scheduleCursor, 0, len(schedules))
	for index, schedule := range schedules {
		if schedule == nil {
			return fmt.Errorf("schedule[%d] is nil", index)
		}
		next := schedule.Next(after)
		if next.IsZero() || next.After(horizon) {
			continue
		}
		if !next.After(after) {
			return fmt.Errorf("schedule[%d] did not advance beyond %s", index, after)
		}
		cursors = append(cursors, scheduleCursor{index: index, schedule: schedule, next: next})
	}

	var previous time.Time
	havePrevious := false
	for range scheduleValidationMaxOccurrences {
		if len(cursors) == 0 {
			break
		}

		earliestIndex := 0
		for index := 1; index < len(cursors); index++ {
			if cursors[index].next.Before(cursors[earliestIndex].next) {
				earliestIndex = index
			}
		}
		current := cursors[earliestIndex].next
		if !havePrevious || !current.Equal(previous) {
			if havePrevious {
				interval := current.Sub(previous)
				if interval < minimum {
					return fmt.Errorf("schedule occurrences %s and %s are %s apart, below minimum %s", previous, current, interval, minimum)
				}
			}
			previous = current
			havePrevious = true
		}

		for index := 0; index < len(cursors); {
			if !cursors[index].next.Equal(current) {
				index++
				continue
			}
			prior := cursors[index].next
			next := cursors[index].schedule.Next(prior)
			if next.IsZero() || next.After(horizon) {
				cursors = append(cursors[:index], cursors[index+1:]...)
				continue
			}
			if !next.After(prior) {
				return fmt.Errorf("schedule[%d] did not advance beyond %s", cursors[index].index, prior)
			}
			cursors[index].next = next
			index++
		}
	}
	return nil
}

// matchesPattern checks if a name matches any of the given patterns.
// Patterns can be exact matches or glob patterns using * and **.
// * matches any sequence of non-separator characters
// ** matches any sequence of characters including separators
func matchesPattern(name string, patterns []string) (bool, error) {
	for _, pattern := range patterns {
		matched, err := doublestar.Match(pattern, name)
		if err != nil {
			return false, err
		}
		if matched {
			return true, nil
		}
	}
	return false, nil
}

func FromFile(name string, contents []byte) (Workflow, error) {
	wf := Workflow{Name: name}

	if err := yaml.Unmarshal(contents, &wf); err != nil {
		return wf, err
	}
	if err := wf.validateSchedules(); err != nil {
		return wf, err
	}
	if err := wf.validateAudience(); err != nil {
		return wf, err
	}

	wf.Raw = string(contents)

	return wf, nil
}

const MaxAudienceLength = 150

// validateAudience accepts printable ASCII only. the audience is signed
// verbatim, so bidi or unicode-space tricks must not reach the claim.
func (w Workflow) validateAudience() error {
	if w.Audience == "" {
		return nil
	}
	if len(w.Audience) > MaxAudienceLength {
		return fmt.Errorf("audience: longer than %d characters", MaxAudienceLength)
	}
	for _, r := range w.Audience {
		if r < 0x21 || r > 0x7e {
			return fmt.Errorf("audience: only printable ASCII is allowed")
		}
	}
	return nil
}

func (w Workflow) validateSchedules() error {
	for i, constraint := range w.When {
		isSchedule := slices.Contains(constraint.Event, string(TriggerKindSchedule))
		switch {
		case isSchedule && len(constraint.Schedule) == 0:
			return fmt.Errorf("when[%d]: schedule event requires schedule entries", i)
		case !isSchedule && len(constraint.Schedule) > 0:
			return fmt.Errorf("when[%d]: schedule entries require schedule event", i)
		}
		if isSchedule && (len(constraint.Branch) > 0 ||
			len(constraint.Tag) > 0 ||
			len(constraint.Types) > 0 ||
			len(constraint.Paths) > 0) {
			return fmt.Errorf("when[%d]: schedule constraints cannot specify branch, tag, types, or paths", i)
		}
		for j, entry := range constraint.Schedule {
			if entry.Cron == "" {
				return fmt.Errorf("when[%d].schedule[%d]: cron is required", i, j)
			}
			if _, err := parseCron(entry.Cron, entry.Timezone, w.Name, !HasHashedCron(entry.Cron)); err != nil {
				return fmt.Errorf("when[%d].schedule[%d]: invalid cron expression %q: %w", i, j, entry.Cron, err)
			}
		}
	}
	return nil
}

// if any of the constraints on a workflow is true, return true

func (w *Workflow) Match(trigger TriggerMetadata, changedFiles []string) (bool, error) {
	// manual dispatch skips matching constraints since selection is done by the caller
	if trigger.Manual != nil {
		return true, nil
	}

	// if not manual, run through the constraint list and see if any one matches
	for _, c := range w.When {
		matched, err := c.Match(trigger, changedFiles)
		if err != nil {
			return false, err
		}
		if matched {
			return true, nil
		}
	}

	// no constraints, always run this workflow
	if len(w.When) == 0 {
		return true, nil
	}

	return false, nil
}

func (c *Constraint) Match(trigger TriggerMetadata, changedFiles []string) (bool, error) {
	// manual triggers always pass this constraint
	if trigger.Manual != nil {
		return true, nil
	}

	// apply event constraints
	if !c.MatchEvent(trigger.Kind) {
		return false, nil
	}

	// apply branch and action constraints for PRs
	if trigger.PullRequest != nil {
		matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
		if err != nil {
			return false, err
		}
		if !matched {
			return false, nil
		}
		action := ""
		if trigger.PullRequest.Action != nil {
			action = *trigger.PullRequest.Action
		}
		if !c.MatchTypes(action) {
			return false, nil
		}
	}

	// apply ref constraints for pushes
	if trigger.Push != nil {
		matched, err := c.MatchRef(trigger.Push.Ref)
		if err != nil {
			return false, err
		}
		if !matched {
			return false, nil
		}
	}

	// apply paths filter: if specified, at least one changed file must match. the
	// push trigger is the only one we compute a diff for, so a nil list there means
	// the diff could not be computed, and that must not skip the workflow quietly.
	// every other trigger keeps the old behaviour of a list that does not match
	if len(c.Paths) > 0 && (trigger.Push == nil || changedFiles != nil) {
		matched, err := matchesAnyFile(changedFiles, c.Paths)
		if err != nil {
			return false, err
		}
		if !matched {
			return false, nil
		}
	}

	return true, nil
}

// matchesAnyFile returns true if any file in files matches any of the glob patterns.
func matchesAnyFile(files []string, patterns []string) (bool, error) {
	for _, f := range files {
		matched, err := matchesPattern(f, patterns)
		if err != nil {
			return false, err
		}
		if matched {
			return true, nil
		}
	}
	return false, nil
}

func (c *Constraint) MatchRef(ref string) (bool, error) {
	refName := plumbing.ReferenceName(ref)
	shortName := refName.Short()

	if refName.IsBranch() {
		return c.MatchBranch(shortName)
	}

	if refName.IsTag() {
		return c.MatchTag(shortName)
	}

	return false, nil
}

func (c *Constraint) MatchBranch(branch string) (bool, error) {
	return matchesPattern(branch, c.Branch)
}

func (c *Constraint) MatchTag(tag string) (bool, error) {
	return matchesPattern(tag, c.Tag)
}

func (c *Constraint) MatchEvent(event string) bool {
	return slices.Contains(c.Event, event)
}

// MatchTypes reports whether a pull_request action satisfies this constraint's
// `types` filter. An empty `types` list falls back to DefaultPullRequestActions.
// A missing action (e.g. legacy trigger metadata) is treated as "opened" so
// existing pull_request workflows keep matching.
func (c *Constraint) MatchTypes(action string) bool {
	if action == "" {
		action = PullRequestActionOpened
	}
	types := []string(c.Types)
	if len(types) == 0 {
		types = DefaultPullRequestActions
	}
	return slices.Contains(types, action)
}

// Custom unmarshaller for StringList
func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error {
	var stringType string
	if err := unmarshal(&stringType); err == nil {
		*s = []string{stringType}
		return nil
	}

	var sliceType []any
	if err := unmarshal(&sliceType); err == nil {

		if sliceType == nil {
			*s = nil
			return nil
		}

		parts := make([]string, len(sliceType))
		for k, v := range sliceType {
			if sv, ok := v.(string); ok {
				parts[k] = sv
			} else {
				return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v)
			}
		}

		*s = parts
		return nil
	}

	return errors.New("failed to unmarshal StringOrSlice")
}

func (c CloneOpts) AsRecord() CloneSpec {
	return CloneSpec{
		Depth:      int64(c.Depth),
		Skip:       c.Skip,
		Submodules: c.IncludeSubmodules == nil || *c.IncludeSubmodules,
		Tags:       c.Tags == nil || *c.Tags,
	}
}
