package lint

import (
	"fmt"
	"net/http"
	"os"
	"path"

	"coopcloud.tech/abra/pkg/log"
	"coopcloud.tech/abra/pkg/recipe"
	recipePkg "coopcloud.tech/abra/pkg/recipe"
	"coopcloud.tech/tagcmp"
	"github.com/distribution/reference"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
)

var Warn = "warn"
var Critical = "critical"

type LintFunction func(recipe.Recipe) (bool, error)

// SkipFunction determines whether the LintFunction is run or not. It should
// not take the lint rule level into account because some rules are always an
// error but may depend on some additional context of the recipe configuration.
// This function aims to cover those additional cases.
type SkipFunction func(recipe.Recipe) (bool, error)

// LintRule is a linting rule which helps a recipe maintainer avoid common
// problems in their recipe configurations. We aim to highlight things that
// might result in critical errors or hours lost in debugging obscure
// Docker-isms. Humans make the final call on these rules, please raise an
// issue if you disagree.
type LintRule struct {
	Ref           string       // Reference of the linting rule
	Level         string       // Level of the warning
	Description   string       // Description of the issue
	HowToResolve  string       // Documentation for recipe maintainer
	Function      LintFunction // Rule implementation
	SkipCondition SkipFunction // Whether or not to execute the lint rule
}

// Skip implements the SkipFunction for the lint rule.
func (l LintRule) Skip(recipe recipe.Recipe) bool {
	if l.SkipCondition != nil {
		ok, err := l.SkipCondition(recipe)
		if err != nil {
			log.Debugf("%s: skip condition: %s", l.Ref, err)
		}
		if ok {
			log.Debugf("skipping %s based on skip condition", l.Ref)
			return true
		}
	}

	return false
}

var LintRules = map[string][]LintRule{
	"warn": {
		{
			Ref:          "R001",
			Level:        "warn",
			Description:  "compose config has expected version",
			HowToResolve: "ensure 'version: \"3.8\"' in compose configs",
			Function:     LintComposeVersion,
		},
		{
			Ref:          "R002",
			Level:        "warn",
			Description:  "healthcheck enabled for all services",
			HowToResolve: "wire up healthchecks",
			Function:     LintHealthchecks,
		},
		{
			Ref:          "R003",
			Level:        "warn",
			Description:  "all images use a tag",
			HowToResolve: "use a tag for all images",
			Function:     LintAllImagesTagged,
		},
		{
			Ref:          "R004",
			Level:        "warn",
			Description:  "no unstable tags",
			HowToResolve: "tag all images with stable tags",
			Function:     LintNoUnstableTags,
		},
		{
			Ref:          "R005",
			Level:        "warn",
			Description:  "tags use semver-like format",
			HowToResolve: "use semver-like tags",
			Function:     LintSemverLikeTags,
		},
		{
			Ref:          "R006",
			Level:        "warn",
			Description:  "has published catalogue version",
			HowToResolve: "publish a recipe version to the catalogue",
			Function:     LintHasPublishedVersion,
		},
		{
			Ref:          "R007",
			Level:        "warn",
			Description:  "README.md metadata filled in",
			HowToResolve: "fill out all the metadata",
			Function:     LintMetadataFilledIn,
		},
		{
			Ref:          "R013",
			Level:        "warn",
			Description:  "git.coopcloud.tech repo exists",
			HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
			Function:     LintHasRecipeRepo,
		},
		{
			Ref:          "R015",
			Level:        "warn",
			Description:  "long secret names",
			HowToResolve: "reduce length of secret names to 12 chars",
			Function:     LintSecretLengths,
		},
	},
	"error": {
		{
			Ref:          "R008",
			Level:        "error",
			Description:  ".env.sample provided",
			HowToResolve: "create an example .env.sample",
			Function:     LintEnvConfigPresent,
		},
		{
			Ref:          "R009",
			Level:        "error",
			Description:  "one service named 'app'",
			HowToResolve: "name a servce 'app'",
			Function:     LintAppService,
		},
		{
			Ref:          "R015",
			Level:        "error",
			Description:  "deploy labels stanza present",
			HowToResolve: "include \"deploy: labels: ...\" stanza",
			Function:     LintDeployLabelsPresent,
		},
		{
			Ref:           "R010",
			Level:         "error",
			Description:   "traefik routing enabled",
			HowToResolve:  "include \"traefik.enable=true\" deploy label",
			Function:      LintTraefikEnabled,
			SkipCondition: LintTraefikEnabledSkipCondition,
		},
		{
			Ref:          "R011",
			Level:        "error",
			Description:  "all services have images",
			HowToResolve: "ensure \"image: ...\" set on all services",
			Function:     LintImagePresent,
		},
		{
			Ref:          "R012",
			Level:        "error",
			Description:  "config version are vendored",
			HowToResolve: "vendor config versions in an abra.sh",
			Function:     LintAbraShVendors,
		},
		{
			Ref:          "R014",
			Level:        "error",
			Description:  "only annotated tags used for recipe version",
			HowToResolve: "replace lightweight tag with annotated tag",
			Function:     LintValidTags,
		},
	},
}

// LintForErrors lints specifically for errors and not other levels. This is
// used in code paths such as "app deploy" to avoid nasty surprises but not for
// the typical linting commands, which do handle other levels.
func LintForErrors(recipe recipe.Recipe) error {
	log.Debugf("linting for critical errors in %s configs", recipe.Name)

	for level := range LintRules {
		if level != "error" {
			continue
		}

		for _, rule := range LintRules[level] {
			if rule.Skip(recipe) {
				continue
			}

			ok, err := rule.Function(recipe)
			if err != nil {
				return err
			}
			if !ok {
				return fmt.Errorf("lint error in %s configs: \"%s\" failed lint checks (%s)", recipe.Name, rule.Description, rule.Ref)
			}
		}
	}

	log.Debugf("linting successful, %s is well configured", recipe.Name)

	return nil
}

func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	if config.Version == "3.8" {
		return true, nil
	}

	return true, nil
}

func LintEnvConfigPresent(r recipe.Recipe) (bool, error) {
	if _, err := os.Stat(r.SampleEnvPath); !os.IsNotExist(err) {
		return true, nil
	}

	return false, nil
}

func LintAppService(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		if service.Name == "app" {
			return true, nil
		}
	}

	return false, nil
}

// LintTraefikEnabledSkipCondition signals a skip for this linting rule if it
// confirms that there is no "DOMAIN=..." in the .env.sample configuration of
// the recipe. This typically means that no domain is required to deploy and
// therefore no matching traefik deploy label will be present.
func LintTraefikEnabledSkipCondition(r recipe.Recipe) (bool, error) {
	sampleEnv, err := r.SampleEnv()
	if err != nil {
		return false, fmt.Errorf("Unable to discover .env.sample for %s", r.Name)
	}

	if _, ok := sampleEnv["DOMAIN"]; !ok {
		return true, nil
	}

	return false, nil
}

func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		for label := range service.Deploy.Labels {
			if label == "traefik.enable" {
				if service.Deploy.Labels[label] == "true" {
					return true, nil
				}
			}
		}
	}

	return false, nil
}

func LintDeployLabelsPresent(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}

	for _, service := range config.Services {
		if service.Name == "app" && service.Deploy.Labels != nil {
			return true, nil
		}
	}

	return false, nil
}

func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		if service.HealthCheck == nil {
			return false, nil
		}
	}

	return true, nil
}

func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		img, err := reference.ParseNormalizedNamed(service.Image)
		if err != nil {
			return false, err
		}
		if reference.IsNameOnly(img) {
			return false, nil
		}
	}

	return true, nil
}

func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		img, err := reference.ParseNormalizedNamed(service.Image)
		if err != nil {
			return false, err
		}

		var tag string
		switch img.(type) {
		case reference.NamedTagged:
			tag = img.(reference.NamedTagged).Tag()
		case reference.Named:
			return false, nil
		}

		if tag == "latest" {
			return false, nil
		}
	}

	return true, nil
}

func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		img, err := reference.ParseNormalizedNamed(service.Image)
		if err != nil {
			return false, err
		}

		var tag string
		switch img.(type) {
		case reference.NamedTagged:
			tag = img.(reference.NamedTagged).Tag()
		case reference.Named:
			return false, nil
		}

		if !tagcmp.IsParsable(tag) {
			return false, nil
		}
	}

	return true, nil
}

func LintImagePresent(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		if service.Image == "" {
			return false, nil
		}
	}
	return true, nil
}

func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
	catl, err := recipePkg.ReadRecipeCatalogue(false)
	if err != nil {
		log.Fatal(err)
	}

	versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
	if err != nil {
		log.Fatal(err)
	}

	if len(versions) == 0 {
		return false, nil
	}

	return true, nil
}

func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
	features, category, err := recipe.GetRecipeFeaturesAndCategory(r)
	if err != nil {
		return false, err
	}

	if category == "" {
		return false, nil
	}

	if features.Backups == "" ||
		features.Email == "" ||
		features.Healthcheck == "" ||
		features.Image.Image == "" ||
		features.SSO == "" {
		return false, nil
	}

	return true, nil
}

func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for _, service := range config.Services {
		if len(service.Configs) > 0 {
			abraSh := path.Join(recipe.Dir, "abra.sh")
			if _, err := os.Stat(abraSh); err != nil {
				if os.IsNotExist(err) {
					return false, err
				}
				return false, err
			}
		}
	}
	return true, nil
}

func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
	res, err := http.Get(recipe.GitURL)
	if err != nil {
		return false, err
	}

	if res.StatusCode != 200 {
		return false, err
	}

	return true, nil
}

func LintSecretLengths(recipe recipe.Recipe) (bool, error) {
	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return false, err
	}
	for name := range config.Secrets {
		if len(name) > 12 {
			return false, fmt.Errorf("secret %s is longer than 12 characters", name)
		}
	}

	return true, nil
}

func LintValidTags(recipe recipe.Recipe) (bool, error) {
	repo, err := git.PlainOpen(recipe.Dir)
	if err != nil {
		return false, fmt.Errorf("unable to open %s: %s", recipe.Dir, err)
	}

	iter, err := repo.Tags()
	if err != nil {
		log.Fatalf("unable to list local tags for %s", recipe.Name)
	}

	if err := iter.ForEach(func(ref *plumbing.Reference) error {
		_, err := repo.TagObject(ref.Hash())
		if err != nil {
			switch err {
			case plumbing.ErrObjectNotFound:
				return fmt.Errorf("invalid lightweight tag detected")
			default:
				return err
			}
		}
		return nil
	}); err != nil {
		return false, nil
	}

	return true, nil
}
