package cyclonedx

import (
	"io"
	"sort"
	"strconv"
	"strings"

	cdx "github.com/CycloneDX/cyclonedx-go"
	"github.com/samber/lo"
	"golang.org/x/xerrors"

	ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
	"github.com/aquasecurity/trivy/pkg/purl"
	"github.com/aquasecurity/trivy/pkg/sbom"
)

type Unmarshaler struct {
	format cdx.BOMFileFormat

	dependencies map[string][]string
	components   map[string]cdx.Component
}

func NewJSONUnmarshaler() sbom.Unmarshaler {
	return &Unmarshaler{
		format: cdx.BOMFileFormatJSON,
	}
}

func (u *Unmarshaler) Unmarshal(r io.Reader) (sbom.SBOM, error) {
	bom := cdx.NewBOM()
	decoder := cdx.NewBOMDecoder(r, u.format)
	if err := decoder.Decode(bom); err != nil {
		return sbom.SBOM{}, xerrors.Errorf("CycloneDX decode error: %w", err)
	}

	u.dependencies = dependencyMap(bom.Dependencies)
	u.components = componentMap(bom.Metadata, bom.Components)

	var (
		osInfo   *ftypes.OS
		apps     []ftypes.Application
		pkgInfos []ftypes.PackageInfo
		seen     = make(map[string]struct{})
	)
	for bomRef := range u.dependencies {
		component := u.components[bomRef]
		switch component.Type {
		case cdx.ComponentTypeOS: // OS info and OS packages
			osInfo = toOS(component)
			pkgInfo, err := u.parseOSPkgs(component, seen)
			if err != nil {
				return sbom.SBOM{}, xerrors.Errorf("failed to parse os packages: %w", err)
			}
			pkgInfos = append(pkgInfos, pkgInfo)
		case cdx.ComponentTypeApplication: // It would be a lock file in a CycloneDX report generated by Trivy
			if lookupProperty(component.Properties, PropertyType) == "" {
				continue
			}
			app, err := u.parseLangPkgs(component, seen)
			if err != nil {
				return sbom.SBOM{}, xerrors.Errorf("failed to parse language packages: %w", err)
			}
			apps = append(apps, *app)
		case cdx.ComponentTypeLibrary:
			// It is an individual package not associated with any lock files and should be processed later.
			// e.g. .gemspec, .egg and .wheel
			continue
		}
	}

	var libComponents []cdx.Component
	for ref, component := range u.components {
		if _, ok := seen[ref]; ok {
			continue
		}
		if component.Type == cdx.ComponentTypeLibrary {
			libComponents = append(libComponents, component)
		}
	}

	aggregatedApps, err := aggregateLangPkgs(libComponents)
	if err != nil {
		return sbom.SBOM{}, xerrors.Errorf("failed to aggregate packages: %w", err)
	}
	apps = append(apps, aggregatedApps...)

	sort.Slice(apps, func(i, j int) bool {
		if apps[i].Type != apps[j].Type {
			return apps[i].Type < apps[j].Type
		}
		return apps[i].FilePath < apps[j].FilePath
	})

	var metadata ftypes.Metadata
	if bom.Metadata != nil {
		metadata.Timestamp = bom.Metadata.Timestamp
		if bom.Metadata.Component != nil {
			metadata.Component = toTrivyCdxComponent(lo.FromPtr(bom.Metadata.Component))
		}
	}

	var components []ftypes.Component
	for _, c := range lo.FromPtr(bom.Components) {
		components = append(components, toTrivyCdxComponent(c))
	}

	return sbom.SBOM{
		OS:           osInfo,
		Packages:     pkgInfos,
		Applications: apps,

		// Keep the original SBOM
		CycloneDX: &ftypes.CycloneDX{
			BOMFormat:    bom.BOMFormat,
			SpecVersion:  bom.SpecVersion,
			SerialNumber: bom.SerialNumber,
			Version:      bom.Version,
			Metadata:     metadata,
			Components:   components,
		},
	}, nil
}

func (u *Unmarshaler) parseOSPkgs(component cdx.Component, seen map[string]struct{}) (ftypes.PackageInfo, error) {
	components := u.walkDependencies(component.BOMRef)
	pkgs, err := parsePkgs(components, seen)
	if err != nil {
		return ftypes.PackageInfo{}, xerrors.Errorf("failed to parse os package: %w", err)
	}

	return ftypes.PackageInfo{
		Packages: pkgs,
	}, nil
}

func (u *Unmarshaler) parseLangPkgs(component cdx.Component, seen map[string]struct{}) (*ftypes.Application, error) {
	components := u.walkDependencies(component.BOMRef)
	components = lo.UniqBy(components, func(c cdx.Component) string {
		return c.BOMRef
	})

	app := toApplication(component)
	pkgs, err := parsePkgs(components, seen)
	if err != nil {
		return nil, xerrors.Errorf("failed to parse language-specific packages: %w", err)
	}
	app.Libraries = pkgs

	return app, nil
}

func parsePkgs(components []cdx.Component, seen map[string]struct{}) ([]ftypes.Package, error) {
	var pkgs []ftypes.Package
	for _, com := range components {
		seen[com.BOMRef] = struct{}{}
		_, pkg, err := toPackage(com)
		if err != nil {
			return nil, xerrors.Errorf("failed to parse language package: %w", err)
		}
		pkgs = append(pkgs, *pkg)
	}
	return pkgs, nil
}

// walkDependencies takes all nested dependencies of the root component.
//
// e.g. Library A, B, C, D and E will be returned as dependencies of Application 1.
// type: Application 1
//   - type: Library A
//     - type: Library B
//   - type: Application 2
//     - type: Library C
//     - type: Application 3
//       - type: Library D
//       - type: Library E
func (u *Unmarshaler) walkDependencies(rootRef string) []cdx.Component {
	var components []cdx.Component
	for _, dep := range u.dependencies[rootRef] {
		component, ok := u.components[dep]
		if !ok {
			continue
		}

		// Take only 'Libraries'
		if component.Type == cdx.ComponentTypeLibrary {
			components = append(components, component)
		}

		components = append(components, u.walkDependencies(dep)...)
	}
	return components
}

func componentMap(metadata *cdx.Metadata, components *[]cdx.Component) map[string]cdx.Component {
	cmap := make(map[string]cdx.Component)

	for _, component := range lo.FromPtr(components) {
		cmap[component.BOMRef] = component
	}
	if metadata != nil {
		cmap[metadata.Component.BOMRef] = *metadata.Component
	}
	return cmap
}

func dependencyMap(deps *[]cdx.Dependency) map[string][]string {
	depMap := make(map[string][]string)

	for _, dep := range lo.FromPtr(deps) {
		if _, ok := depMap[dep.Ref]; ok {
			continue
		}

		var refs []string
		for _, d := range lo.FromPtr(dep.Dependencies) {
			refs = append(refs, d.Ref)
		}

		depMap[dep.Ref] = refs
	}
	return depMap
}

func aggregateLangPkgs(libs []cdx.Component) ([]ftypes.Application, error) {
	pkgMap := map[string][]ftypes.Package{}
	for _, lib := range libs {
		appType, pkg, err := toPackage(lib)
		if err != nil {
			return nil, xerrors.Errorf("failed to parse purl to package: %w", err)
		}

		pkgMap[appType] = append(pkgMap[appType], *pkg)
	}

	var apps []ftypes.Application
	for appType, pkgs := range pkgMap {
		sort.Slice(pkgs, func(i, j int) bool {
			return pkgs[i].Name < pkgs[j].Name
		})
		apps = append(apps, ftypes.Application{
			Type:      appType,
			Libraries: pkgs,
		})
	}
	return apps, nil
}

func toOS(component cdx.Component) *ftypes.OS {
	return &ftypes.OS{
		Family: component.Name,
		Name:   component.Version,
	}
}

func toApplication(component cdx.Component) *ftypes.Application {
	return &ftypes.Application{
		Type:     lookupProperty(component.Properties, PropertyType),
		FilePath: component.Name,
	}
}

func toPackage(component cdx.Component) (string, *ftypes.Package, error) {
	p, err := purl.FromString(component.PackageURL)
	if err != nil {
		return "", nil, xerrors.Errorf("failed to parse purl: %w", err)
	}

	pkg := p.Package()
	pkg.Ref = component.BOMRef

	for _, license := range lo.FromPtr(component.Licenses) {
		pkg.Licenses = append(pkg.Licenses, license.Expression)
	}

	for _, prop := range lo.FromPtr(component.Properties) {
		if strings.HasPrefix(prop.Name, Namespace) {
			switch strings.TrimPrefix(prop.Name, Namespace) {
			case PropertySrcName:
				pkg.SrcName = prop.Value
			case PropertySrcVersion:
				pkg.SrcVersion = prop.Value
			case PropertySrcRelease:
				pkg.SrcRelease = prop.Value
			case PropertySrcEpoch:
				pkg.SrcEpoch, err = strconv.Atoi(prop.Value)
				if err != nil {
					return "", nil, xerrors.Errorf("failed to parse source epoch: %w", err)
				}
			case PropertyModularitylabel:
				pkg.Modularitylabel = prop.Value
			case PropertyLayerDiffID:
				pkg.Layer.DiffID = prop.Value
			}
		}
	}

	return p.AppType(), pkg, nil
}

func toTrivyCdxComponent(component cdx.Component) ftypes.Component {
	return ftypes.Component{
		BOMRef:     component.BOMRef,
		MIMEType:   component.MIMEType,
		Type:       ftypes.ComponentType(component.Type),
		Name:       component.Name,
		Version:    component.Version,
		PackageURL: component.PackageURL,
	}
}

func lookupProperty(properties *[]cdx.Property, key string) string {
	for _, p := range lo.FromPtr(properties) {
		if p.Name == Namespace+key {
			return p.Value
		}
	}
	return ""
}
