package workflow

import (
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/robfig/cron/v3"
	"github.com/stretchr/testify/assert"
)

func TestUnmarshalWorkflowWithBranch(t *testing.T) {
	yamlData := `
when:
  - event: ["push", "pull_request"]
    branch: ["main", "develop"]`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err, "YAML should unmarshal without error")

	assert.Len(t, wf.When, 1, "Should have one constraint")
	assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
	assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)

	assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
}

func TestAudienceIsValidatedAtParseTime(t *testing.T) {
	tests := []struct {
		name     string
		audience string
		ok       bool
	}{
		{"gcp provider resource", "//iam.googleapis.com/projects/tangled/locations/global/workloadIdentityPools/ci/providers/spindle", true},
		{"https audience", "https://vault.example.com", true},
		{"sigstore", "sigstore", true},
		{"empty is unset", "", true},
		{"space inside", "https://vault .example.com", false},
		{"a newline", "https://vault.example.com\n", false},
		{"nbsp", "https://vault\u00a0.example.com", false},
		{"zero width space", "sig\u200bstore", false},
		{"rtl override", "sig\u202estore", false},
		{"line separator", "https://vault\u2028.example.com", false},
		{"c1 nel", "sig\u0085store", false},
		{"non-ascii letters", "sigst\u00f6re", false},
		{"invalid utf-8", "sigst\xff\xfdo", false},
		{"over 150 characters", strings.Repeat("a", 151), false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			yamlData := "engine: nixery\naudience: " + strconv.Quote(tt.audience)
			wf, err := FromFile("test.yml", []byte(yamlData))
			if tt.ok {
				assert.NoError(t, err)
				assert.Equal(t, tt.audience, wf.Audience)
			} else {
				assert.Error(t, err)
			}
		})
	}
}

func TestAudienceMustBeAScalar(t *testing.T) {
	yamlData := "engine: nixery\naudience: [a, b]"
	_, err := FromFile("test.yml", []byte(yamlData))
	assert.Error(t, err, "a list audience must not parse")
}

func TestUnmarshalWorkflowWithRunsOnLabels(t *testing.T) {
	yamlData := `
engine: microvm
runs_on: [linux/arm64, kvm]
when:
  - event: push`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err)

	assert.Equal(t, []string{"linux/arm64", "kvm"}, wf.RunsOn)
}

func TestUnmarshalCloneFalse(t *testing.T) {
	yamlData := `
when:
  - event: pull_request_close

clone:
  skip: true
`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err)

	assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event)

	assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}

func TestUnmarshalWorkflowWithTags(t *testing.T) {
	yamlData := `
when:
  - event: ["push"]
    tag: ["v*", "release-*"]`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err, "YAML should unmarshal without error")

	assert.Len(t, wf.When, 1, "Should have one constraint")
	assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
	assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
}

func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
	yamlData := `
when:
  - event: ["push"]
    branch: ["main", "develop"]
    tag: ["v*"]`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err, "YAML should unmarshal without error")

	assert.Len(t, wf.When, 1, "Should have one constraint")
	assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
	assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
}

func TestMatchesPattern(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		patterns []string
		expected bool
	}{
		{"exact match", "main", []string{"main"}, true},
		{"exact match in list", "develop", []string{"main", "develop"}, true},
		{"no match", "feature", []string{"main", "develop"}, false},
		{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
		{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
		{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
		{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
		{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
		{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
		{"double star no match", "feature/test", []string{"release/**"}, false},
		{"no patterns matches nothing", "anything", []string{}, false},
		{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
		{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
		{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := matchesPattern(tt.input, tt.patterns)
			assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
		})
	}
}

func TestConstraintMatchRef_Branches(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		ref        string
		expected   bool
	}{
		{
			name:       "exact branch match",
			constraint: Constraint{Branch: []string{"main"}},
			ref:        "refs/heads/main",
			expected:   true,
		},
		{
			name:       "branch glob match",
			constraint: Constraint{Branch: []string{"feature-*"}},
			ref:        "refs/heads/feature-123",
			expected:   true,
		},
		{
			name:       "branch no match",
			constraint: Constraint{Branch: []string{"main"}},
			ref:        "refs/heads/develop",
			expected:   false,
		},
		{
			name:       "no constraints matches nothing",
			constraint: Constraint{},
			ref:        "refs/heads/anything",
			expected:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := tt.constraint.MatchRef(tt.ref)
			assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
		})
	}
}

func TestConstraintMatchRef_Tags(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		ref        string
		expected   bool
	}{
		{
			name:       "exact tag match",
			constraint: Constraint{Tag: []string{"v1.0.0"}},
			ref:        "refs/tags/v1.0.0",
			expected:   true,
		},
		{
			name:       "tag glob match",
			constraint: Constraint{Tag: []string{"v*"}},
			ref:        "refs/tags/v1.2.3",
			expected:   true,
		},
		{
			name:       "tag glob with pattern",
			constraint: Constraint{Tag: []string{"release-*"}},
			ref:        "refs/tags/release-2024",
			expected:   true,
		},
		{
			name:       "tag no match",
			constraint: Constraint{Tag: []string{"v*"}},
			ref:        "refs/tags/release-1.0",
			expected:   false,
		},
		{
			name:       "tag not matched when only branch constraint",
			constraint: Constraint{Branch: []string{"main"}},
			ref:        "refs/tags/v1.0.0",
			expected:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := tt.constraint.MatchRef(tt.ref)
			assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
		})
	}
}

func TestConstraintMatchRef_Combined(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		ref        string
		expected   bool
	}{
		{
			name:       "matches branch in combined constraint",
			constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
			ref:        "refs/heads/main",
			expected:   true,
		},
		{
			name:       "matches tag in combined constraint",
			constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
			ref:        "refs/tags/v1.0.0",
			expected:   true,
		},
		{
			name:       "no match in combined constraint",
			constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
			ref:        "refs/heads/develop",
			expected:   false,
		},
		{
			name:       "glob patterns in combined constraint - branch",
			constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
			ref:        "refs/heads/release-2024",
			expected:   true,
		},
		{
			name:       "glob patterns in combined constraint - tag",
			constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
			ref:        "refs/tags/v2.0.0",
			expected:   true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := tt.constraint.MatchRef(tt.ref)
			assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
		})
	}
}

func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		branch     string
		expected   bool
	}{
		{
			name:       "exact match",
			constraint: Constraint{Branch: []string{"main"}},
			branch:     "main",
			expected:   true,
		},
		{
			name:       "glob match",
			constraint: Constraint{Branch: []string{"feature-*"}},
			branch:     "feature-123",
			expected:   true,
		},
		{
			name:       "no match",
			constraint: Constraint{Branch: []string{"main"}},
			branch:     "develop",
			expected:   false,
		},
		{
			name:       "multiple patterns with match",
			constraint: Constraint{Branch: []string{"main", "release-*"}},
			branch:     "release-1.0",
			expected:   true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := tt.constraint.MatchBranch(tt.branch)
			assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
		})
	}
}

func TestMatchesAnyFile(t *testing.T) {
	tests := []struct {
		name     string
		files    []string
		patterns []string
		expected bool
	}{
		{
			name:     "exact file match",
			files:    []string{"src/main.go"},
			patterns: []string{"src/main.go"},
			expected: true,
		},
		{
			name:     "glob match single star",
			files:    []string{"src/main.go"},
			patterns: []string{"src/*.go"},
			expected: true,
		},
		{
			name:     "glob match double star",
			files:    []string{"src/pkg/util.go"},
			patterns: []string{"src/**/*.go"},
			expected: true,
		},
		{
			name:     "any file in list matches",
			files:    []string{"README.md", "src/main.go", "docs/guide.md"},
			patterns: []string{"src/**"},
			expected: true,
		},
		{
			name:     "no file matches",
			files:    []string{"README.md", "docs/guide.md"},
			patterns: []string{"src/**"},
			expected: false,
		},
		{
			name:     "empty files list",
			files:    []string{},
			patterns: []string{"src/**"},
			expected: false,
		},
		{
			name:     "nil files list",
			files:    nil,
			patterns: []string{"src/**"},
			expected: false,
		},
		{
			name:     "multiple patterns, second matches",
			files:    []string{"docs/guide.md"},
			patterns: []string{"src/**", "docs/**"},
			expected: true,
		},
		{
			name:     "single star does not cross directory boundary",
			files:    []string{"src/pkg/util.go"},
			patterns: []string{"src/*.go"},
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, err := matchesAnyFile(tt.files, tt.patterns)
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestConstraintMatch_PathsFilter(t *testing.T) {
	pushTrigger := TriggerMetadata{
		Kind: string(TriggerKindPush),
		Push: &PushTriggerData{
			Ref: "refs/heads/main",
		},
	}

	tests := []struct {
		name         string
		constraint   Constraint
		changedFiles []string
		expected     bool
	}{
		{
			name: "paths match - workflow runs",
			constraint: Constraint{
				Event:  []string{"push"},
				Branch: []string{"main"},
				Paths:  []string{"src/**"},
			},
			changedFiles: []string{"src/main.go"},
			expected:     true,
		},
		{
			name: "paths no match - workflow skipped",
			constraint: Constraint{
				Event:  []string{"push"},
				Branch: []string{"main"},
				Paths:  []string{"src/**"},
			},
			changedFiles: []string{"docs/guide.md"},
			expected:     false,
		},
		{
			name: "no paths filter - all files pass",
			constraint: Constraint{
				Event:  []string{"push"},
				Branch: []string{"main"},
			},
			changedFiles: []string{"docs/guide.md"},
			expected:     true,
		},
		{
			name: "paths filter with empty changed files - skipped",
			constraint: Constraint{
				Event:  []string{"push"},
				Branch: []string{"main"},
				Paths:  []string{"src/**"},
			},
			changedFiles: []string{},
			expected:     false,
		},
		{
			name: "paths glob matches one of many changed files",
			constraint: Constraint{
				Event:  []string{"push"},
				Branch: []string{"main"},
				Paths:  []string{"**/*.go"},
			},
			changedFiles: []string{"README.md", "go.mod", "src/main.go"},
			expected:     true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, err := tt.constraint.Match(pushTrigger, tt.changedFiles)
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestUnmarshalWorkflowWithPaths(t *testing.T) {
	yamlData := `
when:
  - event: push
    branch: main
    paths:
      - "src/**"
      - "**.go"`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err)
	assert.Len(t, wf.When, 1)
	assert.ElementsMatch(t, []string{"src/**", "**.go"}, wf.When[0].Paths)
}

func TestUnmarshalWorkflowWithPathsSingleString(t *testing.T) {
	yamlData := `
when:
  - event: push
    branch: main
    paths: "src/**"`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err)
	assert.Len(t, wf.When, 1)
	assert.ElementsMatch(t, []string{"src/**"}, wf.When[0].Paths)
}

func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		tag        string
		expected   bool
	}{
		{
			name:       "exact match",
			constraint: Constraint{Tag: []string{"v1.0.0"}},
			tag:        "v1.0.0",
			expected:   true,
		},
		{
			name:       "glob match",
			constraint: Constraint{Tag: []string{"v*"}},
			tag:        "v2.3.4",
			expected:   true,
		},
		{
			name:       "no match",
			constraint: Constraint{Tag: []string{"v*"}},
			tag:        "release-1.0",
			expected:   false,
		},
		{
			name:       "multiple patterns with match",
			constraint: Constraint{Tag: []string{"v*", "release-*"}},
			tag:        "release-2024",
			expected:   true,
		},
		{
			name:       "empty tag list matches nothing",
			constraint: Constraint{Tag: []string{}},
			tag:        "v1.0.0",
			expected:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, _ := tt.constraint.MatchTag(tt.tag)
			assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
		})
	}
}

func TestUnmarshalWorkflowWithTypes(t *testing.T) {
	yamlData := `
when:
  - event: pull_request
    types: [opened, reopened, closed]
    branch: main`

	wf, err := FromFile("test.yml", []byte(yamlData))
	assert.NoError(t, err, "YAML should unmarshal without error")
	assert.Len(t, wf.When, 1)
	assert.ElementsMatch(t, []string{"opened", "reopened", "closed"}, wf.When[0].Types)
	assert.ElementsMatch(t, []string{"pull_request"}, wf.When[0].Event)
}

func TestConstraintMatchTypes(t *testing.T) {
	tests := []struct {
		name       string
		constraint Constraint
		action     string
		expected   bool
	}{
		{"empty types defaults to opened", Constraint{}, PullRequestActionOpened, true},
		{"empty types defaults to reopened", Constraint{}, PullRequestActionReopened, true},
		{"empty types defaults to synchronize", Constraint{}, PullRequestActionSynchronize, true},
		{"empty types does not match closed", Constraint{}, PullRequestActionClosed, false},
		{"empty types does not match merged", Constraint{}, PullRequestActionMerged, false},
		{"missing action treated as opened", Constraint{}, "", true},
		{"explicit closed matches", Constraint{Types: []string{"closed"}}, PullRequestActionClosed, true},
		{"explicit closed does not match opened", Constraint{Types: []string{"closed"}}, PullRequestActionOpened, false},
		{"merged is distinct from closed", Constraint{Types: []string{"closed"}}, PullRequestActionMerged, false},
		{"explicit merged matches", Constraint{Types: []string{"merged"}}, PullRequestActionMerged, true},
		{"multiple types", Constraint{Types: []string{"opened", "closed"}}, PullRequestActionClosed, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, tt.constraint.MatchTypes(tt.action))
		})
	}
}

func TestConstraintMatch_PathsFilterWithUnknownChangedFiles(t *testing.T) {
	constraint := func(event string) *Constraint {
		return &Constraint{Event: []string{event}, Branch: []string{"main"}, Paths: []string{"src/**"}}
	}

	t.Run("a push we could not diff runs", func(t *testing.T) {
		trigger := TriggerMetadata{
			Kind: string(TriggerKindPush),
			Push: &PushTriggerData{Ref: "refs/heads/main"},
		}
		matched, err := constraint("push").Match(trigger, nil)
		assert.NoError(t, err)
		assert.True(t, matched)
	})

	t.Run("a pull request keeps skipping on a list that does not match", func(t *testing.T) {
		trigger := TriggerMetadata{
			Kind:        string(TriggerKindPullRequest),
			PullRequest: &PullRequestTriggerData{TargetBranch: "main"},
		}
		matched, err := constraint("pull_request").Match(trigger, nil)
		assert.NoError(t, err)
		assert.False(t, matched)
	})
}

func TestConstraintMatch_PullRequestTypes(t *testing.T) {
	prTrigger := func(action string) TriggerMetadata {
		return TriggerMetadata{
			Kind: string(TriggerKindPullRequest),
			PullRequest: &PullRequestTriggerData{
				Action:       &action,
				TargetBranch: "main",
			},
		}
	}

	tests := []struct {
		name       string
		constraint Constraint
		action     string
		expected   bool
	}{
		{
			name:       "no types matches opened on target branch",
			constraint: Constraint{Event: []string{"pull_request"}, Branch: []string{"main"}},
			action:     PullRequestActionOpened,
			expected:   true,
		},
		{
			name:       "no types does not match closed",
			constraint: Constraint{Event: []string{"pull_request"}, Branch: []string{"main"}},
			action:     PullRequestActionClosed,
			expected:   false,
		},
		{
			name:       "explicit closed type matches close event",
			constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"main"}},
			action:     PullRequestActionClosed,
			expected:   true,
		},
		{
			name:       "closed type does not fire on open",
			constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"main"}},
			action:     PullRequestActionOpened,
			expected:   false,
		},
		{
			name:       "branch mismatch overrides matching type",
			constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"release"}},
			action:     PullRequestActionClosed,
			expected:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, err := tt.constraint.Match(prTrigger(tt.action), nil)
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestMatch_ManualDispatch(t *testing.T) {
	// manual dispatch is policy-free: every workflow matches regardless of its
	// declared event/branch/tag/path constraints. Selection is the caller's job.
	manualTrigger := TriggerMetadata{
		Kind:   string(TriggerKindManual),
		Manual: &ManualTriggerData{Sha: "deadbeef"},
	}

	workflows := []Workflow{
		{When: nil},
		{When: []Constraint{{Event: []string{"push"}, Branch: []string{"main"}}}},
		{When: []Constraint{{Event: []string{"pull_request"}, Paths: []string{"src/**"}}}},
		{When: []Constraint{{Event: []string{"push"}, Tag: []string{"v*"}}}},
	}

	for i, wf := range workflows {
		result, err := wf.Match(manualTrigger, nil)
		assert.NoError(t, err)
		assert.True(t, result, "workflow %d should match a manual dispatch", i)
	}
}

func TestUnmarshalWorkflowWithScheduleEntries(t *testing.T) {
	wf, err := FromFile("nightly.yml", []byte(`
when:
  - event: schedule
    schedule:
      - cron: "30 2 * * *"
        timezone: America/New_York
      - cron: "0 9 * * 1-5"
        timezone: Europe/London
`))
	assert.NoError(t, err)
	assert.Equal(t, []Schedule{
		{Cron: "30 2 * * *", Timezone: "America/New_York"},
		{Cron: "0 9 * * 1-5", Timezone: "Europe/London"},
	}, wf.When[0].Schedule)
}

func TestUnmarshalWorkflowUsesNameBeforeHashedCronValidation(t *testing.T) {
	wf, err := FromFile("hashed.yml", []byte(`
when:
  - event: schedule
    schedule:
      - cron: "0 0 31 H *"
`))
	assert.NoError(t, err)
	assert.Equal(t, "hashed.yml", wf.Name)
}

func TestUnmarshalWorkflowRejectsInvalidCronSchedule(t *testing.T) {
	tests := []struct {
		name string
		yaml string
	}{
		{
			name: "schedule event without entries",
			yaml: "when:\n  - event: schedule\n",
		},
		{
			name: "schedule entry with timezone without event",
			yaml: "when:\n  - event: push\n    branch: main\n    schedule:\n      - cron: \"0 9 * * *\"\n        timezone: America/New_York\n",
		},
		{
			name: "schedule entry without cron",
			yaml: "when:\n  - event: schedule\n    schedule:\n      - {}\n",
		},
		{
			name: "six field cron",
			yaml: "when:\n  - event: schedule\n    schedule:\n      - cron: \"0 0 9 * * *\"\n",
		},
		{
			name: "invalid cron value",
			yaml: "when:\n  - event: schedule\n    schedule:\n      - cron: \"70 9 * * *\"\n",
		},
		{
			name: "impossible date",
			yaml: "when:\n  - event: schedule\n    schedule:\n      - cron: \"0 0 30 2 *\"\n",
		},
		{
			name: "invalid timezone",
			yaml: "when:\n  - event: schedule\n    schedule:\n      - cron: \"0 9 * * *\"\n        timezone: Mars/Olympus_Mons\n",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := FromFile("invalid.yml", []byte(test.yaml))
			assert.Error(t, err)
		})
	}
}

func TestUnmarshalWorkflowRejectsScheduleFilters(t *testing.T) {
	tests := []struct {
		name string
		yaml string
	}{
		{
			name: "branch",
			yaml: "when:\n  - event: schedule\n    branch: main\n    schedule:\n      - cron: \"0 9 * * *\"\n",
		},
		{
			name: "tag",
			yaml: "when:\n  - event: schedule\n    tag: v*\n    schedule:\n      - cron: \"0 9 * * *\"\n",
		},
		{
			name: "types",
			yaml: "when:\n  - event: schedule\n    types: opened\n    schedule:\n      - cron: \"0 9 * * *\"\n",
		},
		{
			name: "paths",
			yaml: "when:\n  - event: schedule\n    paths: '**/*.go'\n    schedule:\n      - cron: \"0 9 * * *\"\n",
		},
		{
			name: "schedule in event list",
			yaml: "when:\n  - event: [push, schedule]\n    branch: main\n    schedule:\n      - cron: \"0 9 * * *\"\n",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := FromFile("invalid.yml", []byte(test.yaml))
			assert.Error(t, err)
		})
	}
}

func TestParseCronUsesTimezone(t *testing.T) {
	after := time.Date(2026, time.August, 10, 9, 29, 0, 0, time.UTC)
	schedule, err := ParseCron("30 5 * * 1-5", "America/New_York", "timezone-test")
	assert.NoError(t, err)
	assert.Equal(t, time.Date(2026, time.August, 10, 9, 30, 0, 0, time.UTC), schedule.Next(after))
}

func TestParseCronKeepsExactExpressionsIndependentOfSeed(t *testing.T) {
	after := time.Date(2026, time.August, 10, 9, 29, 0, 0, time.UTC)
	first, err := ParseCron("30 5 * * 1-5", "UTC", "first-seed")
	assert.NoError(t, err)
	second, err := ParseCron("30 5 * * 1-5", "UTC", "second-seed")
	assert.NoError(t, err)
	assert.Equal(t, first.Next(after), second.Next(after))
}

func TestParseCronPreservesNamedWeekdaysAlongsideHashedTerms(t *testing.T) {
	after := time.Date(2026, time.January, 1, 2, 59, 0, 0, time.UTC)
	named, err := ParseCron("0 3 * * THU", "UTC", "named-weekday-seed")
	assert.NoError(t, err)
	assert.Equal(t, time.Thursday, named.Next(after).Weekday())

	mixed, err := ParseCron("0 3 * * THU,H", "UTC", "named-weekday-seed")
	assert.NoError(t, err)
	assert.NotZero(t, mixed.Next(after))
}

func TestParseCronHashedFieldsAreStableAndBounded(t *testing.T) {
	after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
	first, err := ParseCron("H H H H *", "UTC", "stable-seed")
	assert.NoError(t, err)
	second, err := ParseCron("H H H H *", "UTC", "stable-seed")
	assert.NoError(t, err)

	firstOccurrence := first.Next(after)
	secondOccurrence := second.Next(after)
	assert.Equal(t, firstOccurrence, secondOccurrence)
	assert.NotZero(t, firstOccurrence)
	assert.GreaterOrEqual(t, firstOccurrence.Minute(), 0)
	assert.LessOrEqual(t, firstOccurrence.Minute(), 59)
	assert.GreaterOrEqual(t, firstOccurrence.Hour(), 0)
	assert.LessOrEqual(t, firstOccurrence.Hour(), 23)
	assert.GreaterOrEqual(t, firstOccurrence.Day(), 1)
	assert.LessOrEqual(t, firstOccurrence.Day(), 28)
	assert.GreaterOrEqual(t, int(firstOccurrence.Month()), 1)
	assert.LessOrEqual(t, int(firstOccurrence.Month()), 12)
	assert.GreaterOrEqual(t, int(firstOccurrence.Weekday()), 0)
	assert.LessOrEqual(t, int(firstOccurrence.Weekday()), 6)
}

func TestParseCronHashedRangeAndStep(t *testing.T) {
	after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
	schedule, err := ParseCron("H(10-20)/5 * * * *", "UTC", "range-step-seed")

	assert.NoError(t, err)

	occurrence := schedule.Next(after)
	assert.NotZero(t, occurrence)
	offset := occurrence.Minute() % 5
	for range 8 {
		assert.GreaterOrEqual(t, occurrence.Minute(), 10)
		assert.LessOrEqual(t, occurrence.Minute(), 20)
		assert.Equal(t, offset, occurrence.Minute()%5)
		occurrence = schedule.Next(occurrence)
	}
}

func TestParseCronHashedStepOneKeepsJenkinsSingleValueSemantics(t *testing.T) {
	after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
	schedule, err := ParseCron("H/1 * * * *", "UTC", "step-one-seed")
	assert.NoError(t, err)

	first := schedule.Next(after)
	second := schedule.Next(first)
	assert.Equal(t, time.Hour, second.Sub(first))
}

func TestParseCronRejectsMalformedHashedFields(t *testing.T) {
	for _, expression := range []string{
		"H(foo) * * * *",
		"H(20-10) * * * *",
		"H(0-60) * * * *",
		"H/0 * * * *",
		"H(0-5)/7 * * * *",
		"5H * * * *",
	} {
		t.Run(expression, func(t *testing.T) {
			_, err := ParseCron(expression, "UTC", "malformed-seed")
			assert.Error(t, err)
		})
	}
}

func TestHasHashedCron(t *testing.T) {
	assert.True(t, HasHashedCron("H(0-29)/10 * * * *"))
	assert.True(t, HasHashedCron("H * * * *"))
	assert.True(t, HasHashedCron("0 3 * * THU,H"))
	assert.False(t, HasHashedCron("0 3 * * THU"))
	assert.False(t, HasHashedCron("*/5 * * * *"))
}

func TestParseCronAllowsIntervalsBelowHostedMinimum(t *testing.T) {
	_, err := ParseCron("*/4 * * * *", "UTC", "interval-seed")
	assert.NoError(t, err)
}

type stuckCronSchedule struct {
	at time.Time
}

func (s stuckCronSchedule) Next(time.Time) time.Time {
	return s.at
}

func TestValidateScheduleIntervalRejectsNonAdvancingSchedule(t *testing.T) {
	after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
	schedule := stuckCronSchedule{at: after.Add(time.Minute)}
	assert.Error(t, ValidateScheduleInterval([]cron.Schedule{schedule}, 5*time.Minute, after))
}

func TestValidateScheduleInterval(t *testing.T) {
	after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
	closeFirst, err := ParseCron("0 * * * *", "UTC", "close-first")
	assert.NoError(t, err)
	closeSecond, err := ParseCron("2 * * * *", "UTC", "close-second")
	assert.NoError(t, err)
	assert.Error(t, ValidateScheduleInterval([]cron.Schedule{closeFirst, closeSecond}, 5*time.Minute, after))

	boundary, err := ParseCron("5 * * * *", "UTC", "boundary")
	assert.NoError(t, err)
	assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{closeFirst, boundary}, 5*time.Minute, after))

	duplicate, err := ParseCron("0 9 * * *", "UTC", "duplicate")
	assert.NoError(t, err)
	assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{duplicate, duplicate}, 5*time.Minute, after))

	sparse, err := ParseCron("0 0 29 2 *", "UTC", "sparse")
	assert.NoError(t, err)
	assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{sparse}, 5*time.Minute, after))
}
