package report

import (
	"context"
	"fmt"
	"io"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"

	containerName "github.com/google/go-containerregistry/pkg/name"
	"github.com/owenrumney/go-sarif/v2/sarif"
	"golang.org/x/xerrors"

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

const (
	sarifOsPackageVulnerability        = "OsPackageVulnerability"
	sarifLanguageSpecificVulnerability = "LanguageSpecificPackageVulnerability"
	sarifConfigFiles                   = "Misconfiguration"
	sarifSecretFiles                   = "Secret"
	sarifLicenseFiles                  = "License"
	sarifUnknownIssue                  = "UnknownIssue"

	sarifError   = "error"
	sarifWarning = "warning"
	sarifNote    = "note"
	sarifNone    = "none"

	columnKind = "utf16CodeUnits"

	builtinRulesUrl = "https://github.com/aquasecurity/trivy/blob/main/pkg/fanal/secret/builtin-rules.go" // list all secrets
)

var (
	// pathRegex to extract file path in case string includes (distro:version)
	pathRegex = regexp.MustCompile(`(?P<path>.+?)(?:\s*\((?:.*?)\).*?)?$`)
)

// SarifWriter implements result Writer
type SarifWriter struct {
	Output        io.Writer
	Version       string
	run           *sarif.Run
	locationCache map[string][]location
	Target        string
}

type sarifData struct {
	title            string
	vulnerabilityId  string
	shortDescription string
	fullDescription  string
	helpText         string
	helpMarkdown     string
	resourceClass    types.ResultClass
	severity         string
	url              *url.URL
	resultIndex      int
	artifactLocation *url.URL
	locationMessage  string
	message          string
	cvssScore        string
	cvssData         map[string]any
	locations        []location
}

type location struct {
	startLine int
	endLine   int
}

func (sw *SarifWriter) addSarifRule(data *sarifData) {
	r := sw.run.AddRule(data.vulnerabilityId).
		WithName(toSarifRuleName(data.resourceClass)).
		WithDescription(data.vulnerabilityId).
		WithShortDescription(&sarif.MultiformatMessageString{Text: &data.shortDescription}).
		WithFullDescription(&sarif.MultiformatMessageString{Text: &data.fullDescription}).
		WithHelp(&sarif.MultiformatMessageString{
			Text:     &data.helpText,
			Markdown: &data.helpMarkdown,
		}).
		WithDefaultConfiguration(&sarif.ReportingConfiguration{
			Level: toSarifErrorLevel(data.severity),
		}).
		WithProperties(toProperties(data.title, data.severity, data.cvssScore, data.cvssData))
	if data.url != nil && data.url.String() != "" {
		r.WithHelpURI(data.url.String())
	}
}

func (sw *SarifWriter) addSarifResult(data *sarifData) {
	sw.addSarifRule(data)

	result := sarif.NewRuleResult(data.vulnerabilityId).
		WithRuleIndex(data.resultIndex).
		WithMessage(sarif.NewTextMessage(data.message)).
		WithLevel(toSarifErrorLevel(data.severity)).
		WithLocations(toSarifLocations(data.locations, data.artifactLocation.String(), data.locationMessage))
	sw.run.AddResult(result)
}

func getRuleIndex(id string, indexes map[string]int) int {
	i, ok := indexes[id]
	if ok {
		return i
	}
	l := len(indexes)
	indexes[id] = l
	return l
}

// pathToFileURI converts a filesystem path to a file URI.
// On Windows, backslashes are converted to forward slashes and a leading slash is prepended.
func pathToFileURI(path string) string {
	absPath, _ := filepath.Abs(path)
	slashPath := filepath.ToSlash(absPath)
	if !strings.HasPrefix(slashPath, "/") {
		slashPath = "/" + slashPath
	}
	return fmt.Sprintf("file://%s/", slashPath)
}

func (sw *SarifWriter) Write(_ context.Context, report types.Report) error {
	sarifReport, err := sarif.New(sarif.Version210)
	if err != nil {
		return xerrors.Errorf("error creating a new sarif template: %w", err)
	}
	sw.run = sarif.NewRunWithInformationURI("Trivy", "https://github.com/aquasecurity/trivy")
	sw.run.Tool.Driver.WithVersion(sw.Version)
	sw.run.Tool.Driver.WithFullName("Trivy Vulnerability Scanner")
	sw.locationCache = make(map[string][]location)
	if report.ArtifactType == ftypes.TypeContainerImage {
		sw.run.Properties = sarif.Properties{
			"imageName":   report.ArtifactName,
			"repoTags":    report.Metadata.RepoTags,
			"repoDigests": report.Metadata.RepoDigests,
			"imageID":     report.Metadata.ImageID,
		}
	}

	ruleIndexes := make(map[string]int)
	for _, res := range report.Results {
		target := ToPathUri(res.Target, res.Class)

		for _, vuln := range res.Vulnerabilities {
			fullDescription := vuln.Description
			if fullDescription == "" {
				fullDescription = vuln.Title
			}
			path := target
			if vuln.PkgPath != "" {
				path = ToPathUri(vuln.PkgPath, res.Class)
			}
			cvssData, cvssScore := toCVSSData(vuln)
			sw.addSarifResult(&sarifData{
				title:            "vulnerability",
				vulnerabilityId:  vuln.VulnerabilityID,
				severity:         vuln.Severity,
				cvssScore:        cvssScore,
				cvssData:         cvssData,
				url:              toUri(vuln.PrimaryURL),
				resourceClass:    res.Class,
				artifactLocation: toUri(path),
				locationMessage:  fmt.Sprintf("%v: %v@%v", path, vuln.PkgName, vuln.InstalledVersion),
				locations:        sw.getLocations(vuln.PkgName, vuln.InstalledVersion, path, res.Packages),
				resultIndex:      getRuleIndex(vuln.VulnerabilityID, ruleIndexes),
				shortDescription: vuln.Title,
				fullDescription:  fullDescription,
				helpText: fmt.Sprintf("Vulnerability %v\nSeverity: %v\nPackage: %v\nFixed Version: %v\nLink: [%v](%v)\n%v",
					vuln.VulnerabilityID, vuln.Severity, vuln.PkgName, vuln.FixedVersion, vuln.VulnerabilityID, vuln.PrimaryURL, vuln.Description),
				helpMarkdown: fmt.Sprintf("**Vulnerability %v**\n| Severity | Package | Fixed Version | Link |\n| --- | --- | --- | --- |\n|%v|%v|%v|[%v](%v)|\n\n%v",
					vuln.VulnerabilityID, vuln.Severity, vuln.PkgName, vuln.FixedVersion, vuln.VulnerabilityID, vuln.PrimaryURL, vuln.Description),
				message: fmt.Sprintf("Package: %v\nInstalled Version: %v\nVulnerability %v\nSeverity: %v\nFixed Version: %v\nLink: [%v](%v)",
					vuln.PkgName, vuln.InstalledVersion, vuln.VulnerabilityID, vuln.Severity, vuln.FixedVersion, vuln.VulnerabilityID, vuln.PrimaryURL),
			})
		}
		for _, misconf := range res.Misconfigurations {
			locationURI := clearURI(res.Target)
			sw.addSarifResult(&sarifData{
				title:            "misconfiguration",
				vulnerabilityId:  misconf.ID,
				severity:         misconf.Severity,
				cvssScore:        severityToScore(misconf.Severity),
				url:              toUri(misconf.PrimaryURL),
				resourceClass:    res.Class,
				artifactLocation: toUri(locationURI),
				locationMessage:  locationURI,
				locations: []location{
					{
						startLine: misconf.CauseMetadata.StartLine,
						endLine:   misconf.CauseMetadata.EndLine,
					},
				},
				resultIndex:      getRuleIndex(misconf.ID, ruleIndexes),
				shortDescription: misconf.Title,
				fullDescription:  misconf.Description,
				helpText: fmt.Sprintf("Misconfiguration %v\nType: %s\nSeverity: %v\nCheck: %v\nMessage: %v\nLink: [%v](%v)\n%s",
					misconf.ID, misconf.Type, misconf.Severity, misconf.Title, misconf.Message, misconf.ID, misconf.PrimaryURL, misconf.Description),
				helpMarkdown: fmt.Sprintf("**Misconfiguration %v**\n| Type | Severity | Check | Message | Link |\n| --- | --- | --- | --- | --- |\n|%v|%v|%v|%s|[%v](%v)|\n\n%v",
					misconf.ID, misconf.Type, misconf.Severity, misconf.Title, misconf.Message, misconf.ID, misconf.PrimaryURL, misconf.Description),
				message: fmt.Sprintf("Artifact: %v\nType: %v\nVulnerability %v\nSeverity: %v\nMessage: %v\nLink: [%v](%v)",
					locationURI, res.Type, misconf.ID, misconf.Severity, misconf.Message, misconf.ID, misconf.PrimaryURL),
			})
		}
		for _, secret := range res.Secrets {
			sw.addSarifResult(&sarifData{
				title:            "secret",
				vulnerabilityId:  secret.RuleID,
				severity:         secret.Severity,
				cvssScore:        severityToScore(secret.Severity),
				url:              toUri(builtinRulesUrl),
				resourceClass:    res.Class,
				artifactLocation: toUri(target),
				locationMessage:  target,
				locations: []location{
					{
						startLine: secret.StartLine,
						endLine:   secret.EndLine,
					},
				},
				resultIndex:      getRuleIndex(secret.RuleID, ruleIndexes),
				shortDescription: secret.Title,
				fullDescription:  secret.Match,
				helpText: fmt.Sprintf("Secret %v\nSeverity: %v\nMatch: %s",
					secret.Title, secret.Severity, secret.Match),
				helpMarkdown: fmt.Sprintf("**Secret %v**\n| Severity | Match |\n| --- | --- |\n|%v|%v|",
					secret.Title, secret.Severity, secret.Match),
				message: fmt.Sprintf("Artifact: %v\nType: %v\nSecret %v\nSeverity: %v\nMatch: %v",
					res.Target, res.Type, secret.Title, secret.Severity, secret.Match),
			})
		}
		for _, license := range res.Licenses {
			id := fmt.Sprintf("%s:%s", license.PkgName, license.Name)
			desc := fmt.Sprintf("%s in %s", license.Name, license.PkgName)
			sw.addSarifResult(&sarifData{
				title:            "license",
				vulnerabilityId:  id,
				severity:         license.Severity,
				cvssScore:        severityToScore(license.Severity),
				url:              toUri(license.Link),
				resourceClass:    res.Class,
				artifactLocation: toUri(target),
				resultIndex:      getRuleIndex(id, ruleIndexes),
				shortDescription: desc,
				fullDescription:  desc,
				helpText: fmt.Sprintf("License %s\nClassification: %s\nPkgName: %s\nPath: %s",
					license.Name, license.Category, license.PkgName, license.FilePath),
				helpMarkdown: fmt.Sprintf("**License %s**\n| PkgName | Classification | Path |\n| --- | --- | --- |\n|%s|%s|%s|",
					license.Name, license.PkgName, license.Category, license.FilePath),
				message: fmt.Sprintf("Artifact: %s\nLicense %s\nPkgName: %s\n Classification: %s\n Path: %s",
					res.Target, license.Name, license.PkgName, license.Category, license.FilePath),
			})
		}

	}
	sw.run.ColumnKind = columnKind

	if sw.Target != "" {
		rootPath := pathToFileURI(sw.Target)
		sw.run.OriginalUriBaseIDs = map[string]*sarif.ArtifactLocation{
			"ROOTPATH": {
				URI: &rootPath,
			},
		}
	}
	sarifReport.AddRun(sw.run)
	return sarifReport.PrettyWrite(sw.Output)
}

func toSarifLocations(locations []location, artifactLocation, locationMessage string) []*sarif.Location {
	var sarifLocs []*sarif.Location
	// add default (hardcoded) location for vulnerabilities that don't support locations
	if len(locations) == 0 {
		locations = append(locations, location{
			startLine: 1,
			endLine:   1,
		})
	}

	// some dependencies can be placed in multiple places.
	// e.g.https://github.com/aquasecurity/go-dep-parser/pull/134#discussion_r985353240
	// create locations for each place.

	for _, l := range locations {
		// location is missed. Use default (hardcoded) value (misconfigurations have this case)
		if l.startLine == 0 && l.endLine == 0 {
			l.startLine = 1
			l.endLine = 1
		}
		region := sarif.NewRegion().WithStartLine(l.startLine).WithEndLine(l.endLine).WithStartColumn(1).WithEndColumn(1)
		loc := sarif.NewPhysicalLocation().
			WithArtifactLocation(sarif.NewSimpleArtifactLocation(artifactLocation).WithUriBaseId("ROOTPATH")).
			WithRegion(region)
		sarifLocs = append(sarifLocs, sarif.NewLocation().WithMessage(sarif.NewTextMessage(locationMessage)).WithPhysicalLocation(loc))
	}

	return sarifLocs
}

func toSarifRuleName(class types.ResultClass) string {
	switch class {
	case types.ClassOSPkg:
		return sarifOsPackageVulnerability
	case types.ClassLangPkg:
		return sarifLanguageSpecificVulnerability
	case types.ClassConfig:
		return sarifConfigFiles
	case types.ClassSecret:
		return sarifSecretFiles
	case types.ClassLicense, types.ClassLicenseFile:
		return sarifLicenseFiles
	default:
		return sarifUnknownIssue
	}
}

func toSarifErrorLevel(severity string) string {
	switch severity {
	case "CRITICAL", "HIGH":
		return sarifError
	case "MEDIUM":
		return sarifWarning
	case "LOW", "UNKNOWN":
		return sarifNote
	default:
		return sarifNone
	}
}

func ToPathUri(input string, resultClass types.ResultClass) string {
	// we only need to convert OS input
	// e.g. image names, digests, etc...
	if resultClass != types.ClassOSPkg {
		return input
	}
	matches := pathRegex.FindStringSubmatch(input)
	if matches != nil {
		input = matches[pathRegex.SubexpIndex("path")]
	}
	ref, err := containerName.ParseReference(input)
	if err == nil {
		input = ref.Context().RepositoryStr()
	}

	return clearURI(input)
}

// clearURI clears URI for misconfigs
func clearURI(s string) string {
	s = strings.ReplaceAll(s, "\\", "/")
	// cf. https://developer.hashicorp.com/terraform/language/modules/sources
	switch {
	case strings.HasPrefix(s, "git@github.com:"):
		// build GitHub url format
		// e.g. `git@github.com:terraform-aws-modules/terraform-aws-s3-bucket.git?ref=v4.2.0/main.tf` -> `github.com/terraform-aws-modules/terraform-aws-s3-bucket/tree/v4.2.0/main.tf`
		// cf. https://github.com/aquasecurity/trivy/issues/7897
		s = strings.ReplaceAll(s, "git@github.com:", "github.com/")
		s = strings.ReplaceAll(s, ".git", "")
		s = strings.ReplaceAll(s, "?ref=", "/tree/")
	case strings.HasPrefix(s, "git::https:/") && !strings.HasPrefix(s, "git::https://"):
		s = strings.TrimPrefix(s, "git::https:/")
		s = strings.ReplaceAll(s, ".git", "")
	case strings.HasPrefix(s, "git::ssh://"):
		// `"`git::ssh://username@example.com/storage.git` -> `example.com/storage.git`
		if _, u, ok := strings.Cut(s, "@"); ok {
			s = u
		}
		s = strings.ReplaceAll(s, ".git", "")
	case strings.HasPrefix(s, "git::"):
		// `git::https://example.com/vpc.git` -> `https://example.com/vpc`
		s = strings.TrimPrefix(s, "git::")
		s = strings.ReplaceAll(s, ".git", "")
	case strings.HasPrefix(s, "hg::"):
		// `hg::http://example.com/vpc.hg` -> `http://example.com/vpc`
		s = strings.TrimPrefix(s, "hg::")
		s = strings.ReplaceAll(s, ".hg", "")
	case strings.HasPrefix(s, "s3::"):
		// `s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip` -> `https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip`
		s = strings.TrimPrefix(s, "s3::")
	case strings.HasPrefix(s, "gcs::"):
		// `gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zipp` -> `https://www.googleapis.com/storage/v1/modules/foomodule.zip`
		s = strings.TrimPrefix(s, "gcs::")
	}

	return s
}

func toUri(str string) *url.URL {
	uri, err := url.Parse(str)
	if err != nil {
		logger := log.WithPrefix("sarif")
		logger.Error("Unable to parse URI", log.String("URI", str), log.Err(err))
	}
	return uri
}

func (sw *SarifWriter) getLocations(name, version, path string, pkgs []ftypes.Package) []location {
	id := fmt.Sprintf("%s@%s@%s", path, name, version)
	locs, ok := sw.locationCache[id]
	if !ok {
		for _, pkg := range pkgs {
			if name == pkg.Name && version == pkg.Version {
				for _, l := range pkg.Locations {
					loc := location{
						startLine: l.StartLine,
						endLine:   l.EndLine,
					}
					locs = append(locs, loc)
				}
				sw.locationCache[id] = locs
				return locs
			}
		}
	}
	return locs
}

// toCVSSData extracts CVSS data from the vulnerability and returns it along with the score.
// If CVSS V3 Score is not available, it returns an empty CVSSData struct and a score based on severity.
func toCVSSData(vuln types.DetectedVulnerability) (map[string]any, string) {
	score := severityToScore(vuln.Severity)
	var data = make(map[string]any)

	// Note: 'cvssv3_baseScore' uses a hybrid naming convention (snake_case + camelCase)
	// Reference: https://docs.aws.amazon.com/codecatalyst/latest/userguide/test.sarif.html
	if cvss, ok := vuln.CVSS[vuln.SeveritySource]; ok {
		data["cvssv2_vector"] = cvss.V2Vector
		data["cvssv2_score"] = cvss.V2Score
		data["cvssv3_vector"] = cvss.V3Vector
		data["cvssv3_baseScore"] = cvss.V3Score
		data["cvssv40_vector"] = cvss.V40Vector
		data["cvssv40_baseScore"] = cvss.V40Score

		if cvss.V3Score != 0 {
			score = fmt.Sprintf("%.1f", cvss.V3Score)
		}
	}

	return data, score
}

func severityToScore(severity string) string {
	switch severity {
	case "CRITICAL":
		return "9.5"
	case "HIGH":
		return "8.0"
	case "MEDIUM":
		return "5.5"
	case "LOW":
		return "2.0"
	default:
		return "0.0"
	}
}

func toProperties(title, severity, cvssScore string, cvssData map[string]any) sarif.Properties {
	properties := sarif.Properties{
		"tags": []string{
			title,
			"security",
			severity,
		},
		"precision":         "very-high",
		"security-severity": cvssScore,
	}

	for key, value := range cvssData {
		switch v := value.(type) {
		case string:
			if v == "" {
				continue
			}
		case float64:
			if v == 0 {
				continue
			}
		}
		properties[key] = value
	}

	return properties
}
