package generic

import (
	"errors"
	"fmt"
	"image"
	"os"
	"path"
	"path/filepath"
	"strings"

	"github.com/artifacthub/hub/internal/hub"
	"github.com/artifacthub/hub/internal/oci"
	"github.com/artifacthub/hub/internal/pkg"
	"github.com/artifacthub/hub/internal/util"
	ignore "github.com/sabhiram/go-gitignore"
	"gopkg.in/yaml.v3"
)

const (
	// ArgoTemplateKey represents the key used in the package's data field that
	// contains the template.
	ArgoTemplateKey = "template"

	// FalcoRulesKey represents the key used in the package's data field that
	// contains the raw rules.
	FalcoRulesKey = "rules"

	// GatekeeperExamplesKey represents the key used in the package's data
	// field that contains the examples.
	GatekeeperExamplesKey = "examples"

	// GatekeeperTemplateKey represents the key used in the package's data field
	// that contains the template.
	GatekeeperTemplateKey = "template"

	// KubeArmorPoliciesKey represents the key used in the package's data field
	// that contains the raw policies.
	KubeArmorPoliciesKey = "policies"

	// KyvernoPolicyKey represents the key used in the package's data field that
	// contains the raw policy.
	KyvernoPolicyKey = "policy"

	// mesheryDesignKey represents the key used in the package's data field that
	// contains the design.
	MesheryDesignKey = "design"

	// OPAPoliciesKey represents the key used in the package's data field that
	// contains the raw policies.
	OPAPoliciesKey = "policies"

	// RadiusRecipeKey represents the key used in the package's data field that
	// contains the raw recipe files.
	RadiusRecipeKey = "recipe"

	// argoTemplateManifests represents the filename that contains the Argo
	// template manifests.
	argoTemplateManifests = "manifests.yaml"

	// falcoRulesSuffix is the suffix that each of the rules files in the
	// package must use.
	falcoRulesSuffix = "-rules.yaml"

	// kubeArmorPoliciesSuffix is the suffix that each of the policies files in
	// the package must use.
	kubeArmorPoliciesSuffix = ".yaml"

	// mesheryDesignFile represents the filename that contains the Meshery
	// design file.
	mesheryDesignFile = "design.yml"

	// opaPoliciesSuffix is the suffix that each of the policies files in the
	// package must use.
	opaPoliciesSuffix = ".rego"

	// radiusBicepRecipe represents the filename that contains the Radius
	// recipe file in Bicep format.
	radiusBicepRecipe = "recipe.bicep"

	// radiusTFRecipe represents the filename that contains the Radius recipe
	// file in Terraform format (main).
	radiusTFRecipe = "main.tf"

	// radiusTFRecipeVariables represents the filename that contains the
	// variables used by the Radius recipe file in Terraform format.
	radiusTFRecipeVariables = "variables.tf"
)

// TrackerSource is a hub.TrackerSource implementation used by several kinds
// of repositories.
type TrackerSource struct {
	i *hub.TrackerSourceInput
}

// NewTrackerSource creates a new TrackerSource instance.
func NewTrackerSource(i *hub.TrackerSourceInput, opts ...func(s *TrackerSource)) *TrackerSource {
	return &TrackerSource{i: i}
}

// GetPackagesAvailable implements the TrackerSource interface.
func (s *TrackerSource) GetPackagesAvailable() (map[string]*hub.Package, error) {
	packagesAvailable := make(map[string]*hub.Package)

	// Walk the path provided looking for available packages
	err := filepath.Walk(s.i.BasePath, func(pkgPath string, info os.FileInfo, err error) error {
		// Return ASAP if context is cancelled
		select {
		case <-s.i.Svc.Ctx.Done():
			return s.i.Svc.Ctx.Err()
		default:
		}

		// If an error is raised while visiting a path or the path is not a
		// directory, we skip it
		if err != nil || !info.IsDir() {
			return nil
		}

		// Get package version metadata
		md, err := pkg.GetPackageMetadata(
			s.i.Repository.Kind,
			filepath.Join(pkgPath, hub.PackageMetadataFile),
		)
		if err != nil {
			if !errors.Is(err, os.ErrNotExist) {
				s.warn(fmt.Errorf("error getting package metadata (path: %s): %w", pkgPath, err))
			}
			return nil
		}

		// Prepare and store package version
		p, err := PreparePackage(s.i.Repository, md, pkgPath)
		if err != nil {
			s.warn(err)
			return nil
		}
		p.RelativePath = strings.TrimPrefix(pkgPath, s.i.BasePath)
		packagesAvailable[pkg.BuildKey(p)] = p

		// Prepare and store logo image when available
		logoImageID, err := s.prepareLogoImage(md, pkgPath)
		if err != nil {
			s.warn(fmt.Errorf("error preparing package %s version %s logo image: %w", md.Name, md.Version, err))
		} else {
			p.LogoImageID = logoImageID
		}

		// Check if the package is signed (for applicable kinds)
		switch p.Repository.Kind {
		case hub.Bootc, hub.InspektorGadget, hub.Kubewarden:
			// We'll consider the package signed if all images are signed
			signedImages := 0
			for _, entry := range p.ContainersImages {
				hasCosignSignature, err := s.i.Svc.Sc.HasCosignSignature(s.i.Svc.Ctx, entry.Image, "", "")
				if err != nil {
					s.warn(fmt.Errorf(
						"error checking package %s version %s image %s signature: %w",
						md.Name, md.Version, entry.Image, err,
					))
				} else if hasCosignSignature {
					signedImages++
				}
			}
			if len(p.ContainersImages) > 0 && signedImages == len(p.ContainersImages) {
				p.Signed = true
				p.Signatures = []string{oci.Cosign}
			}
		}

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

	return packagesAvailable, nil
}

// prepareLogoImage processes and stores the logo image provided.
func (s *TrackerSource) prepareLogoImage(md *hub.PackageMetadata, pkgPath string) (string, error) {
	var logoImageID string
	var err error

	// Store logo image when available
	if md.LogoPath != "" {
		data, err := os.ReadFile(filepath.Join(pkgPath, md.LogoPath))
		if err != nil {
			return "", fmt.Errorf("error reading logo image: %w", err)
		}
		logoImageID, err = s.i.Svc.Is.SaveImage(s.i.Svc.Ctx, data)
		if err != nil && !errors.Is(err, image.ErrFormat) {
			return "", fmt.Errorf("error saving logo image: %w", err)
		}
	} else if md.LogoURL != "" {
		logoImageID, err = s.i.Svc.Is.DownloadAndSaveImage(s.i.Svc.Ctx, md.LogoURL)
		if err != nil {
			return "", fmt.Errorf("error downloading and saving logo image: %w", err)
		}
	}

	return logoImageID, nil
}

// warn is a helper that sends the error provided to the errors collector and
// logs it as a warning.
func (s *TrackerSource) warn(err error) {
	s.i.Svc.Logger.Warn().Err(err).Send()
	s.i.Svc.Ec.Append(s.i.Repository.RepositoryID, err.Error())
}

// PreparePackage prepares a package version using the metadata and the files
// in the path provided.
func PreparePackage(r *hub.Repository, md *hub.PackageMetadata, pkgPath string) (*hub.Package, error) {
	// Prepare package from metadata
	p, err := pkg.PreparePackageFromMetadata(md)
	if err != nil {
		return nil, fmt.Errorf("error preparing package %s version %s from metadata: %w", md.Name, md.Version, err)
	}
	p.Repository = r

	// If the readme content hasn't been provided in the metadata file, try to
	// get it from the README.md file.
	if p.Readme == "" {
		readme, err := util.ReadRegularFile(filepath.Join(pkgPath, "README.md"))
		if err == nil {
			p.Readme = string(readme)
		}
	}

	// Include kind specific data into package
	ignorer := ignore.CompileIgnoreLines(append(md.Ignore, "artifacthub-*")...)
	var kindData map[string]interface{}
	switch r.Kind {
	case hub.ArgoTemplate:
		kindData, err = prepareArgoTemplateData(pkgPath)
	case hub.Falco:
		kindData, err = prepareFalcoData(pkgPath, ignorer)
	case hub.Gatekeeper:
		kindData, err = prepareGatekeeperData(pkgPath)
	case hub.KubeArmor:
		kindData, err = prepareKubeArmorData(pkgPath, ignorer)
	case hub.Kyverno:
		kindData, err = prepareKyvernoData(pkgPath, p.Name)
	case hub.Meshery:
		kindData, err = prepareMesheryData(pkgPath)
	case hub.OPA:
		kindData, err = prepareOPAData(pkgPath, ignorer)
	case hub.Radius:
		kindData, err = prepareRadiusData(pkgPath)
	}
	if err != nil {
		return nil, fmt.Errorf("error preparing package %s version %s data: %w", md.Name, md.Version, err)
	}
	if kindData != nil {
		if p.Data == nil {
			p.Data = kindData
		} else {
			for k, v := range kindData {
				p.Data[k] = v
			}
		}
	}

	return p, nil
}

// prepareArgoTemplateData reads and formats Argo templates specific data
// available in the path provided, returning the resulting data structure.
func prepareArgoTemplateData(pkgPath string) (map[string]interface{}, error) {
	// Read manifests file
	manifestsPath := path.Join(pkgPath, argoTemplateManifests)
	template, err := util.ReadRegularFile(manifestsPath)
	if err != nil {
		return nil, fmt.Errorf("error reading argo template manifests: %w", err)
	}

	// Return package data field
	return map[string]interface{}{
		ArgoTemplateKey: string(template),
	}, nil
}

// prepareFalcoData reads and formats Falco specific data available in the path
// provided, returning the resulting data structure.
func prepareFalcoData(pkgPath string, ignorer ignore.IgnoreParser) (map[string]interface{}, error) {
	// Read rules files
	files, err := GetFilesWithSuffix(falcoRulesSuffix, pkgPath, ignorer)
	if err != nil {
		return nil, fmt.Errorf("error getting falco rules files: %w", err)
	}
	if len(files) == 0 {
		return nil, errors.New("no falco rules files found")
	}

	// Return package data field
	return map[string]interface{}{
		FalcoRulesKey: files,
	}, nil
}

// prepareGatekeeperData reads and formats Gatekeeper specific data available
// in the path provided, returning the resulting data structure.
func prepareGatekeeperData(pkgPath string) (map[string]interface{}, error) {
	// Read template file
	templatePath := path.Join(pkgPath, "template.yaml")
	template, err := util.ReadRegularFile(templatePath)
	if err != nil {
		return nil, fmt.Errorf("error reading gatekeeper template file: %w", err)
	}

	// Read examples
	var examples []*GKExample
	suitePath := path.Join(pkgPath, "suite.yaml")
	suiteYaml, err := util.ReadRegularFile(suitePath)
	if err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("error reading gatekeeper suite: %w", err)
		}
	} else {
		var suite *GKSuite
		if err := yaml.Unmarshal(suiteYaml, &suite); err != nil {
			return nil, fmt.Errorf("error reading parsing suite file: %w", err)
		}
		for _, t := range suite.Tests {
			var cases []*GKExampleCase
			if t.Constraint != "" {
				content, err := util.ReadRegularFile(path.Join(pkgPath, t.Constraint))
				if err != nil {
					return nil, fmt.Errorf("error reading constraint file (%s): %w", t.Constraint, err)
				}
				cases = append(cases, &GKExampleCase{
					Name:    "constraint",
					Path:    t.Constraint,
					Content: string(content),
				})
			}
			for _, c := range t.Cases {
				content, err := util.ReadRegularFile(path.Join(pkgPath, c.Object))
				if err != nil {
					return nil, fmt.Errorf("error reading example file (%s): %w", c.Object, err)
				}
				cases = append(cases, &GKExampleCase{
					Name:    c.Name,
					Path:    c.Object,
					Content: string(content),
				})
			}
			examples = append(examples, &GKExample{
				Name:  t.Name,
				Cases: cases,
			})
		}
	}

	// Return package data field
	return map[string]interface{}{
		GatekeeperTemplateKey: string(template),
		GatekeeperExamplesKey: examples,
	}, nil
}

// prepareKubeArmorData reads and formats KubeArmor specific data available in
// the path provided, returning the resulting data structure.
func prepareKubeArmorData(pkgPath string, ignorer ignore.IgnoreParser) (map[string]interface{}, error) {
	// Read policies files
	policies, err := GetFilesWithSuffix(kubeArmorPoliciesSuffix, pkgPath, ignorer)
	if err != nil {
		return nil, fmt.Errorf("error getting kubearmor policies files: %w", err)
	}

	// Return package data field
	return map[string]interface{}{
		KubeArmorPoliciesKey: policies,
	}, nil
}

// prepareKyernoData reads and formats Kyverno specific data available in the
// path provided, returning the resulting data structure.
func prepareKyvernoData(pkgPath, pkgName string) (map[string]interface{}, error) {
	// Read policy file
	policyPath := path.Join(pkgPath, path.Base(pkgPath)+".yaml")
	if _, err := os.Stat(policyPath); os.IsNotExist(err) {
		policyPath = path.Join(pkgPath, pkgName+".yaml")
	}
	policy, err := util.ReadRegularFile(policyPath)
	if err != nil {
		return nil, fmt.Errorf("error reading kyverno policy file: %w", err)
	}

	// Return package data field
	return map[string]interface{}{
		KyvernoPolicyKey: string(policy),
	}, nil
}

// prepareMesheryData reads and formats Meshery designs specific data available
// in the path provided, returning the resulting data structure.
func prepareMesheryData(pkgPath string) (map[string]interface{}, error) {
	// Read design file
	designPath := path.Join(pkgPath, mesheryDesignFile)
	design, err := util.ReadRegularFile(designPath)
	if err != nil {
		return nil, fmt.Errorf("error reading meshery design file: %w", err)
	}

	// Return package data field
	return map[string]interface{}{
		MesheryDesignKey: string(design),
	}, nil
}

// prepareOPAData reads and formats OPA specific data available in the path
// provided, returning the resulting data structure.
func prepareOPAData(pkgPath string, ignorer ignore.IgnoreParser) (map[string]interface{}, error) {
	// Read policies files
	files, err := GetFilesWithSuffix(opaPoliciesSuffix, pkgPath, ignorer)
	if err != nil {
		return nil, fmt.Errorf("error getting opa policies files: %w", err)
	}
	if len(files) == 0 {
		return nil, errors.New("no opa policies files found")
	}

	// Return package data field
	return map[string]interface{}{
		OPAPoliciesKey: files,
	}, nil
}

// prepareRadiusData reads and formats Radius specific data available in the
// path provided, returning the resulting data structure.
func prepareRadiusData(pkgPath string) (map[string]interface{}, error) {
	// Read recipe files
	files := make(map[string]string)
	for _, fileName := range []string{radiusBicepRecipe, radiusTFRecipe, radiusTFRecipeVariables} {
		filePath := path.Join(pkgPath, fileName)
		content, err := util.ReadRegularFile(filePath)
		if err == nil {
			files[fileName] = string(content)
		} else if !errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("error reading recipe file (%s): %w", fileName, err)
		}
	}

	// Check recipe files found
	_, bicepFound := files[radiusBicepRecipe]
	_, tfFound := files[radiusTFRecipe]
	if bicepFound && tfFound {
		return nil, errors.New("invalid recipe: both bicep and terraform files found")
	}
	if len(files) == 0 {
		return nil, errors.New("no recipe files found")
	}

	// Return package data field
	return map[string]interface{}{
		RadiusRecipeKey: files,
	}, nil
}

// GetFilesWithSuffix returns the files with a given suffix in the path
// provided, ignoring the ones the ignorer matches.
func GetFilesWithSuffix(suffix, rootPath string, ignorer ignore.IgnoreParser) (map[string]string, error) {
	files := make(map[string]string)
	err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return fmt.Errorf("error reading files: %w", err)
		}
		if info.IsDir() {
			return nil
		}
		name := strings.TrimPrefix(path, rootPath+"/")
		if ignorer != nil && ignorer.MatchesPath(name) {
			return nil
		}
		if !strings.HasSuffix(info.Name(), suffix) {
			return nil
		}
		content, err := util.ReadRegularFile(path)
		if err != nil {
			return fmt.Errorf("error reading file: %w", err)
		}
		files[name] = string(content)
		return nil
	})
	if err != nil {
		return nil, err
	}
	return files, nil
}
