package maven

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/SAP/jenkins-library/pkg/command"
	piperhttp "github.com/SAP/jenkins-library/pkg/http"
	"github.com/SAP/jenkins-library/pkg/piperutils"

	"github.com/SAP/jenkins-library/pkg/log"
)

// ExecuteOptions are used by Execute() to construct the Maven command line.
type ExecuteOptions struct {
	PomPath                     string   `json:"pomPath,omitempty"`
	ProjectSettingsFile         string   `json:"projectSettingsFile,omitempty"`
	GlobalSettingsFile          string   `json:"globalSettingsFile,omitempty"`
	M2Path                      string   `json:"m2Path,omitempty"`
	Goals                       []string `json:"goals,omitempty"`
	Defines                     []string `json:"defines,omitempty"`
	Flags                       []string `json:"flags,omitempty"`
	LogSuccessfulMavenTransfers bool     `json:"logSuccessfulMavenTransfers,omitempty"`
	ReturnStdout                bool     `json:"returnStdout,omitempty"`
}

// EvaluateOptions are used by Evaluate() to construct the Maven command line.
// In contrast to ExecuteOptions, fewer settings are required for Evaluate and thus a separate type is needed.
type EvaluateOptions struct {
	PomPath             string   `json:"pomPath,omitempty"`
	ProjectSettingsFile string   `json:"projectSettingsFile,omitempty"`
	GlobalSettingsFile  string   `json:"globalSettingsFile,omitempty"`
	M2Path              string   `json:"m2Path,omitempty"`
	Defines             []string `json:"defines,omitempty"`
}

type Utils interface {
	Stdout(out io.Writer)
	Stderr(err io.Writer)
	RunExecutable(e string, p ...string) error

	DownloadFile(url, filename string, header http.Header, cookies []*http.Cookie) error
	Glob(pattern string) (matches []string, err error)
	FileExists(filename string) (bool, error)
	Copy(src, dest string) (int64, error)
	MkdirAll(path string, perm os.FileMode) error
	FileWrite(path string, content []byte, perm os.FileMode) error
	FileRead(path string) ([]byte, error)
}

type utilsBundle struct {
	*command.Command
	*piperutils.Files
	*piperhttp.Client
}

func NewUtilsBundle() Utils {
	utils := utilsBundle{
		Command: &command.Command{},
		Files:   &piperutils.Files{},
		Client:  &piperhttp.Client{},
	}
	utils.Stdout(log.Writer())
	utils.Stderr(log.Writer())
	return &utils
}

const mavenExecutable = "mvn"

// Execute constructs a mvn command line from the given options, and uses the provided
// mavenExecRunner to execute it.
func Execute(options *ExecuteOptions, utils Utils) (string, error) {
	stdOutBuf, stdOut := evaluateStdOut(options)
	utils.Stdout(stdOut)
	utils.Stderr(log.Writer())

	parameters, err := getParametersFromOptions(options, utils)
	if err != nil {
		return "", fmt.Errorf("failed to construct parameters from options: %w", err)
	}

	err = utils.RunExecutable(mavenExecutable, parameters...)
	if err != nil {
		log.SetErrorCategory(log.ErrorBuild)
		commandLine := append([]string{mavenExecutable}, parameters...)
		return "", fmt.Errorf("failed to run executable, command: '%s', error: %w", commandLine, err)
	}

	if stdOutBuf == nil {
		return "", nil
	}
	return string(stdOutBuf.Bytes()), nil
}

// Evaluate constructs ExecuteOptions for using the maven-help-plugin's 'evaluate' goal to
// evaluate a given expression from a pom file. This allows to retrieve the value of - for
// example - 'project.version' from a pom file exactly as Maven itself evaluates it.
func Evaluate(options *EvaluateOptions, expression string, utils Utils) (string, error) {
	defines := []string{"-Dexpression=" + expression, "-DforceStdout", "-q"}
	defines = append(defines, options.Defines...)
	executeOptions := ExecuteOptions{
		PomPath:             options.PomPath,
		M2Path:              options.M2Path,
		ProjectSettingsFile: options.ProjectSettingsFile,
		GlobalSettingsFile:  options.GlobalSettingsFile,
		Goals:               []string{"org.apache.maven.plugins:maven-help-plugin:3.1.0:evaluate"},
		Defines:             defines,
		ReturnStdout:        true,
	}
	value, err := Execute(&executeOptions, utils)
	if err != nil {
		return "", err
	}
	if strings.HasPrefix(value, "null object or invalid expression") {
		return "", fmt.Errorf("expression '%s' in file '%s' could not be resolved", expression, options.PomPath)
	}
	return value, nil
}

// InstallFile installs a maven artifact and its pom into the local maven repository.
// If "file" is empty, only the pom is installed. "pomFile" must not be empty.
func InstallFile(file, pomFile string, options *EvaluateOptions, utils Utils) error {
	if len(pomFile) == 0 {
		return fmt.Errorf("pomFile can't be empty")
	}

	var defines []string
	if len(file) > 0 {
		defines = append(defines, "-Dfile="+file)
		if strings.Contains(file, ".jar") {
			defines = append(defines, "-Dpackaging=jar")
		}
		if strings.Contains(file, "-classes") {
			defines = append(defines, "-Dclassifier=classes")
		}

	} else {
		defines = append(defines, "-Dfile="+pomFile)
	}
	defines = append(defines, "-DpomFile="+pomFile)
	mavenOptionsInstall := ExecuteOptions{
		Goals:               []string{"install:install-file"},
		Defines:             defines,
		M2Path:              options.M2Path,
		ProjectSettingsFile: options.ProjectSettingsFile,
		GlobalSettingsFile:  options.GlobalSettingsFile,
	}
	_, err := Execute(&mavenOptionsInstall, utils)
	if err != nil {
		return fmt.Errorf("failed to install maven artifacts: %w", err)
	}
	return nil
}

// InstallMavenArtifacts finds maven modules (identified by pom.xml files) and installs the artifacts into the local maven repository.
func InstallMavenArtifacts(options *EvaluateOptions, utils Utils) error {
	return doInstallMavenArtifacts(options, utils)
}

func doInstallMavenArtifacts(options *EvaluateOptions, utils Utils) error {
	err := flattenPom(options, utils)
	if err != nil {
		return err
	}

	pomFiles, err := utils.Glob(filepath.Join("**", "pom.xml"))
	if err != nil {
		return err
	}

	// Ensure m2 path is an absolute path, even if it is given relative
	// This is important to avoid getting multiple m2 directories in a maven multimodule project
	if options.M2Path != "" {
		options.M2Path, err = filepath.Abs(options.M2Path)
		if err != nil {
			return err
		}
	}

	for _, pomFile := range pomFiles {
		log.Entry().Info("Installing maven artifacts from module: " + pomFile)

		// Set this module's pom file as the pom file for evaluating the packaging,
		// otherwise we would evaluate the root pom in all iterations.
		evaluateProjectPackagingOptions := *options
		evaluateProjectPackagingOptions.PomPath = pomFile
		packaging, err := Evaluate(&evaluateProjectPackagingOptions, "project.packaging", utils)
		if err != nil {
			return err
		}

		currentModuleDir := filepath.Dir(pomFile)

		// Use flat pom if available to avoid issues with unresolved variables.
		pathToPomFile := pomFile
		flattenedPomExists, _ := utils.FileExists(filepath.Join(currentModuleDir, ".flattened-pom.xml"))
		if flattenedPomExists {
			pathToPomFile = filepath.Join(currentModuleDir, ".flattened-pom.xml")
		}

		if packaging == "pom" {
			err = InstallFile("", pathToPomFile, options, utils)
			if err != nil {
				return err
			}
		} else {

			err = installJarWarArtifacts(pathToPomFile, currentModuleDir, options, utils)
			if err != nil {
				return err
			}
		}
	}
	return err
}

func installJarWarArtifacts(pomFile, dir string, options *EvaluateOptions, utils Utils) error {
	options.PomPath = filepath.Join(dir, "pom.xml")
	finalName, err := Evaluate(options, "project.build.finalName", utils)
	if err != nil {
		return err
	}
	if finalName == "" {
		log.Entry().Warn("project.build.finalName is empty, skipping install of artifact. Installing only the pom file.")
		err = InstallFile("", pomFile, options, utils)
		if err != nil {
			return err
		}
		return nil
	}

	jarExists, _ := utils.FileExists(jarFile(dir, finalName))
	warExists, _ := utils.FileExists(warFile(dir, finalName))
	classesJarExists, _ := utils.FileExists(classesJarFile(dir, finalName))
	originalJarExists, _ := utils.FileExists(originalJarFile(dir, finalName))

	log.Entry().Infof("JAR file with name %s does exist: %t", jarFile(dir, finalName), jarExists)
	log.Entry().Infof("Classes-JAR file with name %s does exist: %t", classesJarFile(dir, finalName), classesJarExists)
	log.Entry().Infof("Original-JAR file with name %s does exist: %t", originalJarFile(dir, finalName), originalJarExists)
	log.Entry().Infof("WAR file with name %s does exist: %t", warFile(dir, finalName), warExists)

	// Due to spring's jar repackaging we need to check for an "original" jar file because the repackaged one is no suitable source for dependent maven modules
	if originalJarExists {
		err = InstallFile(originalJarFile(dir, finalName), pomFile, options, utils)
		if err != nil {
			return err
		}
	} else if jarExists {
		err = InstallFile(jarFile(dir, finalName), pomFile, options, utils)
		if err != nil {
			return err
		}
	}

	if warExists {
		err = InstallFile(warFile(dir, finalName), pomFile, options, utils)
		if err != nil {
			return err
		}
	}

	if classesJarExists {
		err = InstallFile(classesJarFile(dir, finalName), pomFile, options, utils)
		if err != nil {
			return err
		}
	}
	return nil
}

func jarFile(dir, finalName string) string {
	return filepath.Join(dir, "target", finalName+".jar")
}

func classesJarFile(dir, finalName string) string {
	return filepath.Join(dir, "target", finalName+"-classes.jar")
}

func originalJarFile(dir, finalName string) string {
	return filepath.Join(dir, "target", finalName+".jar.original")
}

func warFile(dir, finalName string) string {
	return filepath.Join(dir, "target", finalName+".war")
}

func flattenPom(options *EvaluateOptions, utils Utils) error {
	mavenOptionsFlatten := ExecuteOptions{
		Goals:               []string{"flatten:flatten"},
		Defines:             []string{"-Dflatten.mode=resolveCiFriendliesOnly"},
		PomPath:             options.PomPath,
		M2Path:              options.M2Path,
		ProjectSettingsFile: options.ProjectSettingsFile,
		GlobalSettingsFile:  options.GlobalSettingsFile,
	}
	_, err := Execute(&mavenOptionsFlatten, utils)
	return err
}

func evaluateStdOut(options *ExecuteOptions) (*bytes.Buffer, io.Writer) {
	var stdOutBuf *bytes.Buffer
	stdOut := log.Writer()
	if options.ReturnStdout {
		stdOutBuf = new(bytes.Buffer)
		stdOut = io.MultiWriter(stdOut, stdOutBuf)
	}
	return stdOutBuf, stdOut
}

func getParametersFromOptions(options *ExecuteOptions, utils Utils) ([]string, error) {
	var parameters []string

	parameters, err := DownloadAndGetMavenParameters(options.GlobalSettingsFile, options.ProjectSettingsFile, utils)
	if err != nil {
		return nil, err
	}

	if options.M2Path != "" {
		parameters = append(parameters, "-Dmaven.repo.local="+options.M2Path)
	}

	if options.PomPath != "" {
		parameters = append(parameters, "--file", options.PomPath)
	}

	if options.Flags != nil {
		parameters = append(parameters, options.Flags...)
	}

	if options.Defines != nil {
		parameters = append(parameters, options.Defines...)
	}

	if !options.LogSuccessfulMavenTransfers {
		parameters = append(parameters, "-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn")
	}

	parameters = append(parameters, "--batch-mode")

	parameters = append(parameters, options.Goals...)

	return parameters, nil
}

// GetTestModulesExcludes return testing modules that you be excluded from reactor
func GetTestModulesExcludes(utils Utils) []string {
	var excludes []string
	exists, _ := utils.FileExists("unit-tests/pom.xml")
	if exists {
		excludes = append(excludes, "-pl", "!unit-tests")
	}
	exists, _ = utils.FileExists("integration-tests/pom.xml")
	if exists {
		excludes = append(excludes, "-pl", "!integration-tests")
	}
	return excludes
}
