// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package jobparser

import (
	"fmt"
	"strings"
	"testing"

	"gitea.com/gitea/runner/act/model"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.yaml.in/yaml/v4"
)

func TestParseRawOn(t *testing.T) {
	kases := []struct {
		input  string
		result []*Event
	}{
		{
			input: "on: issue_comment",
			result: []*Event{
				{
					Name: "issue_comment",
				},
			},
		},
		{
			input: "on:\n  push",
			result: []*Event{
				{
					Name: "push",
				},
			},
		},

		{
			input: "on:\n  - push\n  - pull_request",
			result: []*Event{
				{
					Name: "push",
				},
				{
					Name: "pull_request",
				},
			},
		},
		{
			input: "on:\n  push:\n    branches:\n      - master",
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{
						"branches": {
							"master",
						},
					},
				},
			},
		},
		{
			input: "on:\n  push:\n    branches: main",
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{
						"branches": {
							"main",
						},
					},
				},
			},
		},
		{
			input: "on:\n  branch_protection_rule:\n    types: [created, deleted]",
			result: []*Event{
				{
					Name: "branch_protection_rule",
					acts: map[string][]string{
						"types": {
							"created",
							"deleted",
						},
					},
				},
			},
		},
		{
			input: "on:\n  project:\n    types: [created, deleted]\n  milestone:\n    types: [opened, deleted]",
			result: []*Event{
				{
					Name: "project",
					acts: map[string][]string{
						"types": {
							"created",
							"deleted",
						},
					},
				},
				{
					Name: "milestone",
					acts: map[string][]string{
						"types": {
							"opened",
							"deleted",
						},
					},
				},
			},
		},
		{
			input: "on:\n  pull_request:\n    types:\n      - opened\n    branches:\n      - 'releases/**'",
			result: []*Event{
				{
					Name: "pull_request",
					acts: map[string][]string{
						"types": {
							"opened",
						},
						"branches": {
							"releases/**",
						},
					},
				},
			},
		},
		{
			input: "on:\n  push:\n    branches:\n      - main\n  pull_request:\n    types:\n      - opened\n    branches:\n      - '**'",
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{
						"branches": {
							"main",
						},
					},
				},
				{
					Name: "pull_request",
					acts: map[string][]string{
						"types": {
							"opened",
						},
						"branches": {
							"**",
						},
					},
				},
			},
		},
		{
			input: "on:\n  push:\n    branches:\n      - 'main'\n      - 'releases/**'",
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{
						"branches": {
							"main",
							"releases/**",
						},
					},
				},
			},
		},
		{
			input: "on:\n  push:\n    tags:\n      - v1.**",
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{
						"tags": {
							"v1.**",
						},
					},
				},
			},
		},
		{
			input: "on: [pull_request, workflow_dispatch]",
			result: []*Event{
				{
					Name: "pull_request",
				},
				{
					Name: "workflow_dispatch",
				},
			},
		},
		{
			input: "on:\n  schedule:\n    - cron: '20 6 * * *'",
			result: []*Event{
				{
					Name: "schedule",
					schedules: []map[string]string{
						{
							"cron": "20 6 * * *",
						},
					},
				},
			},
		},
		{
			input: `on:
  workflow_dispatch:
    inputs:
      logLevel:
        description: 'Log level'
        required: true
        default: 'warning'
        type: choice
        options:
        - info
        - warning
        - debug
      tags:
        description: 'Test scenario tags'
        required: false
        type: boolean
      environment:
        description: 'Environment to run tests against'
        type: environment
        required: true
  push:
`,
			result: []*Event{
				{
					Name: "workflow_dispatch",
					inputs: []WorkflowDispatchInput{
						{
							Name:        "logLevel",
							Description: "Log level",
							Required:    true,
							Default:     "warning",
							Type:        "choice",
							Options:     []string{"info", "warning", "debug"},
						},
						{
							Name:        "tags",
							Description: "Test scenario tags",
							Required:    false,
							Type:        "boolean",
						},
						{
							Name:        "environment",
							Description: "Environment to run tests against",
							Type:        "environment",
							Required:    true,
						},
					},
				},
				{
					Name: "push",
				},
			},
		},
		{
			// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
			input: `on:
  workflow_call:
    inputs:
      env:
        type: string
        required: true
    outputs:
      sha:
        value: ${{ jobs.build.outputs.commit }}
    secrets:
      DEPLOY_KEY:
        required: true
`,
			result: []*Event{},
		},
		{
			// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
			input: `on:
  workflow_call:
    inputs:
      env:
        type: string
  push:
    branches: [main]
`,
			result: []*Event{
				{
					Name: "push",
					acts: map[string][]string{"branches": {"main"}},
				},
			},
		},
		{
			// Scalar form: a purely reusable workflow has no event triggers.
			input:  "on: workflow_call",
			result: []*Event{},
		},
		{
			// Sequence form: `workflow_call` is excluded while sibling events are kept.
			input: "on:\n  - push\n  - workflow_call\n  - pull_request",
			result: []*Event{
				{Name: "push"},
				{Name: "pull_request"},
			},
		},
	}
	for _, kase := range kases {
		t.Run(kase.input, func(t *testing.T) {
			origin, err := model.ReadWorkflow(strings.NewReader(kase.input))
			assert.NoError(t, err)

			events, err := ParseRawOn(&origin.RawOn)
			assert.NoError(t, err)
			assert.Equal(t, kase.result, events, events)
		})
	}
}

func TestSingleWorkflow_SetJob(t *testing.T) {
	t.Run("erase needs", func(t *testing.T) {
		content := ReadTestdata(t, "erase_needs.in.yaml")
		want := ReadTestdata(t, "erase_needs.out.yaml")
		swf, err := Parse(content)
		require.NoError(t, err)
		builder := &strings.Builder{}
		for _, v := range swf {
			id, job := v.Job()
			require.NoError(t, v.SetJob(id, job.EraseNeeds()))

			if builder.Len() > 0 {
				builder.WriteString("---\n")
			}
			encoder := yaml.NewEncoder(builder)
			encoder.SetIndent(2)
			require.NoError(t, encoder.Encode(v))
		}
		assert.Equal(t, string(want), builder.String())
	})
}

func TestGetContinueOnError(t *testing.T) {
	tests := []struct {
		name string
		yaml string
		want bool
	}{
		{
			name: "absent",
			yaml: "name: test\non: push\njobs:\n  job1:\n    runs-on: ubuntu-22.04\n    steps:\n      - run: echo hi\n",
			want: false,
		},
		{
			name: "static true",
			yaml: "name: test\non: push\njobs:\n  job1:\n    runs-on: ubuntu-22.04\n    continue-on-error: true\n    steps:\n      - run: echo hi\n",
			want: true,
		},
		{
			name: "static false",
			yaml: "name: test\non: push\njobs:\n  job1:\n    runs-on: ubuntu-22.04\n    continue-on-error: false\n    steps:\n      - run: echo hi\n",
			want: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Parse([]byte(tt.yaml))
			require.NoError(t, err)
			require.Len(t, got, 1)
			_, job := got[0].Job()
			assert.Equal(t, tt.want, job.GetContinueOnError())
		})
	}

	// Expression case: ${{ matrix.experimental }} must resolve per matrix variant.
	t.Run("matrix expression", func(t *testing.T) {
		content := ReadTestdata(t, "continue_on_error_expr.in.yaml")
		got, err := Parse(content)
		require.NoError(t, err)
		require.Len(t, got, 2)
		// sorted by matrix name: (false) before (true)
		_, jobFalse := got[0].Job()
		_, jobTrue := got[1].Job()
		assert.False(t, jobFalse.GetContinueOnError())
		assert.True(t, jobTrue.GetContinueOnError())
	})
}

func TestParseMappingNode(t *testing.T) {
	tests := []struct {
		input   string
		scalars []string
		datas   []any
	}{
		{
			input:   "on:\n  push:\n    branches:\n      - master",
			scalars: []string{"push"},
			datas: []any{
				map[string]any{
					"branches": []any{"master"},
				},
			},
		},
		{
			input:   "on:\n  branch_protection_rule:\n    types: [created, deleted]",
			scalars: []string{"branch_protection_rule"},
			datas: []any{
				map[string]any{
					"types": []any{"created", "deleted"},
				},
			},
		},
		{
			input:   "on:\n  project:\n    types: [created, deleted]\n  milestone:\n    types: [opened, deleted]",
			scalars: []string{"project", "milestone"},
			datas: []any{
				map[string]any{
					"types": []any{"created", "deleted"},
				},
				map[string]any{
					"types": []any{"opened", "deleted"},
				},
			},
		},
		{
			input:   "on:\n  pull_request:\n    types:\n      - opened\n    branches:\n      - 'releases/**'",
			scalars: []string{"pull_request"},
			datas: []any{
				map[string]any{
					"types":    []any{"opened"},
					"branches": []any{"releases/**"},
				},
			},
		},
		{
			input:   "on:\n  push:\n    branches:\n      - main\n  pull_request:\n    types:\n      - opened\n    branches:\n      - '**'",
			scalars: []string{"push", "pull_request"},
			datas: []any{
				map[string]any{
					"branches": []any{"main"},
				},
				map[string]any{
					"types":    []any{"opened"},
					"branches": []any{"**"},
				},
			},
		},
		{
			input:   "on:\n  schedule:\n    - cron: '20 6 * * *'",
			scalars: []string{"schedule"},
			datas: []any{
				[]any{map[string]any{
					"cron": "20 6 * * *",
				}},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.input, func(t *testing.T) {
			workflow, err := model.ReadWorkflow(strings.NewReader(test.input))
			assert.NoError(t, err)

			scalars, datas, err := parseMappingNode[any](&workflow.RawOn)
			assert.NoError(t, err)
			assert.Equal(t, test.scalars, scalars, scalars)
			assert.Equal(t, test.datas, datas, datas)
		})
	}
}

func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
	ifExprs := []string{
		`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
		`${{ contains('["linux","windows"]', matrix.target) }}`,
	}

	want := map[string]bool{
		"build (linux)":   true,
		"build (windows)": true,
		"build (macos)":   false,
	}

	for _, ifExpr := range ifExprs {
		t.Run(ifExpr, func(t *testing.T) {
			content := fmt.Sprintf(`
name: test
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    if: %s
    strategy:
      fail-fast: false
      matrix:
        target: [linux, windows, macos]
    steps:
      - run: echo ${{ matrix.target }}
`, ifExpr)

			swfs, err := Parse([]byte(content))
			require.NoError(t, err)
			require.Len(t, swfs, 3)

			got := make(map[string]bool, len(swfs))
			for _, swf := range swfs {
				id, job := swf.Job()
				shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
				require.NoError(t, err)
				got[job.Name] = shouldRun
			}
			assert.Equal(t, want, got)
		})
	}
}

func TestEvaluateJobIfExpression(t *testing.T) {
	kases := []struct {
		name       string
		ifCond     string
		needResult string
		expected   bool
	}{
		{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
		{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
		{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
		{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
		{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
		// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
		{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
		{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
		{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
	}
	for _, kase := range kases {
		t.Run(kase.name, func(t *testing.T) {
			content := strings.ReplaceAll(`
name: test
on: push
jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - run: echo job1
  job2:
    runs-on: ubuntu-latest
    needs: [job1]
    if: IF_COND
    steps:
      - run: echo job2
`, "IF_COND", kase.ifCond)

			workflows, err := Parse([]byte(content))
			require.NoError(t, err)

			var job2 *Job
			for _, wf := range workflows {
				if id, job := wf.Job(); id == "job2" {
					job2 = job
				}
			}
			require.NotNil(t, job2)

			// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
			results := map[string]*JobResult{
				"job1": {Result: kase.needResult},
				"job2": {Needs: []string{"job1"}},
			}
			got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
			require.NoError(t, err)
			assert.Equal(t, kase.expected, got)
		})
	}
}
