// Copyright 2023 Woodpecker 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 linter

import (
	"fmt"
	"slices"

	"codeberg.org/6543/xyaml/v2"
	"go.uber.org/multierr"

	pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils"
	"go.woodpecker-ci.org/woodpecker/v3/shared/constant"
)

// networkModeNone is a const we use to check to allow to drop network completely
// this should be exempt from privileged action as it makes the container even more unprivileged.
const networkModeNone = "none"

// A Linter lints a pipeline configuration.
type Linter struct {
	trusted             TrustedConfiguration
	privilegedPlugins   *[]string
	trustedClonePlugins *[]string
}

type TrustedConfiguration struct {
	Network  bool
	Volumes  bool
	Security bool
}

// New creates a new Linter with options.
func New(opts ...Option) *Linter {
	linter := new(Linter)
	for _, opt := range opts {
		opt(linter)
	}
	return linter
}

type WorkflowConfig struct {
	// File is the path to the configuration file.
	File string

	// RawConfig is the raw configuration.
	RawConfig string

	// Config is the parsed configuration.
	Workflow *types.Workflow
}

// Lint lints the configuration.
func (l *Linter) Lint(configs []*WorkflowConfig) error {
	var linterErr error

	for _, config := range configs {
		if err := l.lintFile(config); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
	}

	return linterErr
}

func (l *Linter) lintFile(config *WorkflowConfig) error {
	var linterErr error

	if len(config.Workflow.Steps.ContainerList) == 0 {
		linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing `steps` section", config.File, "steps", false))
	}

	if err := l.lintCloneSteps(config); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}

	if err := l.lintContainers(config, "clone"); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}
	if err := l.lintContainers(config, "steps"); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}
	if err := l.lintContainers(config, "services"); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}

	if err := l.lintSchema(config); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}
	if err := l.lintDeprecations(config); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}
	if err := l.lintBadHabits(config); err != nil {
		linterErr = multierr.Append(linterErr, err)
	}

	return linterErr
}

func (l *Linter) lintCloneSteps(config *WorkflowConfig) error {
	if len(config.Workflow.Clone.ContainerList) == 0 {
		return nil
	}

	trustedClonePlugins := constant.TrustedClonePlugins
	if l.trustedClonePlugins != nil {
		trustedClonePlugins = *l.trustedClonePlugins
	}

	var linterErr error
	for _, container := range config.Workflow.Clone.ContainerList {
		if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) {
			linterErr = multierr.Append(
				linterErr,
				newLinterError(
					"Specified clone image does not match allow list, netrc is not injected",
					config.File, fmt.Sprintf("clone.%s", container.Name), true,
				),
			)
		}
	}
	return linterErr
}

func (l *Linter) lintContainers(config *WorkflowConfig, area string) error {
	var linterErr error

	var containers []*types.Container

	switch area {
	case "clone":
		containers = config.Workflow.Clone.ContainerList
	case "steps":
		containers = config.Workflow.Steps.ContainerList
	case "services":
		containers = config.Workflow.Services.ContainerList
	}

	for _, container := range containers {
		if err := l.lintImage(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
		if err := l.lintTrusted(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
		if err := l.lintSettings(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
		if err := l.lintPrivilegedPlugins(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
		if err := l.lintContainerDeprecations(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
		if err := l.lintDependsOn(config, container, area); err != nil {
			linterErr = multierr.Append(linterErr, err)
		}
	}

	return linterErr
}

func (l *Linter) lintDependsOn(config *WorkflowConfig, c *types.Container, area string) error {
	if area != "steps" {
		return nil
	}

	var linterErr error
	for _, dep := range c.DependsOn {
		if slices.ContainsFunc(
			config.Workflow.Steps.ContainerList,
			func(step *types.Container) bool { return dep.Name == step.Name },
		) {
			continue
		}
		if dep.Optional {
			continue
		}
		linterErr = multierr.Append(
			linterErr,
			newLinterError(
				"One or more of the specified dependencies do not exist",
				config.File, fmt.Sprintf("%s.%s.depends_on", area, c.Name), false,
			),
		)
	}
	return linterErr
}

func (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area string) error {
	if len(c.Image) == 0 {
		return newLinterError("Invalid or missing image", config.File, fmt.Sprintf("%s.%s", area, c.Name), false)
	}
	return nil
}

func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error {
	// lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918
	if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") && !c.Privileged {
		msg := fmt.Sprintf("The formerly privileged plugin `%s` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`", c.Image)
		// check first if user did not add them back
		if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) {
			return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false)
		} else if l.privilegedPlugins == nil {
			// if linter has no info of current privileged plugins, it's just a warning
			return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), true)
		}
	}

	return nil
}

func (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field string) error {
	if len(c.Settings) == 0 {
		return nil
	}
	if len(c.Commands) != 0 {
		return newLinterError("Cannot configure both `commands` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
	}
	if len(c.Entrypoint) != 0 {
		return newLinterError("Cannot configure both `entrypoint` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
	}
	if len(c.Environment) != 0 {
		return newLinterError("Should not configure both `environment` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
	}
	return nil
}

func (l *Linter) lintContainerDeprecations(config *WorkflowConfig, c *types.Container, field string) error {
	return nil
}

func (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area string) error {
	yamlPath := fmt.Sprintf("%s.%s", area, c.Name)
	errors := []string{}
	if !l.trusted.Security {
		if c.Privileged {
			errors = append(errors, "Insufficient trust level to use `privileged` mode")
		}
	}
	if !l.trusted.Network {
		if len(c.DNS) != 0 {
			errors = append(errors, "Insufficient trust level to use custom `dns`")
		}
		if len(c.DNSSearch) != 0 {
			errors = append(errors, "Insufficient trust level to use `dns_search`")
		}
		if len(c.ExtraHosts) != 0 {
			errors = append(errors, "Insufficient trust level to use `extra_hosts`")
		}
		if len(c.NetworkMode) != 0 && c.NetworkMode != networkModeNone {
			errors = append(errors, "Insufficient trust level to use `network_mode`")
		}
	}
	if !l.trusted.Volumes {
		if len(c.Devices) != 0 {
			errors = append(errors, "Insufficient trust level to use `devices`")
		}
		if len(c.Volumes.Volumes) != 0 {
			errors = append(errors, "Insufficient trust level to use `volumes`")
		}
		if len(c.Tmpfs) != 0 {
			errors = append(errors, "Insufficient trust level to use `tmpfs`")
		}
	}

	if len(errors) > 0 {
		var err error

		for _, e := range errors {
			err = multierr.Append(err, newLinterError(e, config.File, yamlPath, false))
		}

		return err
	}

	return nil
}

func (l *Linter) lintSchema(config *WorkflowConfig) error {
	var linterErr error
	schemaErrors, err := schema.LintString(config.RawConfig)
	if err != nil {
		for _, schemaError := range schemaErrors {
			linterErr = multierr.Append(linterErr, newLinterError(
				schemaError.Description(),
				config.File,
				schemaError.Field(),
				true, // TODO: let pipelines fail if the schema is invalid
			))
		}
	}
	return linterErr
}

func (l *Linter) lintDeprecations(config *WorkflowConfig) error {
	parsed := new(types.Workflow)
	err := xyaml.Unmarshal([]byte(config.RawConfig), parsed)
	if err != nil {
		return err
	}

	if len(parsed.RunsOn) > 0 { //nolint:staticcheck
		err = multierr.Append(err, &pipeline_errors.PipelineError{
			Type:      pipeline_errors.PipelineErrorTypeDeprecation,
			IsWarning: true,
			Message:   "Usage of `runs_on` is deprecated, use `when.status`",
			Data: pipeline_errors.DeprecationErrorData{
				File:  config.File,
				Field: fmt.Sprintf("%s.runs_on", config.File),
				Docs:  "https://woodpecker-ci.org/docs/usage/workflow-syntax#status",
			},
		})
	}

	return err
}

func (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) {
	parsed := new(types.Workflow)
	err = xyaml.Unmarshal([]byte(config.RawConfig), parsed)
	if err != nil {
		return err
	}

	rootEventFilters := len(parsed.When.Constraints) > 0
	for _, c := range parsed.When.Constraints {
		if len(c.Event) == 0 {
			rootEventFilters = false
			break
		}
	}
	if !rootEventFilters {
		// root whens do not necessarily have an event filter, check steps
		for _, step := range parsed.Steps.ContainerList {
			var field string
			var msg string
			if len(step.When.Constraints) == 0 {
				field = fmt.Sprintf("steps.%s", step.Name)
				msg = "Consider adding a `when` block with an `event` filter to this step or the entire workflow"
			} else {
				stepEventIndex := -1
				for i, c := range step.When.Constraints {
					if len(c.Event) == 0 {
						stepEventIndex = i
						break
					}
				}
				if stepEventIndex > -1 {
					field = fmt.Sprintf("steps.%s.when[%d]", step.Name, stepEventIndex)
					msg = "Set an event filter for all steps or the entire workflow on all items of the `when` block"
				}
			}
			if field != "" {
				err = multierr.Append(err, &pipeline_errors.PipelineError{
					Type:    pipeline_errors.PipelineErrorTypeBadHabit,
					Message: msg,
					Data: pipeline_errors.BadHabitErrorData{
						File:  config.File,
						Field: field,
						Docs:  "https://woodpecker-ci.org/docs/usage/linter#event-filter-for-all-steps",
					},
					IsWarning: true,
				})
			}
		}
	}

	return err
}
