package output

import (
	"encoding/json"
	"fmt"
	"log"
	"maps"
	"os"
	"slices"
	"strings"

	"github.com/google/osv-scanner/v2/internal/identifiers"
	"github.com/google/osv-scanner/v2/internal/utility/vulns"
	"github.com/google/osv-scanner/v2/pkg/models"
	"github.com/ossf/osv-schema/bindings/go/osvschema"
	"google.golang.org/protobuf/encoding/protojson"
)

type pkgWithSource struct {
	Package models.PackageInfo `json:"Package"`
	Source  models.SourceInfo  `json:"Source"`
}

// Custom implementation of this unique set map to allow it to serialize to JSON
type pkgSourceSet map[pkgWithSource]struct{}

// StableKeys returns the pkgWithSource keys in a deterministic order
func (pss *pkgSourceSet) StableKeys() []pkgWithSource {
	pkgWithSrcKeys := slices.AppendSeq(make([]pkgWithSource, 0, len(*pss)), maps.Keys(*pss))

	slices.SortFunc(pkgWithSrcKeys, func(a, b pkgWithSource) int {
		// compare based on each field in descending priority
		for _, fn := range []func() int{
			func() int { return strings.Compare(a.Source.Path, b.Source.Path) },
			func() int { return strings.Compare(a.Package.Name, b.Package.Name) },
			func() int { return strings.Compare(a.Package.Version, b.Package.Version) },
		} {
			if r := fn(); r != 0 {
				return r
			}
		}

		return 0
	})

	return pkgWithSrcKeys
}

func (pss *pkgSourceSet) MarshalJSON() ([]byte, error) {
	res := make([]pkgWithSource, 0, len(*pss))

	for v := range *pss {
		res = append(res, v)
	}

	return json.Marshal(res)
}

func (pss *pkgSourceSet) UnmarshalJSON(data []byte) error {
	aux := []pkgWithSource{}
	if err := json.Unmarshal(data, &aux); err != nil {
		return err
	}

	*pss = make(pkgSourceSet)
	for _, pws := range aux {
		(*pss)[pws] = struct{}{}
	}

	return nil
}

// mustGetWorkingDirectory panics if it can't get the working directory
func mustGetWorkingDirectory() string {
	dir, err := os.Getwd()
	if err != nil {
		log.Panicf("can't get working dir: %v", err)
	}

	return dir
}

// groupFixedVersions builds the fixed versions for each ID Group, with keys formatted like so:
// `Source:ID`
func groupFixedVersions(flattened []models.VulnerabilityFlattened) map[string][]string {
	groupedFixedVersions := map[string][]string{}

	// Get the fixed versions indexed by each group of vulnerabilities
	// Prepend source path as same vulnerability in two projects should be counted twice
	// Remember to sort and compact before displaying later
	for _, vf := range flattened {
		if vf.Vulnerability == nil {
			continue
		}
		groupIdx := vf.Source.String() + ":" + vf.GroupInfo.IndexString()
		pkg := vulns.PackageKey{
			Ecosystem: vf.Package.Ecosystem,
			Name:      vf.Package.Name,
		}
		groupedFixedVersions[groupIdx] =
			append(groupedFixedVersions[groupIdx], vulns.GetFixedVersions(vf.Vulnerability)[pkg]...)
	}

	// Remove duplicates
	for k := range groupedFixedVersions {
		fixedVersions := groupedFixedVersions[k]
		slices.Sort(fixedVersions)
		groupedFixedVersions[k] = slices.Compact(fixedVersions)
	}

	return groupedFixedVersions
}

// groupedSARIFFinding groups vulnerabilities by aliases
type groupedSARIFFinding struct {
	DisplayID string
	PkgSource pkgSourceSet
	// AliasedVulns contains vulns that are OSV vulnerabilities
	AliasedVulns map[string]*osvschema.Vulnerability
	// AliasedIDList contains all aliased IDs, including ones that are not OSV (e.g. CVE IDs)
	// Sorted by idSortFunc, therefore the first element will be the display ID
	AliasedIDList []string
}

// UnmarshalJSON implements the json.unmarshaler interface.
// It is required because the AliasedVulns field is a proto message,
// which requires protojson to unmarshal, while the rest of the struct uses
// the standard encoding/json library.
func (g *groupedSARIFFinding) UnmarshalJSON(data []byte) error {
	// Use alias to avoid recursion.
	type alias groupedSARIFFinding

	// Use temporary struct to combine standard fields (via alias)
	// and the manually processed field (via shadowing).
	tmp := &struct {
		*alias

		AliasedVulns map[string]json.RawMessage `json:"AliasedVulns"`
	}{
		alias: (*alias)(g),
	}

	if err := json.Unmarshal(data, &tmp); err != nil {
		return err
	}

	// Manually process the custom field from RawMessage format.
	if tmp.AliasedVulns != nil {
		g.AliasedVulns = make(map[string]*osvschema.Vulnerability, len(tmp.AliasedVulns))
		for id, rawVuln := range tmp.AliasedVulns {
			var vuln osvschema.Vulnerability
			if err := protojson.Unmarshal(rawVuln, &vuln); err != nil {
				return fmt.Errorf("failed to protojson unmarshal vuln %q: %w", id, err)
			}
			g.AliasedVulns[id] = &vuln
		}
	}

	return nil
}

// MarshalJSON implements the json.Marshaler interface.
// It is required because the AliasedVulns field is a proto message,
// which requires protojson to marshal, while the rest of the struct uses
// the standard encoding/json library.
func (g *groupedSARIFFinding) MarshalJSON() ([]byte, error) {
	// Use alias to avoid recursion.
	type alias groupedSARIFFinding

	// Pre-process the custom field into standardized RawMessage format.
	var rawVulns map[string]json.RawMessage
	if g.AliasedVulns != nil {
		rawVulns = make(map[string]json.RawMessage, len(g.AliasedVulns))
		for id, vuln := range g.AliasedVulns {
			unstableJSON, err := protojson.Marshal(vuln)
			if err != nil {
				return nil, fmt.Errorf("failed to marshal vuln %q: %w", id, err)
			}
			var vuln any
			if err := json.Unmarshal(unstableJSON, &vuln); err != nil {
				return nil, err
			}
			b, err := json.MarshalIndent(vuln, "", "  ")
			if err != nil {
				return nil, err
			}
			rawVulns[id] = b
		}
	}

	// Use temporary struct to combine standard fields (via alias)
	// and the manually processed field (via shadowing).
	return json.Marshal(&struct {
		*alias

		AliasedVulns map[string]json.RawMessage `json:"AliasedVulns"`
	}{
		alias:        (*alias)(g),
		AliasedVulns: rawVulns,
	})
}

// mapIDsToGroupedSARIFFinding creates a map over all vulnerability IDs, with aliased vuln IDs
// pointing to the same groupedSARIFFinding object
func mapIDsToGroupedSARIFFinding(vulnResults *models.VulnerabilityResults) map[string]*groupedSARIFFinding {
	// Map of vuln IDs to their respective groupedSARIFFinding
	results := map[string]*groupedSARIFFinding{}

	for _, res := range vulnResults.Results {
		for _, pkg := range res.Packages {
			for _, gi := range pkg.Groups {
				var data *groupedSARIFFinding
				// See if this vulnerability group already exists (from another package or source)
				for _, id := range gi.IDs {
					existingData, ok := results[id]
					if ok {
						data = existingData
						break
					}
				}
				// If not create this group
				if data == nil {
					data = &groupedSARIFFinding{
						PkgSource:    make(pkgSourceSet),
						AliasedVulns: make(map[string]*osvschema.Vulnerability),
					}
				}
				// Point all the IDs of the same group to the same data, either newly created or existing
				for _, id := range gi.IDs {
					results[id] = data
				}
			}
			for _, v := range pkg.Vulnerabilities {
				newPkgSource := pkgWithSource{
					Package: pkg.Package,
					Source:  res.Source,
				}
				entry := results[v.GetId()]
				entry.PkgSource[newPkgSource] = struct{}{}
				entry.AliasedVulns[v.GetId()] = v
				entry.AliasedIDList = append(entry.AliasedIDList, v.GetId())
				entry.AliasedIDList = append(entry.AliasedIDList, v.GetAliases()...)
			}
		}
	}

	for _, gs := range results {
		slices.SortFunc(gs.AliasedIDList, identifiers.IDSortFunc)
		gs.AliasedIDList = slices.Compact(gs.AliasedIDList)
		gs.DisplayID = gs.AliasedIDList[0]
	}

	return results
}
