package policy

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"

	"encoding/json"
	"regexp"

	"github.com/hashicorp/go-getter/v2"

	version "github.com/hashicorp/go-version"

	"github.com/runatlantis/atlantis/server/core/config/valid"
	"github.com/runatlantis/atlantis/server/core/runtime/cache"
	runtime_models "github.com/runatlantis/atlantis/server/core/runtime/models"
	"github.com/runatlantis/atlantis/server/events/command"
	"github.com/runatlantis/atlantis/server/events/models"
	"github.com/runatlantis/atlantis/server/logging"
)

const (
	DefaultConftestVersionEnvKey = "DEFAULT_CONFTEST_VERSION"
	conftestBinaryName           = "conftest"
	conftestDownloadURLPrefix    = "https://github.com/open-policy-agent/conftest/releases/download/v"
)

type Arg struct {
	Param  string
	Option string
}

func (a Arg) build() []string {
	return []string{a.Option, a.Param}
}

func NewPolicyArg(parameter string) Arg {
	return Arg{
		Param:  parameter,
		Option: "-p",
	}
}

type ConftestTestCommandArgs struct {
	PolicyArgs []Arg
	ExtraArgs  []string
	InputFile  string
	Command    string
}

func (c ConftestTestCommandArgs) build() ([]string, error) {

	if len(c.PolicyArgs) == 0 {
		return []string{}, errors.New("no policies specified")
	}

	// add the subcommand
	commandArgs := []string{c.Command, "test"}

	for _, a := range c.PolicyArgs {
		commandArgs = append(commandArgs, a.build()...)
	}

	// add hardcoded options
	commandArgs = append(commandArgs, c.InputFile, "--no-color")

	// add extra args provided through server config
	commandArgs = append(commandArgs, c.ExtraArgs...)

	return commandArgs, nil
}

// SourceResolver resolves the policy set to a local fs path
//
//go:generate pegomock generate --package mocks -o mocks/mock_conftest_client.go SourceResolver
type SourceResolver interface {
	Resolve(policySet valid.PolicySet) (string, error)
}

// LocalSourceResolver resolves a local policy set to a local fs path
type LocalSourceResolver struct {
}

func (p *LocalSourceResolver) Resolve(policySet valid.PolicySet) (string, error) {
	return policySet.Path, nil

}

// SourceResolverProxy proxies to underlying source resolvers dynamically
type SourceResolverProxy struct {
	localSourceResolver SourceResolver
}

func (p *SourceResolverProxy) Resolve(policySet valid.PolicySet) (string, error) {
	switch source := policySet.Source; source {
	case valid.LocalPolicySet:
		return p.localSourceResolver.Resolve(policySet)
	default:
		return "", fmt.Errorf("unable to resolve policy set source %s", source)
	}
}

//go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader

type Downloader interface {
	GetAny(dst, src string) error
}

type ConfTestGoGetterVersionDownloader struct{}

func (c ConfTestGoGetterVersionDownloader) GetAny(dst, src string) error {
	_, err := getter.GetAny(context.Background(), dst, src)
	return err
}

type ConfTestVersionDownloader struct {
	downloader Downloader
}

func (c ConfTestVersionDownloader) downloadConfTestVersion(v *version.Version, destPath string) (runtime_models.FilePath, error) {
	versionURLPrefix := fmt.Sprintf("%s%s", conftestDownloadURLPrefix, v.Original())

	conftestPlatform := getPlatform()
	if conftestPlatform == "" {
		return runtime_models.LocalFilePath(""), fmt.Errorf("don't know where to find conftest for %s on %s", runtime.GOOS, runtime.GOARCH)
	}

	// download binary in addition to checksum file
	binURL := fmt.Sprintf("%s/conftest_%s_%s.tar.gz", versionURLPrefix, v.Original(), conftestPlatform)
	checksumURL := fmt.Sprintf("%s/checksums.txt", versionURLPrefix)

	// underlying implementation uses go-getter so the URL is formatted as such.
	// i know i know, I'm assuming an interface implementation with my inputs.
	// realistically though the interface just exists for testing so ¯\_(ツ)_/¯
	fullSrcURL := fmt.Sprintf("%s?checksum=file:%s", binURL, checksumURL)

	if err := c.downloader.GetAny(destPath, fullSrcURL); err != nil {
		return runtime_models.LocalFilePath(""), fmt.Errorf("downloading conftest version %s at %q: %w", v.String(), fullSrcURL, err)
	}

	binPath := filepath.Join(destPath, "conftest")

	return runtime_models.LocalFilePath(binPath), nil
}

// ConfTestExecutorWorkflow runs a versioned conftest binary with the args built from the project context.
// Project context defines whether conftest runs a local policy set or runs a test on a remote policy set.
type ConfTestExecutorWorkflow struct {
	SourceResolver         SourceResolver
	VersionCache           cache.ExecutionVersionCache
	DefaultConftestVersion *version.Version
	Exec                   runtime_models.Exec
}

func NewConfTestExecutorWorkflow(log logging.SimpleLogging, versionRootDir string, conftestDownloder Downloader) *ConfTestExecutorWorkflow {
	downloader := ConfTestVersionDownloader{
		downloader: conftestDownloder,
	}
	version, err := getDefaultVersion()

	if err != nil {
		// conftest default versions are not essential to service startup so let's not block on it.
		log.Info("failed to get default conftest version. Will attempt request scoped lazy loads %s", err.Error())
	}

	versionCache := cache.NewExecutionVersionLayeredLoadingCache(
		conftestBinaryName,
		versionRootDir,
		downloader.downloadConfTestVersion,
	)

	return &ConfTestExecutorWorkflow{
		VersionCache:           versionCache,
		DefaultConftestVersion: version,
		SourceResolver: &SourceResolverProxy{
			localSourceResolver: &LocalSourceResolver{},
		},
		Exec: runtime_models.LocalExec{},
	}
}

func (c *ConfTestExecutorWorkflow) Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) (string, error) {
	ctx.Log.Debug("policy sets, %s ", ctx.PolicySets)

	inputFile := filepath.Join(workdir, ctx.GetShowResultFileName())
	var policySetResults []models.PolicySetResult
	var combinedErr error

	for _, policySet := range ctx.PolicySets.PolicySets {
		path, resolveErr := c.SourceResolver.Resolve(policySet)

		// Let's not fail the whole step because of a single failure. Log and fail silently
		if resolveErr != nil {
			ctx.Log.Err("Error resolving policyset %s. err: %s", policySet.Name, resolveErr.Error())
			continue
		}

		args := ConftestTestCommandArgs{
			PolicyArgs: []Arg{NewPolicyArg(path)},
			ExtraArgs:  extraArgs,
			InputFile:  inputFile,
			Command:    executablePath,
		}

		serializedArgs, _ := args.build()
		cmdOutput, cmdErr := c.Exec.CombinedOutput(serializedArgs, envs, workdir)

		if cmdErr != nil {
			// Since we're running conftest for each policyset, individual command errors should be concatenated.
			if isValidConftestOutput(cmdOutput) {
				combinedErr = errors.Join(combinedErr, fmt.Errorf("policy_set: %s: conftest: some policies failed", policySet.Name))
			} else {
				combinedErr = errors.Join(combinedErr, fmt.Errorf("policy_set: %s: conftest: %s", policySet.Name, cmdOutput))
			}
		}

		passed := true
		if hasFailures(cmdOutput) {
			passed = false
		}

		policySetResults = append(policySetResults, models.PolicySetResult{
			PolicySetName: policySet.Name,
			PolicyOutput:  cmdOutput,
			Passed:        passed,
			ReqApprovals:  policySet.ApproveCount,
		})
	}

	if policySetResults == nil {
		ctx.Log.Warn("no policies have been configured.")
		return "", nil
		// TODO: enable when we can pass policies in otherwise e2e tests with policy checks fail
		// return "", errors.Wrap(err, "building args")
	}

	marshaledStatus, err := json.Marshal(policySetResults)
	if err != nil {
		return "", errors.New("cannot marshal data into []PolicySetResult. data")
	}

	// Write policy check results to a file which can be used by custom workflow run steps for metrics, notifications, etc.
	policyCheckResultFile := filepath.Join(workdir, ctx.GetPolicyCheckResultFileName())
	err = os.WriteFile(policyCheckResultFile, marshaledStatus, 0600)

	combinedErr = errors.Join(combinedErr, err)

	output := string(marshaledStatus)

	return c.sanitizeOutput(inputFile, output), combinedErr

}

func (c *ConfTestExecutorWorkflow) sanitizeOutput(inputFile string, output string) string {
	return strings.Replace(output, inputFile, "<redacted plan file>", -1)
}

func (c *ConfTestExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *version.Version) (string, error) {
	// we have no information to proceed, so fallback to `conftest` command or fail hard
	if c.DefaultConftestVersion == nil && v == nil {
		localPath, err := c.Exec.LookPath(conftestBinaryName)
		if err == nil {
			log.Info("conftest version is not specified, so fallback to conftest command")
			return localPath, nil
		}
		return "", errors.New("no conftest version configured/specified or not found conftest command")
	}

	var versionToRetrieve *version.Version

	if v == nil {
		versionToRetrieve = c.DefaultConftestVersion
	} else {
		versionToRetrieve = v
	}

	localPath, err := c.VersionCache.Get(versionToRetrieve)

	if err != nil {
		return "", err
	}

	return localPath, nil

}

func getDefaultVersion() (*version.Version, error) {
	// ensure version is not default version.
	// first check for the env var and if that doesn't exist use the local executable version
	defaultVersion, exists := os.LookupEnv(DefaultConftestVersionEnvKey)

	if !exists {
		return nil, fmt.Errorf("%s not set", DefaultConftestVersionEnvKey)
	}

	wrappedVersion, err := version.NewVersion(defaultVersion)

	if err != nil {
		return nil, fmt.Errorf("wrapping version %s: %w", defaultVersion, err)
	}
	return wrappedVersion, nil
}

// Checks if output from conftest is a valid output.
func isValidConftestOutput(output string) bool {

	r := regexp.MustCompile(`^(WARN|FAIL|\[)`)
	if match := r.FindString(output); match != "" {
		return true
	}
	return false
}

// hasFailures checks whether any conftest policies have failed
func hasFailures(output string) bool {
	r := regexp.MustCompile(`([1-9]([0-9]?)* failure|failures": \[)`)
	if match := r.FindString(output); match != "" {
		return true
	}
	return false
}

func getPlatform() string {
	platform := runtime.GOOS + "_" + runtime.GOARCH

	switch platform {
	case "linux_amd64":
		return "Linux_x86_64"
	case "linux_arm64":
		return "Linux_arm64"
	case "darwin_amd64":
		return "Darwin_x86_64"
	case "darwin_arm64":
		return "Darwin_arm64"
	default:
		return ""
	}
}
