package vulndb

import (
	"fmt"
	"log"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)

// AdvisorySource is the advisory lookup the matcher needs. *Store satisfies it.
// The seam keeps the matching logic independent of how the database is opened.
type AdvisorySource interface {
	Advisories(bucket, srcName string) ([]Advisory, error)
}

// Result is one vulnerable package on one server, before it becomes a finding.
//
// Severity, Title, CVSSScore and References are only as good as the advisory
// until MetaCache.Enrich has run over them — for the Debian-family buckets the
// advisory carries no severity at all, so an unenriched Result reads "unknown".
type Result struct {
	CVEID       string
	PackageName string // the BINARY package, which is what is installed
	Installed   string
	FixedIn     string
	Severity    string
	Title       string
	CVSSScore   float64
	References  []string
}

// Match returns every advisory that the installed packages do not satisfy.
//
// Vulnerable means: no fix has been published, or the installed version sorts
// strictly before the fixed version under the distribution's own ordering.
// Equal is NOT vulnerable — that is the backported-fix case, where a
// distribution patches in place without changing the upstream version, and
// treating it as vulnerable reports a patched fleet as exposed.
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
	bucket, err := Bucket(os.Family, os.VersionID)
	if err != nil {
		log.Printf("vulndb: no bucket for family=%q version=%q: %v", os.Family, os.VersionID, err)
		return nil, err
	}
	log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)

	var advisoryCount, skipped, unactionable, noFix int

	// Advisories are keyed on the SOURCE package, and several hundred binary
	// packages on a host resolve to the same few hundred sources — linux-modules,
	// linux-image and linux-headers all ask about "linux", whose advisory list is
	// thousands long. Without this the same bolt read is repeated once per binary
	// package, which is most of what made a single Ubuntu host take minutes.
	cache := make(map[string][]Advisory, len(pkgs))

	var out []Result
	for _, p := range newestPerSource(os.Family, pkgs) {
		// Debian and Ubuntu advisories are keyed on the source package: one
		// advisory against "openssl" covers libssl3, openssl and libssl-dev.
		srcName := p.SourceName
		if srcName == "" {
			srcName = p.Name
		}

		advs, cached := cache[srcName]
		if !cached {
			var err error
			advs, err = src.Advisories(bucket, srcName)
			if err != nil {
				return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
			}
			cache[srcName] = advs
		}
		advisoryCount += len(advs)
		if len(advs) > 0 {
			Debugf("%s (src %s, installed %s): %d advisories", p.Name, srcName, p.Version, len(advs))
		}

		for _, a := range advs {
			// No published fix. Whether that is a finding depends entirely on the
			// status the vendor attached to it — see actionable().
			if a.FixedVersion == "" {
				if !actionable(a.Status) {
					unactionable++
					Debugf("%s (src %s): %s skipped, status %q", p.Name, srcName, a.CVEID, a.Status)
					continue
				}
				noFix++
				out = append(out, Result{
					CVEID: a.CVEID, PackageName: p.Name,
					Installed: p.Version, Severity: a.Severity,
				})
				continue
			}

			older, err := LessThan(os.Family, p.Version, a.FixedVersion)
			if err != nil {
				// Skip this one advisory rather than failing the whole server:
				// one unparseable version must not blind us to every other CVE
				// on the host. Log it — a silent skip is a silent false
				// negative, which is the direction that hurts.
				log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
				skipped++
				continue
			}
			Debugf("%s %s vs fixed %s (%s): vulnerable=%t", p.Name, p.Version, a.FixedVersion, a.CVEID, older)
			if older {
				out = append(out, Result{
					CVEID: a.CVEID, PackageName: p.Name,
					Installed: p.Version, FixedIn: a.FixedVersion, Severity: a.Severity,
				})
			}
		}
	}
	log.Printf("vulndb: bucket %q done: %d packages, %d sources, %d advisories considered, "+
		"%d results (%d with no vendor fix), %d skipped as not-yet-triaged, %d unparseable comparisons",
		bucket, len(pkgs), len(cache), advisoryCount, len(out), noFix, unactionable, skipped)
	return out, nil
}

// newestPerSource collapses the installed set to one binary package per source
// package: the one carrying the highest version.
//
// Advisories are keyed on the source, so every binary package of a source asks
// the same question. Normally they all carry the same version and the answer is
// the same, so collapsing is free. The kernel is the exception that makes it
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
// it, and the old one lingers until an autoremove. Matched per binary package,
// a fully patched host reports every superseded ABI package as vulnerable —
// which is the noise this exists to stop — and reports it twice over, once for
// linux-headers-6.8.0-124 and again for its -generic sibling.
//
// The version, not the name, decides. There is no kernel special case here: a
// source's newest installed version is what the fix landed as, whatever the
// source is.
//
// A comparison that cannot be made keeps the incumbent rather than guessing;
// the loser is dropped either way, and dropping the parseable one would be the
// false-negative direction.
func newestPerSource(family string, pkgs []models.InstalledPackage) []models.InstalledPackage {
	best := make(map[string]models.InstalledPackage, len(pkgs))
	order := make([]string, 0, len(pkgs))

	for _, p := range pkgs {
		src := p.SourceName
		if src == "" {
			src = p.Name
		}
		cur, seen := best[src]
		if !seen {
			best[src] = p
			order = append(order, src)
			continue
		}
		older, err := LessThan(family, cur.Version, p.Version)
		if err != nil {
			log.Printf("vulndb: newest for source %s: compare %s vs %s: %v",
				src, cur.Version, p.Version, err)
			continue
		}
		if older {
			Debugf("source %s: %s %s supersedes %s %s",
				src, p.Name, p.Version, cur.Name, cur.Version)
			best[src] = p
		}
	}

	out := make([]models.InstalledPackage, 0, len(order))
	for _, src := range order {
		out = append(out, best[src])
	}
	return out
}

// actionable decides whether an advisory with no fixed version is a finding.
//
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
// status for every CVE against every source package it ships — the vast
// majority being "under_investigation" (the tracker's needs-triage), meaning
// nobody has yet established that the package is affected at all. Reporting
// those produced ~24,000 findings for a single 797-package host, which is not a
// security report, it is a wall. A "not_affected" is the vendor stating the
// opposite of a finding, so it is never one.
//
// What survives is what the vendor has confirmed: affected, will_not_fix,
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
// is about — an empty fixed_in that means "no fix exists", the one most in need
// of acceptance rather than patching.
// Only the two statuses that positively say "this is not a finding" are
// dropped. "unknown" is kept: a feed that sets no status at all must not become
// a silent false negative, and it is not what generates the noise — Ubuntu
// states under_investigation explicitly.
func actionable(status string) bool {
	switch status {
	case "not_affected", "under_investigation":
		return false
	default:
		return true
	}
}
