package packages

import (
	"crypto/sha256"
	"encoding/hex"
	"sort"
	"strconv"
	"strings"
)

// Package is one installed package as the distribution reports it. Version is
// the distribution's own version string, verbatim — never normalised, because
// the advisory feeds are keyed on exactly this form.
type Package struct {
	Name       string
	Version    string
	Epoch      int
	Arch       string
	SourceName string
}

// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
//
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
// alone finds one of the three.
//
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
// every package dpkg knows about, including ones removed with their config
// files left behind — a host that has upgraded its kernel a dozen times reports
// a dozen old linux-modules versions that are not on disk, and the oldest of
// them sorts first and reads as the installed version. Only "installed" is
// installed. An empty status means dpkg did not understand the field, in which
// case the line is kept rather than the whole inventory silently vanishing.
func ParseDpkg(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		f := strings.Split(line, "\t")
		if len(f) < 3 {
			continue
		}
		if len(f) > 4 {
			if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
				continue
			}
		}
		p := Package{Name: f[0], Version: f[1], Arch: f[2]}
		if len(f) > 3 && f[3] != "" {
			p.SourceName = f[3]
		} else {
			p.SourceName = p.Name
		}
		pkgs = append(pkgs, p)
	}
	return pkgs
}

// ParseRPM reads tab-separated output of
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
func ParseRPM(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		f := strings.Split(line, "\t")
		if len(f) < 4 {
			continue
		}
		epoch := 0
		// rpm prints "(none)" rather than omitting the field when a package has
		// no epoch. That must become 0, not fail the line.
		if f[1] != "" && f[1] != "(none)" {
			if n, err := strconv.Atoi(f[1]); err == nil {
				epoch = n
			}
		}
		p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
		if len(f) > 4 {
			p.SourceName = srcRPMName(f[4])
		}
		if p.SourceName == "" {
			p.SourceName = p.Name
		}
		pkgs = append(pkgs, p)
	}
	return pkgs
}

// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
// the trailing ".src.rpm" and then the version and release segments, which are
// the last two hyphen-separated fields.
func srcRPMName(s string) string {
	s = strings.TrimSuffix(s, ".src.rpm")
	parts := strings.Split(s, "-")
	if len(parts) <= 2 {
		return s
	}
	return strings.Join(parts[:len(parts)-2], "-")
}

// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
// Alpine has no separate source package, so SourceName mirrors Name.
func ParseAPK(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		name, version := splitAPK(line)
		if name == "" {
			continue
		}
		pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
	}
	return pkgs
}

// splitAPK finds the version boundary from the RIGHT. The version is always the
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
// where scanning from the left is not: package names legitimately contain
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
// found by looking for the first digit.
func splitAPK(s string) (name, version string) {
	last := strings.LastIndex(s, "-")
	if last <= 0 {
		return "", ""
	}
	prev := strings.LastIndex(s[:last], "-")
	if prev <= 0 {
		return "", ""
	}
	return s[:prev], s[prev+1:]
}

// Hash fingerprints a package set so an unchanged set never has to be sent.
//
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
// and an ordering-sensitive hash would resend the full ~150KB list every hour
// for no reason — a cost visible only as traffic.
func Hash(pkgs []Package) string {
	lines := make([]string, 0, len(pkgs))
	for _, p := range pkgs {
		lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
	}
	sort.Strings(lines)
	h := sha256.New()
	for _, l := range lines {
		h.Write([]byte(l))
		h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil))
}
