/*
Copyright The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package policy

// This file validates Kubernetes's jobs configs against policies.

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	yaml "sigs.k8s.io/yaml/goyaml.v3"

	cfg "sigs.k8s.io/prow/pkg/config"

	// Importing these packages triggers rerunning the test
	// each time some input file changes.
	_ "k8s.io/test-infra/config/jobs"
	_ "k8s.io/test-infra/config/prow"
)

const configPath = "../../../../config/prow/config.yaml"
const jobConfigPath = "../../../jobs"

// Loaded at TestMain.
var c *cfg.Config

func TestMain(m *testing.M) {
	conf, err := cfg.Load(configPath, jobConfigPath, nil, "")
	if err != nil {
		fmt.Printf("Could not load config: %v", err)
		os.Exit(1)
	}
	c = conf

	os.Exit(m.Run())
}

func TestKubernetesPresubmitJobs(t *testing.T) {
	// If sigs.k8s.io/prow/pkg/config could load from io.FS and bytes,
	// then we could load from jobs.Jobs  and prow.ConfigYAML directly.
	// Probably not important, so instead we use those package only to
	// trigger rerunning the test and load files directly.
	c, err := cfg.Load(configPath, jobConfigPath, nil, "")
	if err != nil {
		t.Fatalf("Could not load config: %v", err)
	}

	jobs := c.AllStaticPresubmits([]string{"kubernetes/kubernetes"})
	var expected presubmitJobs

	for _, job := range jobs {
		// Mirror those attributes of the job which must trigger additional reviews
		// or are needed to identify the job.
		j := presubmitJob{
			Name:         job.Name,
			SkipBranches: job.SkipBranches,
			Branches:     job.Branches,

			Optional:          job.Optional,
			SkipReport:        job.SkipReport,
			RunIfChanged:      job.RunIfChanged,
			SkipIfOnlyChanged: job.SkipIfOnlyChanged,
		}

		// This uses separate top-level fields instead of job attributes to
		// make it more obvious when run_if_changed is used.
		switch {
		case job.AlwaysRun:
			expected.AlwaysRun = append(expected.AlwaysRun, j)
		case job.RunIfChanged == "":
			// Manually triggered, no additional review needed.
			continue
		case job.Optional:
			// Don't encode `optional: true`, it's always true for these jobs.
			j.Optional = false
			expected.OptionallyRunIfChanged = append(expected.OptionallyRunIfChanged, j)
		default:
			t.Errorf("ERROR: A job with `always_run: false` and `run_if_changed` must have `optional: true`.\nSee https://github.com/kubernetes/community/blob/master/contributors/devel/sig-testing/testing-strategy.md#presubmit-jobs.\n\n%#v", j)
		}
	}
	expected.Normalize()

	// Encode the expected content.
	var expectedData bytes.Buffer
	if _, err := expectedData.Write([]byte(`# AUTOGENERATED by "make update-config-fixture". DO NOT EDIT!

`)); err != nil {
		t.Fatalf("unexpected error writing into buffer: %v", err)
	}

	encoder := yaml.NewEncoder(&expectedData)
	encoder.SetIndent(4)
	if err := encoder.Encode(expected); err != nil {
		t.Fatalf("unexpected error encoding %s: %v", presubmitsFile, err)
	}
	expectedDataString := expectedData.String()

	// Inject some additional comments.
	expectedDataString = strings.ReplaceAll(expectedDataString, "optional_and_run_if_changed:", `
# Non-blocking and automatically triggered presubmits must be stable and
# (like all presubmits) must have a corresponding periodic.
# See https://github.com/kubernetes/community/blob/master/contributors/devel/sig-testing/testing-strategy.md#presubmit-jobs
optional_and_run_if_changed:`)

	// Compare. This proceeds on read or decoding errors because
	// the file might get re-generated below.
	var actual presubmitJobs
	actualData, err := os.ReadFile(presubmitsFile)
	if err != nil && !os.IsNotExist(err) {
		t.Errorf("unexpected error: %v", err)
	}
	if err := yaml.Unmarshal(actualData, &actual); err != nil {
		t.Errorf("unexpected error decoding %s: %v", presubmitsFile, err)
	}

	// First check the in-memory structs. The diff is nicer for them (more context).
	diff := cmp.Diff(actual, expected)
	if diff == "" {
		// Next check the encoded data. This should only be different on test updates.
		diff = cmp.Diff(string(actualData), expectedDataString, cmpopts.AcyclicTransformer("SplitLines", func(s string) []string {
			return strings.Split(s, "\n")
		}))
	}

	if diff != "" {
		if value, _ := os.LookupEnv("UPDATE_FIXTURE_DATA"); value == "true" {
			if err := os.WriteFile(presubmitsFile, []byte(expectedDataString), 0644); err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			t.Logf(`
%s was out-dated. Updated as requested with the following changes (- actual, + expected):
%s
`, maybeAbsPath(presubmitsFile), diff)
		} else {
			t.Errorf(`
%s is out-dated. Detected differences (- actual, + expected):
%s

Blocking pre-submit jobs must be for stable, important features.
Non-blocking pre-submit jobs should only be run automatically if they meet
the criteria outlined in https://github.com/kubernetes/community/pull/8196.

To ensure that this is considered when defining pre-submit jobs, they
need to be listed in %s. If the pre-submit job is really needed,
re-run the test with UPDATE_FIXTURE_DATA=true and include the modified
file. The following command can be used:

   make update-config-fixture
`, presubmitsFile, diff, presubmitsFile)
		}
	}
}

// presubmitsFile contains the following struct.
const presubmitsFile = "presubmit-jobs.yaml"

type presubmitJobs struct {
	AlwaysRun              []presubmitJob `yaml:"always_run"`
	OptionallyRunIfChanged []presubmitJob `yaml:"optional_and_run_if_changed"`
}
type presubmitJob struct {
	Name              string   `yaml:"name"`
	SkipBranches      []string `yaml:"skip_branches,omitempty"`
	Branches          []string `yaml:"branches,omitempty"`
	Optional          bool     `yaml:"optional,omitempty"`
	SkipReport        bool     `yaml:"skip_report,omitempty"`
	RunIfChanged      string   `yaml:"run_if_changed,omitempty"`
	SkipIfOnlyChanged string   `yaml:"skip_if_only_changed,omitempty"`
}

func (p *presubmitJobs) Normalize() {
	sortJobs(&p.AlwaysRun)
	sortJobs(&p.OptionallyRunIfChanged)
}

func sortJobs(jobs *[]presubmitJob) {
	for _, job := range *jobs {
		sort.Strings(job.SkipBranches)
		sort.Strings(job.Branches)
	}
	sort.Slice(*jobs, func(i, j int) bool {
		switch strings.Compare((*jobs)[i].Name, (*jobs)[j].Name) {
		case -1:
			return true
		case 1:
			return false
		}
		switch slices.Compare((*jobs)[i].SkipBranches, (*jobs)[j].SkipBranches) {
		case -1:
			return true
		case 1:
			return false
		}
		switch slices.Compare((*jobs)[i].Branches, (*jobs)[j].Branches) {
		case -1:
			return true
		case 1:
			return false
		}
		return false
	})

	// If a job has the same settings regardless of the branch, then
	// we can reduce to a single entry without the branch info.
	shorterJobs := make([]presubmitJob, 0, len(*jobs))
	for i := 0; i < len(*jobs); {
		job := (*jobs)[i]
		job.Branches = nil
		job.SkipBranches = nil

		if sameSettings(*jobs, job) {
			shorterJobs = append(shorterJobs, job)
			// Fast-forward to next job.
			for i < len(*jobs) && (*jobs)[i].Name == job.Name {
				i++
			}
		} else {
			// Keep all of the different entries.
			for i < len(*jobs) && (*jobs)[i].Name == job.Name {
				shorterJobs = append(shorterJobs, (*jobs)[i])
				i++
			}
		}
	}
	*jobs = shorterJobs
}

func sameSettings(jobs []presubmitJob, ref presubmitJob) bool {
	for _, job := range jobs {
		if job.Name != ref.Name {
			continue
		}
		if job.RunIfChanged != ref.RunIfChanged ||
			job.SkipIfOnlyChanged != ref.SkipIfOnlyChanged {
			return false
		}
	}
	return true
}

// maybeAbsPath tries to make a path absolute. This is useful because
// relative paths in test output tend to be confusing when the user
// invoked the test outside of the test's directory.
func maybeAbsPath(path string) string {
	if path, err := filepath.Abs(path); err == nil {
		return path
	}
	return path
}
