package linter

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"

	"alpinelinux.org/go/atools/apkbuild"
	. "alpinelinux.org/go/atools/apkbuild"
	"github.com/moznion/go-optional"
	"gitlab.alpinelinux.org/alpine/go/version"

	"golang.org/x/exp/slices"
	"mvdan.cc/sh/v3/syntax"
)

var (
	AL1 LintingViolation = LintingViolation{
		Code:        "AL1",
		Description: "builddir can be removed as it is the default value",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "DEFAULT_BUILDDIR_VALUE",
	}

	AL3 = LintingViolation{
		Code:        "AL3",
		Description: "pkgname must not be quoted",
		Certainty:   Possible,
		Severity:    Style,
		Skip:        "PKGNAME_QUOTED",
	}

	AL4 = LintingViolation{
		Code:        "AL4",
		Description: "pkgver must not be quoted",
		Certainty:   Possible,
		Severity:    Style,
		Skip:        "PKGVER_QUOTED",
	}

	AL5 = LintingViolation{
		Code:        "AL5",
		Description: "variable set to empty string: %s",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "EMPTY_VARIABLE",
	}

	AL6 = LintingViolation{
		Code:        "AL6",
		Description: "prefix custom variable with _: %s",
		Certainty:   Certain,
		Severity:    Important,
		Skip:        "CUSTOM_VARIABLE",
	}

	AL14 = LintingViolation{
		Code:        "AL14",
		Description: "pkgname must not have uppercase characters",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "PKGNAME_HAS_UPPERCASE",
	}

	AL15 = LintingViolation{
		Code:        "AL15",
		Description: "pkgver must not have -r or _r",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "PKGVER_HAS_PKGREL",
	}

	AL25 = LintingViolation{
		Code:        "AL25",
		Description: "use $() instead of backticks",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "BACKTICKS_USAGE",
	}

	AL26 = LintingViolation{
		Code:        "AL26",
		Description: "rename _builddir to builddir",
		Certainty:   Possible,
		Severity:    Serious,
		Skip:        "_BUILDDIR_IS_SET",
	}

	AL28 = LintingViolation{
		Code:        "AL28",
		Description: "literal integers must not be quoted",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "LITERAL_INTEGER_IS_QUOTED",
	}

	AL29 = LintingViolation{
		Code:        "AL29",
		Description: "$pkgname should not be used in the source url",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "PKGNAME_USED_IN_SOURCE",
	}

	AL30 = LintingViolation{
		Code:        "AL30",
		Description: "double underscore on variables are reserved",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "DOUBLE_UNDERSCORE_IN_VARIABLE",
	}

	AL31 = LintingViolation{
		Code:        "AL31",
		Description: "variable %s must not have capital letters",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "VARIABLE_CAPITALIZED",
	}

	AL32 = LintingViolation{
		Code:        "AL32",
		Description: "unnecessary use of braces: %s",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "BRACED_VARIABLE",
	}

	AL35 = LintingViolation{
		Code:        "AL35",
		Description: "merge the contents of %s into %s and remove it",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "CPAN_VARIABLE",
	}

	AL36 = LintingViolation{
		Code:        "AL36",
		Description: "%[1]s should not be overwritten, add %[1]s to it",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "OVERWRITE_XFLAGS",
	}

	AL49 = LintingViolation{
		Code:        "AL49",
		Description: "invalid option '%s'",
		Certainty:   Certain,
		Severity:    Minor,
		Skip:        "INVALID_OPTION",
	}

	AL57 = LintingViolation{
		Code:        "AL57",
		Description: "invalid arch '%s'",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "INVALID_ARCH",
	}

	AL61 = LintingViolation{
		Code:        "AL61",
		Description: "Version '%s' is not valid",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "BAD_VERSION",
	}

	AL62 = LintingViolation{
		Code:        "AL62",
		Description: "volatile source '%s'",
		Certainty:   Certain,
		Severity:    Serious,
		Skip:        "VOLATILE_SOURCE",
	}
)

func init() {
	Register(&AL1, CheckDefaultBuildDir)
	Register(&AL3, CheckPkgNameIsNotQuoted)
	Register(&AL4, CheckPkgVersionIsNotQuoted)
	Register(&AL5, CheckGlobalVariableIsNotEmpty)
	Register(&AL6, CheckUseOfCustomVariables)
	Register(&AL14, CheckPkgNameUppercase)
	Register(&AL15, CheckPkgRelInPkgVer)
	Register(&AL25, CheckBackticksUsage)
	Register(&AL26, CheckUnderscoreBuildDir)
	Register(&AL28, CheckLiteralIntegerIsQuoted)
	Register(&AL29, CheckPkgnameInSource)
	Register(&AL30, CheckDoubleUnderOnVariables)
	Register(&AL31, CheckVariableCapitalized)
	Register(&AL32, CheckUnnecessaryUseOfBraces)
	Register(&AL35, CheckCpanVariables)
	Register(&AL36, CheckOverwriteXflags)
	Register(&AL49, CheckInvalidOption)
	Register(&AL57, CheckInvalidArch)
	Register(&AL62, CheckBadVersion)
	Register(&AL62, CheckVolatileSource)
}

// paramExpressionHasSubstitutions reports whether the provided parameter
// expression uses any substitutions. A substitution means either a replacement,
// an expansion or a slice.
func paramExpressionHasSubstitutions(exp *syntax.ParamExp) bool {
	return exp.Exp != nil || exp.Repl != nil || exp.Slice != nil
}

func CheckDefaultBuildDir(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	pkgname := apkbuild.GlobalVariables.GetValue("pkgname")
	pkgver := apkbuild.GlobalVariables.GetValue("pkgver")
	defaultBuilddir := fmt.Sprintf("/%s-%s", pkgname, pkgver)

	var checkWordPartsForParamSubstitution func(parts []syntax.WordPart) bool
	checkWordPartsForParamSubstitution = func(parts []syntax.WordPart) bool {
		for _, wordpart := range parts {
			switch exp := wordpart.(type) {
			case *syntax.ParamExp:
				if paramExpressionHasSubstitutions(exp) {
					return true
				}
			case *syntax.DblQuoted:
				if checkWordPartsForParamSubstitution(exp.Parts) {
					return true
				}
			}
		}

		return false
	}

	apkbuild.GlobalVariables.Get("builddir").IfSome(func(builddir Variable) {
		if checkWordPartsForParamSubstitution(builddir.Assignment.Value.Parts) {
			return
		}
		if builddir.EvaluatedValue == defaultBuilddir {
			violation := AL1.Instantiate(
				apkbuild.File.Name,
				builddir.Assignment.Name.ValuePos.Line(),
				builddir.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})

	return
}

func isQuotedVar(variable optional.Option[Variable]) bool {
	wordparts := optional.MapOr(
		variable,
		[]syntax.WordPart{},
		func(v Variable) []syntax.WordPart { return v.Assignment.Value.Parts },
	)

	// Detects a single Lit node in a single DblQoted node
	// pkgname="abc"
	if len(wordparts) == 1 {
		if part, ok := wordparts[0].(*syntax.DblQuoted); ok {
			if len(part.Parts) == 1 {
				if _, lit := part.Parts[0].(*syntax.Lit); lit {
					return true
				}
			}
		}
	}

	return false
}

func CheckPkgNameIsNotQuoted(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.GlobalVariables.IterVar("pkgname", func(v Variable) {
		if isQuotedVar(optional.Some(v)) {
			violation := AL3.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}

	})

	return
}

func CheckPkgVersionIsNotQuoted(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.GlobalVariables.IterVar("pkgver", func(v Variable) {
		if isQuotedVar(optional.Some(v)) {
			violation := AL4.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})
	return
}

func CheckGlobalVariableIsNotEmpty(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	allowedEmptyVariables := map[string]struct{}{
		"arch":       {}, // has a default value, so setting it empty has an effect
		"maintainer": {}, // unmaintained packages have an empty maintainer
	}
	apkbuild.GlobalVariables.Iter(func(v Variable) {
		if _, allowedEmpty := allowedEmptyVariables[v.Name]; allowedEmpty {
			return
		}
		if !strings.HasPrefix(v.Name, "_") && v.Value == "" {
			violation := AL5.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
				v.Name,
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckUseOfCustomVariables(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.GlobalVariables.Iter(func(v Variable) {
		if !HasCustomVariablePrefix(v.Name) &&
			!IsOfficialVariable(v.Name) &&
			!IsDeprecatedVariable(v.Name) &&
			!v.Exported {
			violation := AL6.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
				v.Name,
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckPkgNameUppercase(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.GlobalVariables.IterVar("pkgname", func(v Variable) {
		if pkgname := v.EvaluatedValue; pkgname != strings.ToLower(pkgname) {
			violation := AL14.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckPkgRelInPkgVer(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	pattern := regexp.MustCompile(`(-r|_r(?:[^c]|$))`)

	apkbuild.GlobalVariables.IterVar("pkgver", func(v Variable) {
		if pattern.MatchString(v.EvaluatedValue) {
			violation := AL15.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})
	return
}

func CheckBackticksUsage(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.IterAssigns(func(v Variable) {
		for _, cmdsubst := range v.CommandSubst {
			if cmdsubst.Backquotes {
				violation := AL25.Instantiate(
					apkbuild.File.Name,
					cmdsubst.Left.Line(),
					cmdsubst.Left.Col(),
				)
				violations = append(violations, violation)
			}
		}
	})

	return
}

func CheckUnderscoreBuildDir(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	buildDir := apkbuild.GlobalVariables.Get("builddir")
	dunderBuildir := apkbuild.GlobalVariables.Get("_builddir")

	if buildDir.IsNone() && dunderBuildir.IsSome() {
		dunderBuildir.IfSome(func(v Variable) {
			violation := AL26.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		})
	}

	return
}

func CheckLiteralIntegerIsQuoted(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	for variable := range apkbuild.AssignsIterator() {
		assignment := variable.Assignment

		if assignment.Value == nil {
			continue
		}

		if len(assignment.Value.Parts) != 1 {
			// complex structure, definitely not a literal integer
			continue
		}

		var value string
		switch part := assignment.Value.Parts[0].(type) {
		case *syntax.DblQuoted:
			if len(part.Parts) == 1 {
				if partLit, ok := part.Parts[0].(*syntax.Lit); ok {
					value = partLit.Value
				}
			}
		case *syntax.SglQuoted:
			value = part.Value
		}

		_, err := strconv.ParseInt(value, 10, 64)
		if err != nil {
			// Unevaluated value is not an integer
			continue
		}
		violations = append(violations, AL28.Instantiate(
			apkbuild.File.Name, variable.Assignment.Pos().Line(), variable.Assignment.Pos().Col()))
	}
	return
}

func CheckPkgnameInSource(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	pattern := regexp.MustCompile(`https?://.*\$pkgname`)

	apkbuild.GlobalVariables.IterVar("source", func(v Variable) {
		if pattern.MatchString(v.Value) {
			violation := AL29.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckDoubleUnderOnVariables(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.IterAssigns(func(v Variable) {
		if strings.HasPrefix(v.Name, "__") {
			violation := AL30.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckVariableCapitalized(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.IterAssigns(func(v Variable) {
		if slices.Contains([]string{"IFS", "PATH", "PWD"}, v.Name) {
			return
		}
		if v.Name != strings.ToLower(v.Name) && !v.Exported {
			violation := AL31.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
				v.Name,
			)
			violations = append(violations, violation)
		}
	})

	return
}

func CheckUnnecessaryUseOfBraces(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	apkbuild.IterAssigns(func(v Variable) {
		if v.Assignment.Value == nil {
			// No value to check
			return
		}
		paramExpressions := checkBracesWordPart(v.Assignment.Value.Parts)

		for _, paramExpression := range paramExpressions {
			violation := AL32.Instantiate(
				apkbuild.File.Name,
				paramExpression.Dollar.Line(),
				paramExpression.Dollar.Col(),
				paramExpression.Param.Value,
			)
			violations = append(violations, violation)
		}
	})

	for stmt := range apkbuild.StmtIterator() {
		if cmd, ok := stmt.Cmd.(*syntax.CallExpr); ok {
			if len(cmd.Args) < 2 {
				// Only the command, no argum
				continue
			}
			for _, word := range cmd.Args[1:] {
				paramExpressions := checkBracesWordPart(word.Parts)

				for _, paramExpression := range paramExpressions {
					violation := AL32.Instantiate(
						apkbuild.File.Name,
						paramExpression.Dollar.Line(),
						paramExpression.Dollar.Col(),
						paramExpression.Param.Value,
					)
					violations = append(violations, violation)
				}
			}
		}
	}

	return
}

// Returns any ParamExp (${abc}), optionally followed by literal text (def) where the
// first character is not a valid variable character.
func checkBracesWordPart(wordparts []syntax.WordPart) (res []*syntax.ParamExp) {
	var prevParamExp *syntax.ParamExp
	var pattern *regexp.Regexp = regexp.MustCompile(`^[a-zA-Z0-9_]`)

	for _, wordpart := range wordparts {
		switch part := wordpart.(type) {
		case *syntax.ParamExp:
			if !paramExpressionHasSubstitutions(part) {
				prevParamExp = part
			}
			continue
		case *syntax.Lit:
			// Short means there are no braces
			if prevParamExp != nil && !prevParamExp.Short && !pattern.MatchString(part.Value) {
				res = append(res, prevParamExp)
			}
		case *syntax.DblQuoted:
			res = append(res, checkBracesWordPart(part.Parts)...)
		}
		prevParamExp = nil
	}
	if prevParamExp != nil && !prevParamExp.Short {
		// Apparently there was a ParamExp with braces not followed by anything
		// else. This means the braces are unnecessary.
		res = append(res, prevParamExp)
	}

	return res
}

func CheckCpanVariables(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	cpanVariables := map[string]string{
		"cpandepends":      "depends",
		"cpanmakedepends":  "makedepends",
		"cpancheckdepends": "checkdepends",
	}

	apkbuild.GlobalVariables.Iter(func(v Variable) {
		if replacement, exists := cpanVariables[v.Name]; exists {
			violation := AL35.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
				v.Name,
				replacement,
			)
			violations = append(violations, violation)
		}
	})
	return
}

func CheckOverwriteXflags(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	xflags := []string{"CFLAGS", "CPPFLAGS", "CXXFLAGS", "GOFLAGS"}
	for _, name := range xflags {
		apkbuild.IterAssigns(func(v Variable) {
			if v.Name != name {
				return
			}
			if strings.HasPrefix(v.Value, "$(perl") {
				return
			}

			for _, paramExpr := range v.ParamExpressions() {
				if paramExpr.Param.Value == name {
					return
				}
			}
			violation := AL36.Instantiate(
				apkbuild.File.Name,
				v.Assignment.Name.ValuePos.Line(),
				v.Assignment.Name.ValuePos.Col(),
				name,
			)
			violations = append(violations, violation)
		})
	}
	return
}

func CheckInvalidOption(apkbuildFile *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	customOptions := strings.Fields(getenv("CUSTOM_VALID_OPTIONS"))
	optionsVar := apkbuildFile.GlobalVariables.Get("options")
	optionsVar.IfSome(func(optionsVar Variable) {
		for _, option := range strings.Fields(optionsVar.EvaluatedValue) {
			if !apkbuild.IsValidOption(option) && !slices.Contains(customOptions, option) {
				violations = append(violations, AL49.Instantiate(
					apkbuildFile.File.Name,
					optionsVar.Assignment.Name.ValuePos.Line(),
					optionsVar.Assignment.Name.ValuePos.Col(),
					option,
				))
			}
		}
	})
	return
}

func CheckInvalidArch(apkbuildFile *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	archVar := apkbuildFile.GlobalVariables.Get("arch")
	archVar.IfSome(func(archVar Variable) {
		for _, arch := range strings.Fields(archVar.EvaluatedValue) {
			if arch == "all" || arch == "noarch" {
				continue
			}
			if !apkbuild.IsValidArch(arch) && !apkbuild.IsValidArch(strings.Trim(arch, "!")) {
				violations = append(violations, AL57.Instantiate(
					apkbuildFile.File.Name,
					archVar.Assignment.Name.ValuePos.Line(),
					archVar.Assignment.Name.ValuePos.Col(),
					arch,
				))
			}
		}
	})
	return
}

func CheckBadVersion(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	versionVar := apkbuild.GlobalVariables.Get("pkgver")
	versionVar.IfSome(func(versionVar Variable) {
		if !version.IsValidWithoutRevision(versionVar.EvaluatedValue) {
			violations = append(violations, AL61.Instantiate(
				apkbuild.File.Name,
				versionVar.Assignment.Name.ValuePos.Line(),
				versionVar.Assignment.Name.ValuePos.Col(),
				versionVar.EvaluatedValue,
			))
		}
	})

	return
}

func CheckVolatileSource(apkbuild *Apkbuild, getenv func(string) string) (violations []LintingViolation) {
	volatileSourceUrlPatterns := []regexp.Regexp{
		*regexp.MustCompile(`https://github[.]com/.*/(commit|pull)/.*\.patch$`),
		*regexp.MustCompile(`https://gitlab[.]com/.*/-/(commit|merge_requests)/.*\.patch$`),
		*regexp.MustCompile(`https://(deb|ftp)[.]debian[.]org/debian/pool/.*$`),
	}
	apkbuild.GlobalVariables.IterVar("source", func(v Variable) {
		sources := strings.Fields(v.EvaluatedValue)
		for _, source := range sources {
			filename, location, found := strings.Cut(source, "::")
			if !found {
				location = filename
			}

			for _, pattern := range volatileSourceUrlPatterns {
				if pattern.MatchString(location) {
					violations = append(violations, AL62.Instantiate(
						apkbuild.File.Name,
						v.Assignment.Name.ValuePos.Line(),
						v.Assignment.Name.ValuePos.Col(),
						source,
					))
				}
			}
		}
	})
	return
}
