// Command driverpilot parses Windows .inf driver package files, builds
// inventories of a driver folder, and diffs two inventories to show exactly
// which driver packages were added, removed, upgraded or downgraded.
//
// It reads driver PACKAGE FILES only. It never talks to a live Windows driver
// store and never installs, removes or rolls back anything.
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode/utf16"
	"unicode/utf8"
)

const (
	toolName    = "driverpilot"
	toolVersion = "1.0.0"
	toolLine    = "DriverPilot " + toolVersion + " - Driver Safety Center"
)

// Exit codes.
const (
	exitOK       = 0
	exitUsage    = 1
	exitFindings = 2
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool line).
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

// Model is one device line inside a models section of an .inf.
type Model struct {
	Description    string   `json:"description"`
	Section        string   `json:"section"`
	InstallSection string   `json:"installSection"`
	HardwareIDs    []string `json:"hardwareIds"`
}

// ManufacturerEntry is one line of the [Manufacturer] section.
type ManufacturerEntry struct {
	Name           string   `json:"name"`
	BaseSection    string   `json:"baseSection"`
	TargetSections []string `json:"targetSections"`
}

// Package is everything DriverPilot extracts from a single .inf file.
type Package struct {
	File             string              `json:"file"`
	Encoding         string              `json:"encoding"`
	SizeBytes        int64               `json:"sizeBytes"`
	Provider         string              `json:"provider"`
	Class            string              `json:"class"`
	ClassGUID        string              `json:"classGuid"`
	DriverVerDate    string              `json:"driverVerDate"`
	DriverVerVersion string              `json:"driverVerVersion"`
	CatalogFile      string              `json:"catalogFile"`
	Manufacturers    []ManufacturerEntry `json:"manufacturers"`
	Models           []Model             `json:"models"`
	HardwareIDs      []string            `json:"hardwareIds"`
}

// Failure records one .inf that could not be parsed.
type Failure struct {
	File  string `json:"file"`
	Error string `json:"error"`
}

// Inventory is the on-disk snapshot format written by `inventory --out`.
type Inventory struct {
	Tool      string    `json:"tool"`
	Version   string    `json:"version"`
	Format    int       `json:"format"`
	Generated string    `json:"generated"`
	Root      string    `json:"root"`
	Recursive bool      `json:"recursive"`
	Packages  []Package `json:"packages"`
	Failures  []Failure `json:"failures"`
}

// ---------------------------------------------------------------------------
// Decoding: UTF-16LE/BE with BOM, UTF-8 with/without BOM, ANSI fallback.
// ---------------------------------------------------------------------------

func decodeUTF16(b []byte, bigEndian bool) (string, error) {
	if len(b)%2 != 0 {
		return "", errors.New("truncated UTF-16 data (odd number of bytes)")
	}
	u := make([]uint16, len(b)/2)
	for i := 0; i < len(u); i++ {
		if bigEndian {
			u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1])
		} else {
			u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
		}
	}
	return string(utf16.Decode(u)), nil
}

// looksLikeUTF16LE guesses BOM-less UTF-16LE: ASCII text in UTF-16LE has a
// zero byte in every odd position.
func looksLikeUTF16LE(b []byte) bool {
	if len(b) < 8 || len(b)%2 != 0 {
		return false
	}
	n := len(b)
	if n > 512 {
		n = 512
	}
	zeroOdd, pairs := 0, 0
	for i := 0; i+1 < n; i += 2 {
		pairs++
		if b[i+1] == 0 && b[i] != 0 {
			zeroOdd++
		}
	}
	return pairs > 0 && zeroOdd*10 >= pairs*8
}

// decodeText turns raw file bytes into a string plus a human encoding label.
func decodeText(raw []byte) (string, string, error) {
	switch {
	case len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF:
		return string(raw[3:]), "UTF-8 (BOM)", nil
	case len(raw) >= 2 && raw[0] == 0xFF && raw[1] == 0xFE:
		s, err := decodeUTF16(raw[2:], false)
		return s, "UTF-16LE (BOM)", err
	case len(raw) >= 2 && raw[0] == 0xFE && raw[1] == 0xFF:
		s, err := decodeUTF16(raw[2:], true)
		return s, "UTF-16BE (BOM)", err
	}
	if looksLikeUTF16LE(raw) {
		s, err := decodeUTF16(raw, false)
		return s, "UTF-16LE (no BOM)", err
	}
	if utf8.Valid(raw) {
		return string(raw), "UTF-8", nil
	}
	// Legacy single-byte code page: map each byte to the matching rune so a
	// stray 0xE9 does not make the whole package unreadable.
	var sb strings.Builder
	sb.Grow(len(raw))
	for _, c := range raw {
		sb.WriteRune(rune(c))
	}
	return sb.String(), "ANSI (single byte)", nil
}

// looksBinary reports whether decoded text is really binary junk.
func looksBinary(s string) bool {
	if strings.ContainsRune(s, 0) {
		return true
	}
	bad, total := 0, 0
	for _, r := range s {
		total++
		if r == '\n' || r == '\r' || r == '\t' {
			continue
		}
		if r == utf8.RuneError || (r < 0x20) || r == 0x7f {
			bad++
		}
	}
	return total > 0 && bad*100 > total*2
}

// ---------------------------------------------------------------------------
// INF lexing: sections, key=value pairs, comments, continuations.
// ---------------------------------------------------------------------------

type kv struct {
	Key   string
	Value string
	Line  int
}

type infFile struct {
	Path     string
	Encoding string
	Size     int64
	names    []string // section names in original case, in file order
	sections map[string][]kv
}

func (f *infFile) section(name string) ([]kv, bool) {
	s, ok := f.sections[strings.ToLower(name)]
	return s, ok
}

// stripComment removes a ';' comment, honouring double-quoted strings.
func stripComment(line string) string {
	inQuote := false
	for i, r := range line {
		switch r {
		case '"':
			inQuote = !inQuote
		case ';':
			if !inQuote {
				return line[:i]
			}
		}
	}
	return line
}

// splitTopLevel splits on commas outside double quotes.
func splitTopLevel(s string) []string {
	var out []string
	inQuote := false
	start := 0
	for i, r := range s {
		switch r {
		case '"':
			inQuote = !inQuote
		case ',':
			if !inQuote {
				out = append(out, s[start:i])
				start = i + 1
			}
		}
	}
	out = append(out, s[start:])
	return out
}

func unquote(s string) string {
	s = strings.TrimSpace(s)
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		s = s[1 : len(s)-1]
	}
	return strings.TrimSpace(s)
}

// lexINF splits decoded text into sections. It returns an error for structural
// damage (unterminated section header, content outside any section).
func lexINF(text string) (*infFile, error) {
	f := &infFile{sections: map[string][]kv{}}
	raw := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")

	// Join line continuations: a trailing backslash preceded by a comma or
	// whitespace continues onto the next line. Requiring that prefix keeps
	// hardware IDs such as PCI\VEN_8086 from being misread.
	type physLine struct {
		text string
		num  int
	}
	var lines []physLine
	for i := 0; i < len(raw); i++ {
		cur := stripComment(raw[i])
		num := i + 1
		for strings.HasSuffix(strings.TrimRight(cur, " \t"), "\\") && i+1 < len(raw) {
			t := strings.TrimRight(cur, " \t")
			prev := byte(' ')
			if len(t) >= 2 {
				prev = t[len(t)-2]
			}
			if prev != ',' && prev != ' ' && prev != '\t' && prev != '=' {
				break
			}
			i++
			cur = t[:len(t)-1] + " " + strings.TrimSpace(stripComment(raw[i]))
		}
		lines = append(lines, physLine{cur, num})
	}

	current := ""
	for _, pl := range lines {
		line := strings.TrimSpace(pl.text)
		if line == "" {
			continue
		}
		if strings.HasPrefix(line, "[") {
			end := strings.Index(line, "]")
			if end < 0 {
				return nil, fmt.Errorf("line %d: unterminated section header %q", pl.num, line)
			}
			name := strings.TrimSpace(line[1:end])
			if name == "" {
				return nil, fmt.Errorf("line %d: empty section name", pl.num)
			}
			lower := strings.ToLower(name)
			if _, seen := f.sections[lower]; !seen {
				f.names = append(f.names, name)
				f.sections[lower] = nil
			}
			current = lower
			continue
		}
		if current == "" {
			return nil, fmt.Errorf("line %d: content outside any section: %q", pl.num, trunc(line, 48))
		}
		if eq := strings.Index(line, "="); eq >= 0 {
			f.sections[current] = append(f.sections[current], kv{
				Key:   strings.TrimSpace(line[:eq]),
				Value: strings.TrimSpace(line[eq+1:]),
				Line:  pl.num,
			})
			continue
		}
		// Valueless list entry (legal in sections such as [SourceDisksNames]).
		f.sections[current] = append(f.sections[current], kv{Value: line, Line: pl.num})
	}
	if len(f.names) == 0 {
		return nil, errors.New("no INF sections found")
	}
	return f, nil
}

func trunc(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "..."
}

// ---------------------------------------------------------------------------
// INF semantics
// ---------------------------------------------------------------------------

// lookup finds a key in a section, case-insensitively.
func (f *infFile) lookup(section, key string) (string, bool) {
	entries, ok := f.section(section)
	if !ok {
		return "", false
	}
	want := strings.ToLower(key)
	for _, e := range entries {
		if strings.ToLower(e.Key) == want {
			return e.Value, true
		}
	}
	return "", false
}

// lookupPrefix finds the first key whose lowercase form starts with prefix,
// which is how decorated keys such as CatalogFile.NTamd64 are picked up.
func (f *infFile) lookupPrefix(section, prefix string) (string, bool) {
	entries, ok := f.section(section)
	if !ok {
		return "", false
	}
	want := strings.ToLower(prefix)
	for _, e := range entries {
		if strings.HasPrefix(strings.ToLower(e.Key), want) && e.Value != "" {
			return e.Value, true
		}
	}
	return "", false
}

// resolve expands %Token% references using the [Strings] section.
func (f *infFile) resolve(s string) string {
	s = unquote(s)
	if !strings.Contains(s, "%") {
		return s
	}
	var out strings.Builder
	i := 0
	for i < len(s) {
		if s[i] != '%' {
			out.WriteByte(s[i])
			i++
			continue
		}
		end := strings.IndexByte(s[i+1:], '%')
		if end < 0 {
			out.WriteString(s[i:])
			break
		}
		token := s[i+1 : i+1+end]
		if token == "" { // %% is a literal percent sign
			out.WriteByte('%')
			i += 2
			continue
		}
		if v, ok := f.stringToken(token); ok {
			out.WriteString(v)
		} else {
			out.WriteString("%" + token + "%")
		}
		i += end + 2
	}
	return strings.TrimSpace(out.String())
}

// stringToken searches [Strings] and any localised [Strings.xxxx] section.
func (f *infFile) stringToken(token string) (string, bool) {
	if v, ok := f.lookup("Strings", token); ok {
		return unquote(v), true
	}
	for _, name := range f.names {
		if strings.HasPrefix(strings.ToLower(name), "strings.") {
			if v, ok := f.lookup(name, token); ok {
				return unquote(v), true
			}
		}
	}
	return "", false
}

// parsePackage reads and interprets a single .inf file.
func parsePackage(path string) (*Package, error) {
	st, err := os.Stat(path)
	if err != nil {
		return nil, err
	}
	if st.IsDir() {
		return nil, errors.New("is a directory, not an .inf file")
	}
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(raw) == 0 {
		return nil, errors.New("file is empty (0 bytes)")
	}
	text, enc, err := decodeText(raw)
	if err != nil {
		return nil, err
	}
	if looksBinary(text) {
		return nil, fmt.Errorf("not a text .inf file: %s content looks binary", enc)
	}
	f, err := lexINF(text)
	if err != nil {
		return nil, err
	}
	f.Path = path
	f.Encoding = enc
	f.Size = st.Size()

	if _, ok := f.section("Version"); !ok {
		return nil, errors.New("missing [Version] section (not a driver package .inf)")
	}

	p := &Package{
		File:      filepath.ToSlash(path),
		Encoding:  enc,
		SizeBytes: st.Size(),
	}
	if v, ok := f.lookup("Version", "Provider"); ok {
		p.Provider = f.resolve(v)
	}
	if v, ok := f.lookup("Version", "Class"); ok {
		p.Class = f.resolve(v)
	}
	if v, ok := f.lookup("Version", "ClassGUID"); ok {
		p.ClassGUID = f.resolve(v)
	}
	if v, ok := f.lookupPrefix("Version", "CatalogFile"); ok {
		p.CatalogFile = f.resolve(v)
	}
	if v, ok := f.lookupPrefix("Version", "DriverVer"); ok {
		p.DriverVerDate, p.DriverVerVersion = parseDriverVer(f.resolve(v))
	}

	p.Manufacturers, p.Models = parseManufacturers(f)

	seen := map[string]bool{}
	for _, m := range p.Models {
		for _, id := range m.HardwareIDs {
			k := strings.ToUpper(id)
			if !seen[k] {
				seen[k] = true
				p.HardwareIDs = append(p.HardwareIDs, id)
			}
		}
	}
	if p.Manufacturers == nil {
		p.Manufacturers = []ManufacturerEntry{}
	}
	if p.Models == nil {
		p.Models = []Model{}
	}
	if p.HardwareIDs == nil {
		p.HardwareIDs = []string{}
	}
	return p, nil
}

// parseDriverVer splits `09/21/2023,31.0.15.3179` into date and version.
func parseDriverVer(v string) (date, version string) {
	parts := splitTopLevel(v)
	if len(parts) > 0 {
		date = unquote(parts[0])
	}
	if len(parts) > 1 {
		version = unquote(parts[1])
	}
	return date, version
}

// parseManufacturers walks [Manufacturer] into its models sections, honouring
// the `BaseSection, TargetDecoration, ...` form.
func parseManufacturers(f *infFile) ([]ManufacturerEntry, []Model) {
	entries, ok := f.section("Manufacturer")
	if !ok {
		return nil, nil
	}
	var mfgs []ManufacturerEntry
	var models []Model
	visited := map[string]bool{}

	for _, e := range entries {
		if e.Key == "" {
			continue
		}
		fields := splitTopLevel(e.Value)
		base := unquote(fields[0])
		if base == "" {
			continue
		}
		me := ManufacturerEntry{Name: f.resolve(e.Key), BaseSection: base}

		var candidates []string
		for _, d := range fields[1:] {
			d = unquote(d)
			if d == "" {
				continue
			}
			candidates = append(candidates, base+"."+d)
		}
		// The undecorated section is always a legal target as well.
		candidates = append(candidates, base)

		for _, name := range candidates {
			if _, exists := f.section(name); !exists {
				continue
			}
			if !contains(me.TargetSections, name) {
				me.TargetSections = append(me.TargetSections, name)
			}
			if visited[strings.ToLower(name)] {
				continue
			}
			visited[strings.ToLower(name)] = true
			models = append(models, parseModelSection(f, name)...)
		}
		if me.TargetSections == nil {
			me.TargetSections = []string{}
		}
		mfgs = append(mfgs, me)
	}
	return mfgs, models
}

func contains(ss []string, s string) bool {
	for _, x := range ss {
		if x == s {
			return true
		}
	}
	return false
}

// parseModelSection reads `%DeviceDesc% = Install_Section, HWID [, HWID...]`.
func parseModelSection(f *infFile, name string) []Model {
	entries, ok := f.section(name)
	if !ok {
		return nil
	}
	var out []Model
	for _, e := range entries {
		if e.Key == "" {
			continue
		}
		fields := splitTopLevel(e.Value)
		m := Model{
			Description:    f.resolve(e.Key),
			Section:        name,
			InstallSection: unquote(fields[0]),
			HardwareIDs:    []string{},
		}
		for _, id := range fields[1:] {
			id = f.resolve(id)
			if id != "" {
				m.HardwareIDs = append(m.HardwareIDs, id)
			}
		}
		out = append(out, m)
	}
	return out
}

// ---------------------------------------------------------------------------
// Numeric version comparison
// ---------------------------------------------------------------------------

// compareVersions compares dotted versions component by component NUMERICALLY.
// 10.0.1.0 > 9.9.9.9 and 1.2.10 > 1.2.9, both of which a string sort gets
// wrong. Missing trailing components count as zero. Non-numeric components
// fall back to a case-insensitive string compare of that component only.
func compareVersions(a, b string) int {
	as := strings.Split(strings.TrimSpace(a), ".")
	bs := strings.Split(strings.TrimSpace(b), ".")
	n := len(as)
	if len(bs) > n {
		n = len(bs)
	}
	for i := 0; i < n; i++ {
		x, y := "0", "0"
		if i < len(as) {
			x = strings.TrimSpace(as[i])
		}
		if i < len(bs) {
			y = strings.TrimSpace(bs[i])
		}
		if x == "" {
			x = "0"
		}
		if y == "" {
			y = "0"
		}
		xn, xerr := strconv.ParseUint(x, 10, 64)
		yn, yerr := strconv.ParseUint(y, 10, 64)
		if xerr == nil && yerr == nil {
			if xn != yn {
				if xn < yn {
					return -1
				}
				return 1
			}
			continue
		}
		if c := strings.Compare(strings.ToLower(x), strings.ToLower(y)); c != 0 {
			return c
		}
	}
	return 0
}

// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------

type scanResult struct {
	Packages []Package
	Failures []Failure
	Files    []string // every regular file found under root (for catalog checks)
}

func isINF(name string) bool {
	return strings.EqualFold(filepath.Ext(name), ".inf")
}

func scanDir(root string, recursive bool) (*scanResult, error) {
	st, err := os.Stat(root)
	if err != nil {
		return nil, err
	}
	if !st.IsDir() {
		return nil, fmt.Errorf("%s: not a directory", root)
	}
	res := &scanResult{Packages: []Package{}, Failures: []Failure{}}
	var infs []string

	walk := func(path string, d os.DirEntry) {
		if d.IsDir() {
			return
		}
		res.Files = append(res.Files, path)
		if isINF(d.Name()) {
			infs = append(infs, path)
		}
	}

	if recursive {
		err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
			if err != nil {
				res.Failures = append(res.Failures, Failure{File: filepath.ToSlash(path), Error: err.Error()})
				return nil
			}
			walk(path, d)
			return nil
		})
		if err != nil {
			return nil, err
		}
	} else {
		ents, err := os.ReadDir(root)
		if err != nil {
			return nil, err
		}
		for _, d := range ents {
			walk(filepath.Join(root, d.Name()), d)
		}
	}

	sort.Strings(infs)
	for _, path := range infs {
		p, err := parsePackage(path)
		if err != nil {
			res.Failures = append(res.Failures, Failure{File: filepath.ToSlash(path), Error: err.Error()})
			continue
		}
		res.Packages = append(res.Packages, *p)
	}
	return res, nil
}

// ---------------------------------------------------------------------------
// Diff
// ---------------------------------------------------------------------------

type diffEntry struct {
	Key        string `json:"key"`
	HardwareID string `json:"hardwareId"`
	Provider   string `json:"provider"`
	Class      string `json:"class"`
	File       string `json:"file"`
	Version    string `json:"driverVerVersion"`
	Date       string `json:"driverVerDate"`
}

type diffChange struct {
	Key           string `json:"key"`
	HardwareID    string `json:"hardwareId"`
	Provider      string `json:"provider"`
	Class         string `json:"class"`
	Direction     string `json:"direction"`
	BeforeFile    string `json:"beforeFile"`
	AfterFile     string `json:"afterFile"`
	BeforeVersion string `json:"beforeVersion"`
	AfterVersion  string `json:"afterVersion"`
	BeforeDate    string `json:"beforeDate"`
	AfterDate     string `json:"afterDate"`
}

type diffReport struct {
	Tool           string       `json:"tool"`
	Command        string       `json:"command"`
	BeforeFile     string       `json:"beforeFile"`
	AfterFile      string       `json:"afterFile"`
	BeforeEntries  int          `json:"beforeEntries"`
	AfterEntries   int          `json:"afterEntries"`
	Added          []diffEntry  `json:"added"`
	Removed        []diffEntry  `json:"removed"`
	Changed        []diffChange `json:"changed"`
	UnchangedCount int          `json:"unchangedCount"`
}

// entriesOf flattens an inventory into one comparable entry per
// provider+class+hardware ID.
func entriesOf(inv *Inventory) map[string]diffEntry {
	out := map[string]diffEntry{}
	for _, p := range inv.Packages {
		ids := p.HardwareIDs
		if len(ids) == 0 {
			ids = []string{"(no hardware ids) " + filepath.Base(p.File)}
		}
		for _, id := range ids {
			key := strings.ToLower(p.Provider) + "|" + strings.ToLower(p.Class) + "|" + strings.ToUpper(id)
			if _, dup := out[key]; dup {
				continue
			}
			out[key] = diffEntry{
				Key:        key,
				HardwareID: id,
				Provider:   p.Provider,
				Class:      p.Class,
				File:       filepath.Base(p.File),
				Version:    p.DriverVerVersion,
				Date:       p.DriverVerDate,
			}
		}
	}
	return out
}

func buildDiff(beforePath, afterPath string, before, after *Inventory) *diffReport {
	b := entriesOf(before)
	a := entriesOf(after)
	rep := &diffReport{
		Tool:          toolName,
		Command:       "diff",
		BeforeFile:    beforePath,
		AfterFile:     afterPath,
		BeforeEntries: len(b),
		AfterEntries:  len(a),
		Added:         []diffEntry{},
		Removed:       []diffEntry{},
		Changed:       []diffChange{},
	}
	for k, ae := range a {
		be, ok := b[k]
		if !ok {
			rep.Added = append(rep.Added, ae)
			continue
		}
		if be.Version == ae.Version && be.Date == ae.Date {
			rep.UnchangedCount++
			continue
		}
		dir := "REDATED"
		switch c := compareVersions(be.Version, ae.Version); {
		case c < 0:
			dir = "UPGRADED"
		case c > 0:
			dir = "DOWNGRADED"
		}
		rep.Changed = append(rep.Changed, diffChange{
			Key: k, HardwareID: ae.HardwareID, Provider: ae.Provider, Class: ae.Class,
			Direction:  dir,
			BeforeFile: be.File, AfterFile: ae.File,
			BeforeVersion: be.Version, AfterVersion: ae.Version,
			BeforeDate: be.Date, AfterDate: ae.Date,
		})
	}
	for k, be := range b {
		if _, ok := a[k]; !ok {
			rep.Removed = append(rep.Removed, be)
		}
	}
	sort.Slice(rep.Added, func(i, j int) bool { return rep.Added[i].Key < rep.Added[j].Key })
	sort.Slice(rep.Removed, func(i, j int) bool { return rep.Removed[i].Key < rep.Removed[j].Key })
	sort.Slice(rep.Changed, func(i, j int) bool { return rep.Changed[i].Key < rep.Changed[j].Key })
	return rep
}

// ---------------------------------------------------------------------------
// Check
// ---------------------------------------------------------------------------

type dupClaim struct {
	HardwareID string   `json:"hardwareId"`
	Files      []string `json:"files"`
}

type checkReport struct {
	Tool                 string     `json:"tool"`
	Command              string     `json:"command"`
	Root                 string     `json:"root"`
	PackagesOK           int        `json:"packagesOk"`
	ParseFailures        []Failure  `json:"parseFailures"`
	MissingDriverVer     []string   `json:"missingDriverVer"`
	MissingCatalog       []Failure  `json:"missingCatalog"`
	DuplicateHardwareIDs []dupClaim `json:"duplicateHardwareIds"`
	TotalFindings        int        `json:"totalFindings"`
}

func runCheckReport(root string, res *scanResult) *checkReport {
	rep := &checkReport{
		Tool: toolName, Command: "check", Root: filepath.ToSlash(root),
		PackagesOK:           len(res.Packages),
		ParseFailures:        res.Failures,
		MissingDriverVer:     []string{},
		MissingCatalog:       []Failure{},
		DuplicateHardwareIDs: []dupClaim{},
	}
	if rep.ParseFailures == nil {
		rep.ParseFailures = []Failure{}
	}

	// Index every file present under the root by lowercase base name.
	present := map[string]bool{}
	for _, f := range res.Files {
		present[strings.ToLower(filepath.Base(f))] = true
	}

	claims := map[string][]string{}
	var order []string
	for _, p := range res.Packages {
		base := filepath.Base(p.File)
		if strings.TrimSpace(p.DriverVerVersion) == "" && strings.TrimSpace(p.DriverVerDate) == "" {
			rep.MissingDriverVer = append(rep.MissingDriverVer, base)
		}
		if cat := strings.TrimSpace(p.CatalogFile); cat != "" {
			if !present[strings.ToLower(filepath.Base(cat))] {
				rep.MissingCatalog = append(rep.MissingCatalog, Failure{
					File:  base,
					Error: "references CatalogFile " + cat + " which is not present in the folder",
				})
			}
		}
		for _, id := range p.HardwareIDs {
			k := strings.ToUpper(id)
			if _, seen := claims[k]; !seen {
				order = append(order, k)
			}
			if !contains(claims[k], base) {
				claims[k] = append(claims[k], base)
			}
		}
	}
	sort.Strings(order)
	for _, k := range order {
		if len(claims[k]) > 1 {
			files := append([]string{}, claims[k]...)
			sort.Strings(files)
			rep.DuplicateHardwareIDs = append(rep.DuplicateHardwareIDs, dupClaim{HardwareID: k, Files: files})
		}
	}
	rep.TotalFindings = len(rep.ParseFailures) + len(rep.MissingDriverVer) +
		len(rep.MissingCatalog) + len(rep.DuplicateHardwareIDs)
	return rep
}

// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------

func emitJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	return enc.Encode(v)
}

func fatalf(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", toolName, fmt.Sprintf(format, args...))
	os.Exit(exitUsage)
}

func usage() {
	fmt.Fprint(os.Stderr, helpText)
	os.Exit(exitUsage)
}

const helpText = toolLine + `

Parse Windows .inf driver packages, inventory a driver folder, and diff two
inventories to see exactly what changed. Reads package FILES only - it never
touches the live Windows driver store and installs nothing.

USAGE
  driverpilot <command> [options]

COMMANDS
  parse <file.inf> [--json]
        Parse one .inf and report Provider, Class, ClassGuid, DriverVer date
        and version, CatalogFile, [Manufacturer] entries and every hardware ID.

  inventory <dir> [--recursive] --out <inventory.json> [--json]
        Parse every .inf in a directory into one inventory file. Files that
        fail to parse are reported individually; the scan always completes.

  diff --before <a.json> --after <b.json> [--json]
        Compare two inventories: ADDED, REMOVED and UPGRADED/DOWNGRADED
        packages. Version comparison is numeric per component, so 10.0.1.0 is
        correctly newer than 9.9.9.9.

  check <dir> [--json]
        Quality report: unparseable .inf files, packages with no DriverVer,
        CatalogFile referenced but absent from the folder, and hardware IDs
        claimed by more than one package.

  help, -h, --help      Show this help.
  version               Print version.

EXIT CODES
  0  success, no findings
  1  usage error or unreadable input
  2  check found at least one finding

EXAMPLES
  driverpilot parse ./drivers/acmenet.inf
  driverpilot inventory ./drivers --recursive --out before.json
  driverpilot diff --before before.json --after after.json --json
  driverpilot check ./drivers
`

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

// takeFlags pulls known flags out of args and returns the positional rest.
func takeFlags(args []string, valueFlags map[string]bool, boolFlags map[string]bool) (map[string]string, []string, error) {
	args = reorderFlags(args, valueFlags)
	vals := map[string]string{}
	var rest []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		if !strings.HasPrefix(a, "-") {
			rest = append(rest, a)
			continue
		}
		name := strings.ToLower(strings.TrimLeft(a, "-"))
		if eq := strings.Index(name, "="); eq >= 0 {
			key := name[:eq]
			if !valueFlags[key] {
				return nil, nil, fmt.Errorf("unknown flag: %s", a)
			}
			vals[key] = name[eq+1:]
			continue
		}
		switch {
		case boolFlags[name]:
			vals[name] = "true"
		case valueFlags[name]:
			if i+1 >= len(args) {
				return nil, nil, fmt.Errorf("flag --%s needs a value", name)
			}
			i++
			vals[name] = args[i]
		default:
			return nil, nil, fmt.Errorf("unknown flag: %s", a)
		}
	}
	return vals, rest, nil
}

func isHelp(a string) bool {
	switch strings.ToLower(a) {
	case "-h", "--help", "help", "-help":
		return true
	}
	return false
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
	}
	for _, a := range args {
		if isHelp(a) {
			fmt.Print(helpText)
			os.Exit(exitOK)
		}
	}
	cmd := strings.ToLower(args[0])
	rest := args[1:]

	switch cmd {
	case "parse":
		cmdParse(rest)
	case "inventory":
		cmdInventory(rest)
	case "diff":
		cmdDiff(rest)
	case "check":
		cmdCheck(rest)
	case "version", "--version", "-v":
		fmt.Println(toolLine)
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", toolName, args[0])
		usage()
	}
}

func cmdParse(args []string) {
	vals, rest, err := takeFlags(args, map[string]bool{}, map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: parse needs exactly one .inf file\n\n", toolName)
		usage()
	}
	p, err := parsePackage(rest[0])
	if err != nil {
		fatalf("%s: %v", rest[0], err)
	}
	if vals["json"] == "true" {
		if err := emitJSON(struct {
			Tool    string   `json:"tool"`
			Command string   `json:"command"`
			Package *Package `json:"package"`
		}{toolName, "parse", p}); err != nil {
			fatalf("writing json: %v", err)
		}
		return
	}
	printPackage(p)
}

func printPackage(p *Package) {
	fmt.Printf("File:        %s (%s, %s)\n", p.File, humanBytes(p.SizeBytes), p.Encoding)
	fmt.Printf("Provider:    %s\n", orNone(p.Provider))
	fmt.Printf("Class:       %s\n", orNone(p.Class))
	fmt.Printf("ClassGuid:   %s\n", orNone(p.ClassGUID))
	fmt.Printf("DriverVer:   date=%s version=%s\n", orNone(p.DriverVerDate), orNone(p.DriverVerVersion))
	fmt.Printf("CatalogFile: %s\n", orNone(p.CatalogFile))

	fmt.Printf("\nManufacturer entries (%d):\n", len(p.Manufacturers))
	for _, m := range p.Manufacturers {
		fmt.Printf("  %s -> base=%s targets=%s\n", m.Name, m.BaseSection, joinOrNone(m.TargetSections))
	}
	fmt.Printf("\nModels (%d):\n", len(p.Models))
	for _, m := range p.Models {
		fmt.Printf("  [%s] %s\n", m.Section, m.Description)
		fmt.Printf("      install: %s\n", orNone(m.InstallSection))
		for _, id := range m.HardwareIDs {
			fmt.Printf("      hwid:    %s\n", id)
		}
	}
	fmt.Printf("\nHardware IDs (%d unique):\n", len(p.HardwareIDs))
	for _, id := range p.HardwareIDs {
		fmt.Printf("  %s\n", id)
	}
}

func orNone(s string) string {
	if strings.TrimSpace(s) == "" {
		return "(none)"
	}
	return s
}

func joinOrNone(ss []string) string {
	if len(ss) == 0 {
		return "(none)"
	}
	return strings.Join(ss, ", ")
}

func cmdInventory(args []string) {
	vals, rest, err := takeFlags(args,
		map[string]bool{"out": true},
		map[string]bool{"json": true, "recursive": true, "r": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: inventory needs exactly one directory\n\n", toolName)
		usage()
	}
	out := vals["out"]
	if strings.TrimSpace(out) == "" {
		fmt.Fprintf(os.Stderr, "%s: inventory requires --out <inventory.json>\n\n", toolName)
		usage()
	}
	recursive := vals["recursive"] == "true" || vals["r"] == "true"

	res, err := scanDir(rest[0], recursive)
	if err != nil {
		fatalf("%v", err)
	}
	inv := &Inventory{
		Tool:      toolName,
		Version:   toolVersion,
		Format:    1,
		Generated: time.Now().UTC().Format(time.RFC3339),
		Root:      filepath.ToSlash(rest[0]),
		Recursive: recursive,
		Packages:  res.Packages,
		Failures:  res.Failures,
	}
	data, err := json.MarshalIndent(inv, "", "  ")
	if err != nil {
		fatalf("encoding inventory: %v", err)
	}
	data = append(data, '\n')
	if err := os.WriteFile(out, data, 0o644); err != nil {
		fatalf("writing %s: %v", out, err)
	}

	if vals["json"] == "true" {
		if err := emitJSON(struct {
			Tool      string    `json:"tool"`
			Command   string    `json:"command"`
			Out       string    `json:"out"`
			Root      string    `json:"root"`
			Recursive bool      `json:"recursive"`
			Parsed    int       `json:"parsed"`
			Failed    int       `json:"failed"`
			Packages  []Package `json:"packages"`
			Failures  []Failure `json:"failures"`
		}{toolName, "inventory", out, inv.Root, recursive,
			len(inv.Packages), len(inv.Failures), inv.Packages, inv.Failures}); err != nil {
			fatalf("writing json: %v", err)
		}
		return
	}

	fmt.Printf("Inventory of %s (recursive=%v)\n", inv.Root, recursive)
	fmt.Printf("Parsed %d package(s), %d failure(s), wrote %s (%s)\n\n",
		len(inv.Packages), len(inv.Failures), out, humanBytes(int64(len(data))))
	for _, p := range inv.Packages {
		fmt.Printf("  OK    %-28s %-20s %-10s %-14s %d hwid(s)\n",
			filepath.Base(p.File), trunc(orNone(p.Provider), 20),
			orNone(p.Class), orNone(p.DriverVerVersion), len(p.HardwareIDs))
	}
	for _, f := range inv.Failures {
		fmt.Printf("  FAIL  %-28s %s\n", filepath.Base(f.File), f.Error)
	}
	if len(inv.Packages) == 0 && len(inv.Failures) == 0 {
		fmt.Println("  (no .inf files found)")
	}
}

func loadInventory(path string) (*Inventory, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(data) == 0 {
		return nil, fmt.Errorf("%s: file is empty", path)
	}
	var inv Inventory
	if err := json.Unmarshal(data, &inv); err != nil {
		return nil, fmt.Errorf("%s: not a valid inventory json: %v", path, err)
	}
	if inv.Tool != toolName {
		return nil, fmt.Errorf("%s: not a %s inventory (tool=%q)", path, toolName, inv.Tool)
	}
	return &inv, nil
}

func cmdDiff(args []string) {
	vals, rest, err := takeFlags(args,
		map[string]bool{"before": true, "after": true},
		map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 0 {
		fmt.Fprintf(os.Stderr, "%s: diff takes no positional arguments (got %q)\n\n", toolName, rest[0])
		usage()
	}
	if vals["before"] == "" || vals["after"] == "" {
		fmt.Fprintf(os.Stderr, "%s: diff requires --before <a.json> and --after <b.json>\n\n", toolName)
		usage()
	}
	before, err := loadInventory(vals["before"])
	if err != nil {
		fatalf("%v", err)
	}
	after, err := loadInventory(vals["after"])
	if err != nil {
		fatalf("%v", err)
	}
	rep := buildDiff(vals["before"], vals["after"], before, after)

	if vals["json"] == "true" {
		if err := emitJSON(rep); err != nil {
			fatalf("writing json: %v", err)
		}
		return
	}
	fmt.Printf("Before: %s  (%d package(s), %d device entr(ies))\n",
		rep.BeforeFile, len(before.Packages), rep.BeforeEntries)
	fmt.Printf("After:  %s  (%d package(s), %d device entr(ies))\n\n",
		rep.AfterFile, len(after.Packages), rep.AfterEntries)

	fmt.Printf("ADDED (%d):\n", len(rep.Added))
	for _, e := range rep.Added {
		fmt.Printf("  + %s  [%s / %s]  %s (%s)  %s\n",
			e.HardwareID, orNone(e.Provider), orNone(e.Class),
			orNone(e.Version), orNone(e.Date), e.File)
	}
	if len(rep.Added) == 0 {
		fmt.Println("  (none)")
	}
	fmt.Printf("\nREMOVED (%d):\n", len(rep.Removed))
	for _, e := range rep.Removed {
		fmt.Printf("  - %s  [%s / %s]  %s (%s)  %s\n",
			e.HardwareID, orNone(e.Provider), orNone(e.Class),
			orNone(e.Version), orNone(e.Date), e.File)
	}
	if len(rep.Removed) == 0 {
		fmt.Println("  (none)")
	}
	fmt.Printf("\nVERSION CHANGES (%d):\n", len(rep.Changed))
	for _, c := range rep.Changed {
		fmt.Printf("  %-10s %s  [%s / %s]\n", c.Direction, c.HardwareID,
			orNone(c.Provider), orNone(c.Class))
		fmt.Printf("             %s (%s)  ->  %s (%s)\n",
			orNone(c.BeforeVersion), orNone(c.BeforeDate),
			orNone(c.AfterVersion), orNone(c.AfterDate))
	}
	if len(rep.Changed) == 0 {
		fmt.Println("  (none)")
	}
	fmt.Printf("\nUNCHANGED: %d\n", rep.UnchangedCount)
}

func cmdCheck(args []string) {
	if runCheck(args) > 0 {
		os.Exit(exitFindings)
	}
}

// runCheck does the whole of "check" and returns how many findings it
// reported, instead of exiting on the spot.
//
// The exit code is the caller's business, and one caller cannot afford it: the
// guided session that runs when the program is double-clicked has to print
// "Press Enter to close this window" after the report, and os.Exit here would
// close the window before the reader saw a word of it. From the command line
// the behaviour is unchanged — cmdCheck still exits 2 when there are findings.
func runCheck(args []string) int {
	vals, rest, err := takeFlags(args, map[string]bool{}, map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: check needs exactly one directory\n\n", toolName)
		usage()
	}
	res, err := scanDir(rest[0], true)
	if err != nil {
		fatalf("%v", err)
	}
	rep := runCheckReport(rest[0], res)

	if vals["json"] == "true" {
		if err := emitJSON(rep); err != nil {
			fatalf("writing json: %v", err)
		}
	} else {
		fmt.Printf("Quality report for %s\n", rep.Root)
		fmt.Printf("Packages parsed OK: %d   Findings: %d\n\n", rep.PackagesOK, rep.TotalFindings)

		fmt.Printf("Unparseable .inf files (%d):\n", len(rep.ParseFailures))
		for _, f := range rep.ParseFailures {
			fmt.Printf("  ! %s: %s\n", filepath.Base(f.File), f.Error)
		}
		if len(rep.ParseFailures) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nPackages missing DriverVer (%d):\n", len(rep.MissingDriverVer))
		for _, f := range rep.MissingDriverVer {
			fmt.Printf("  ! %s\n", f)
		}
		if len(rep.MissingDriverVer) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nCatalogFile referenced but absent (%d):\n", len(rep.MissingCatalog))
		for _, f := range rep.MissingCatalog {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
		if len(rep.MissingCatalog) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nHardware IDs claimed by more than one package (%d):\n", len(rep.DuplicateHardwareIDs))
		for _, d := range rep.DuplicateHardwareIDs {
			fmt.Printf("  ! %s claimed by: %s\n", d.HardwareID, strings.Join(d.Files, ", "))
		}
		if len(rep.DuplicateHardwareIDs) == 0 {
			fmt.Println("  (none)")
		}
	}
	return rep.TotalFindings
}
