// Command traceguard reports fleet-wide privacy posture.
//
// Each machine produces a scan report; traceguard ingests those reports,
// evaluates every machine against a written policy, and prints a compliance
// rollup for the whole fleet. It never modifies or deletes anything.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"math"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"text/tabwriter"
	"time"
)

const (
	toolName      = "traceguard"
	toolVersion   = "1.0.0"
	formatVersion = 1
)

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

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])
}

// ---------------------------------------------------------------------------
// Built-in trace catalog.
// ---------------------------------------------------------------------------

type catalogEntry struct {
	category string
	desc     string
	patterns []string
}

// catalog lists the trace categories traceguard knows about. Every pattern is
// a slash-separated glob resolved relative to the scan root (--home), so the
// scanner is fully testable against synthetic home trees.
var catalog = []catalogEntry{
	{
		category: "browser_cache",
		desc:     "cached page assets written by browsers",
		patterns: []string{
			".cache/mozilla/firefox",
			".cache/google-chrome",
			".cache/chromium",
			".cache/BraveSoftware",
			".config/google-chrome/*/Cache",
			".config/chromium/*/Cache",
			"Library/Caches/Google/Chrome",
			"Library/Caches/Firefox",
			"AppData/Local/Google/Chrome/User Data/*/Cache",
			"AppData/Local/Microsoft/Edge/User Data/*/Cache",
		},
	},
	{
		category: "cookies",
		desc:     "cookie jars and their write-ahead logs",
		patterns: []string{
			".mozilla/firefox/*/cookies.sqlite",
			".mozilla/firefox/*/cookies.sqlite-wal",
			".config/google-chrome/*/Cookies",
			".config/google-chrome/*/Network/Cookies",
			".config/chromium/*/Cookies",
			"Library/Application Support/Google/Chrome/*/Cookies",
			"AppData/Local/Google/Chrome/User Data/*/Network/Cookies",
		},
	},
	{
		category: "browsing_history",
		desc:     "visited-URL databases",
		patterns: []string{
			".mozilla/firefox/*/places.sqlite",
			".config/google-chrome/*/History",
			".config/chromium/*/History",
			"Library/Application Support/Google/Chrome/*/History",
			"Library/Safari/History.db",
			"AppData/Local/Google/Chrome/User Data/*/History",
		},
	},
	{
		category: "saved_sessions",
		desc:     "restorable tab/session state",
		patterns: []string{
			".mozilla/firefox/*/sessionstore-backups",
			".mozilla/firefox/*/sessionstore.jsonlz4",
			".config/google-chrome/*/Sessions",
			".config/chromium/*/Sessions",
			"AppData/Local/Google/Chrome/User Data/*/Sessions",
		},
	},
	{
		category: "thumbnail_cache",
		desc:     "generated image/document thumbnails",
		patterns: []string{
			".cache/thumbnails",
			".thumbnails",
			"Library/Caches/com.apple.QuickLook.thumbnailcache",
			"AppData/Local/Microsoft/Windows/Explorer",
		},
	},
	{
		category: "recent_documents",
		desc:     "recently-opened document trails",
		patterns: []string{
			".local/share/recently-used.xbel",
			".recently-used.xbel",
			".local/share/RecentDocuments",
			"Library/Application Support/com.apple.sharedfilelist",
			"AppData/Roaming/Microsoft/Windows/Recent",
		},
	},
	{
		category: "crash_dumps",
		desc:     "crash reports and minidumps",
		patterns: []string{
			".cache/crash-reports",
			".config/google-chrome/Crash Reports",
			"Library/Logs/DiagnosticReports",
			"AppData/Local/CrashDumps",
		},
	},
	{
		category: "temp_files",
		desc:     "per-user temporary scratch files",
		patterns: []string{
			".cache/tmp",
			".local/tmp",
			"tmp",
			"AppData/Local/Temp",
		},
	},
}

func knownCategory(name string) bool {
	for _, c := range catalog {
		if c.category == name {
			return true
		}
	}
	return false
}

func categoryNames() []string {
	names := make([]string, 0, len(catalog))
	for _, c := range catalog {
		names = append(names, c.category)
	}
	return names
}

// ---------------------------------------------------------------------------
// Report / policy data model.
// ---------------------------------------------------------------------------

// CategoryReport is one trace category's measurement on one machine.
type CategoryReport struct {
	Category      string `json:"category"`
	Present       bool   `json:"present"`
	Files         int    `json:"files"`
	Bytes         int64  `json:"bytes"`
	OldestAgeDays int    `json:"oldest_age_days"`
	OldestPath    string `json:"oldest_path,omitempty"`
}

// Report is a single machine's privacy scan result.
type Report struct {
	Tool          string           `json:"tool"`
	ToolVersion   string           `json:"tool_version"`
	FormatVersion int              `json:"format_version"`
	Machine       string           `json:"machine"`
	Home          string           `json:"home"`
	ScannedAt     string           `json:"scanned_at"`
	TotalFiles    int              `json:"total_files"`
	TotalBytes    int64            `json:"total_bytes"`
	Categories    []CategoryReport `json:"categories"`

	source string
}

// Rule is a single policy constraint on a single trace category.
type Rule struct {
	ID       string `json:"id,omitempty"`
	Category string `json:"category"`
	Type     string `json:"type"`
	Limit    int64  `json:"limit,omitempty"`
	Severity string `json:"severity"`

	index int
}

// Policy is the written standard the fleet is measured against.
type Policy struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
	Rules       []Rule `json:"rules"`
}

// Violation records one rule broken by one machine.
type Violation struct {
	RuleID   string `json:"rule_id"`
	Category string `json:"category"`
	Type     string `json:"type"`
	Severity string `json:"severity"`
	Limit    int64  `json:"limit"`
	Actual   int64  `json:"actual"`
	Detail   string `json:"detail"`
}

// MachineResult is one machine's verdict under the policy.
type MachineResult struct {
	Machine       string      `json:"machine"`
	Source        string      `json:"source"`
	Verdict       string      `json:"verdict"`
	WorstSeverity string      `json:"worst_severity,omitempty"`
	ExposedBytes  int64       `json:"exposed_bytes"`
	ExposedFiles  int         `json:"exposed_files"`
	Violations    []Violation `json:"violations"`
}

// SkippedReport records a report file that could not be used, and why.
type SkippedReport struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

// TopRule names the rule broken by the most machines.
type TopRule struct {
	RuleID   string `json:"rule_id"`
	Category string `json:"category"`
	Type     string `json:"type"`
	Machines int    `json:"machines"`
}

// Summary is the fleet rollup.
type Summary struct {
	MachinesAudited   int      `json:"machines_audited"`
	Passed            int      `json:"passed"`
	Failed            int      `json:"failed"`
	Skipped           int      `json:"skipped"`
	TotalExposedBytes int64    `json:"total_exposed_bytes"`
	TotalExposedFiles int      `json:"total_exposed_files"`
	TotalViolations   int      `json:"total_violations"`
	MostViolatedRule  *TopRule `json:"most_violated_rule"`
	FleetCompliant    bool     `json:"fleet_compliant"`
}

// AuditResult is the complete machine-readable audit output.
type AuditResult struct {
	Tool           string          `json:"tool"`
	ToolVersion    string          `json:"tool_version"`
	FormatVersion  int             `json:"format_version"`
	PolicyName     string          `json:"policy_name"`
	PolicyPath     string          `json:"policy_path"`
	FailOn         string          `json:"fail_on"`
	ActiveRules    int             `json:"active_rules"`
	AuditedAt      string          `json:"audited_at"`
	PolicyWarnings []string        `json:"policy_warnings"`
	Machines       []MachineResult `json:"machines"`
	Skipped        []SkippedReport `json:"skipped"`
	Summary        Summary         `json:"summary"`
}

// ---------------------------------------------------------------------------
// Severity handling.
// ---------------------------------------------------------------------------

var severityRank = map[string]int{"low": 1, "medium": 2, "high": 3}

func severityOK(s string) bool {
	_, ok := severityRank[s]
	return ok
}

var ruleTypes = []string{"max_age_days", "max_bytes", "max_files", "forbidden"}

func ruleTypeOK(t string) bool {
	for _, k := range ruleTypes {
		if k == t {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------------
// Usage.
// ---------------------------------------------------------------------------

const usageText = `traceguard ` + toolVersion + ` - fleet-wide privacy posture reporting (Techlosoft Privacy Eraser).

USAGE
  traceguard scan   --home <root> --machine <name> [--out <report.json>] [--json]
  traceguard audit  <report.json> [more.json ...] [--dir <reports-dir>]
                    --policy <policy.json> [--fail-on low|medium|high] [--json]
  traceguard policy --example
  traceguard help | -h | --help

COMMANDS
  scan    Measure one machine's privacy traces under --home and emit a report.
          For each built-in category it records file count, total bytes and the
          age in days of the OLDEST artifact. All paths resolve relative to
          --home, so scans are reproducible and testable.

  audit   Ingest one or many machine reports, evaluate each against a written
          policy, and print a per-machine compliance table plus a fleet rollup
          (pass/fail counts, most commonly violated rule, total exposed bytes).

  policy  Print an example policy file to stdout with --example.

SCAN FLAGS
  --home <dir>      Scan root. Required.
  --machine <name>  Machine label recorded in the report. Required.
  --out <file>      Write the JSON report to this file. Use "-" for stdout.
  --json            Print the JSON report to stdout instead of a summary table.

AUDIT FLAGS
  --policy <file>   Policy file to evaluate against. Required.
  --dir <dir>       Also ingest every *.json file in this directory.
  --fail-on <sev>   Lowest severity that fails a machine: low, medium or high.
                    Default: high.
  --json            Emit the full audit result as JSON instead of a table.

POLICY RULE TYPES
  max_age_days   no artifact in the category may be older than <limit> days
  max_bytes      the category must not exceed <limit> bytes
  max_files      the category must not exceed <limit> files
  forbidden      the category must be entirely absent (limit ignored)

RULE SEVERITIES
  low, medium, high

TRACE CATEGORIES
  browser_cache, cookies, browsing_history, saved_sessions,
  thumbnail_cache, recent_documents, crash_dumps, temp_files

EXIT CODES
  0  success; for audit, every machine passed
  1  usage error, unreadable input, or no usable reports
  2  audit completed and at least one machine failed the policy

TraceGuard only REPORTS. It never modifies or deletes anything; remediation is
the job of CleanVault and EraseProof.
`

func usage(w io.Writer) {
	fmt.Fprint(w, usageText)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, toolName+": "+format+"\n", args...)
	os.Exit(1)
}

func failUsage(format string, args ...any) {
	fmt.Fprintf(os.Stderr, toolName+": "+format+"\n\n", args...)
	usage(os.Stderr)
	os.Exit(1)
}

func isHelp(a string) bool {
	return a == "-h" || a == "--help" || a == "help" || a == "-help"
}

// ---------------------------------------------------------------------------
// main.
// ---------------------------------------------------------------------------

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(os.Stderr)
		os.Exit(1)
	}
	if isHelp(args[0]) {
		usage(os.Stdout)
		os.Exit(0)
	}
	switch args[0] {
	case "scan":
		cmdScan(args[1:])
	case "audit":
		cmdAudit(args[1:])
	case "policy":
		cmdPolicy(args[1:])
	default:
		failUsage("unknown command %q", args[0])
	}
}

// newFlagSet builds a flag set that never prints its own usage, so every
// error path funnels through failUsage.
func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fs.Usage = func() {}
	return fs
}

func containsHelp(args []string) bool {
	for _, a := range args {
		if isHelp(a) {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------------
// scan.
// ---------------------------------------------------------------------------

func cmdScan(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{"home": true, "machine": true, "out": true})

	fs := newFlagSet("scan")
	home := fs.String("home", "", "scan root")
	machine := fs.String("machine", "", "machine name")
	out := fs.String("out", "", "report output path")
	asJSON := fs.Bool("json", false, "print JSON report to stdout")
	if err := fs.Parse(args); err != nil {
		failUsage("scan: %v", err)
	}
	if rest := fs.Args(); len(rest) > 0 {
		failUsage("scan: unexpected argument %q", rest[0])
	}
	if *home == "" {
		failUsage("scan: --home is required")
	}
	if *machine == "" {
		failUsage("scan: --machine is required")
	}

	info, err := os.Stat(*home)
	if err != nil {
		fail("scan: cannot read --home %s: %v", *home, err)
	}
	if !info.IsDir() {
		fail("scan: --home %s is not a directory", *home)
	}

	rep, err := scanHome(*home, *machine, time.Now())
	if err != nil {
		fail("scan: %v", err)
	}

	data, err := json.MarshalIndent(rep, "", "  ")
	if err != nil {
		fail("scan: encoding report: %v", err)
	}
	data = append(data, '\n')

	if *out != "" && *out != "-" {
		if dir := filepath.Dir(*out); dir != "" && dir != "." {
			if err := os.MkdirAll(dir, 0o755); err != nil {
				fail("scan: creating %s: %v", dir, err)
			}
		}
		if err := os.WriteFile(*out, data, 0o644); err != nil {
			fail("scan: writing %s: %v", *out, err)
		}
	}
	switch {
	case *asJSON || *out == "-":
		os.Stdout.Write(data)
	default:
		printScanSummary(os.Stdout, rep, *out)
	}
}

// scanHome measures every catalog category beneath root. A file is counted at
// most once, by the first category whose pattern claims it.
func scanHome(root, machine string, now time.Time) (*Report, error) {
	abs, err := filepath.Abs(root)
	if err != nil {
		abs = root
	}
	rep := &Report{
		Tool:          toolName,
		ToolVersion:   toolVersion,
		FormatVersion: formatVersion,
		Machine:       machine,
		Home:          filepath.ToSlash(abs),
		ScannedAt:     now.UTC().Format(time.RFC3339),
	}
	seen := make(map[string]bool)

	for _, entry := range catalog {
		cr := CategoryReport{Category: entry.category}
		var oldestMod time.Time
		var oldestPath string

		for _, pattern := range entry.patterns {
			matches, err := filepath.Glob(filepath.Join(abs, filepath.FromSlash(pattern)))
			if err != nil {
				// Only ErrBadPattern is possible; the catalog is static, so
				// this would be a programming error rather than user input.
				return nil, fmt.Errorf("bad catalog pattern %q: %w", pattern, err)
			}
			sort.Strings(matches)
			for _, m := range matches {
				collect(m, seen, &cr, &oldestMod, &oldestPath, now)
			}
		}

		cr.Present = cr.Files > 0
		if cr.Present {
			cr.OldestAgeDays = ageDays(oldestMod, now)
			if rel, err := filepath.Rel(abs, oldestPath); err == nil {
				cr.OldestPath = filepath.ToSlash(rel)
			} else {
				cr.OldestPath = filepath.ToSlash(oldestPath)
			}
		}
		rep.Categories = append(rep.Categories, cr)
		rep.TotalFiles += cr.Files
		rep.TotalBytes += cr.Bytes
	}
	return rep, nil
}

// collect adds path (a file, or every regular file beneath a directory) to cr.
// Symlinks are never followed and are not counted.
func collect(path string, seen map[string]bool, cr *CategoryReport, oldestMod *time.Time, oldestPath *string, now time.Time) {
	li, err := os.Lstat(path)
	if err != nil {
		return
	}
	if li.Mode()&os.ModeSymlink != 0 {
		return
	}
	if !li.IsDir() {
		if li.Mode().IsRegular() {
			tally(path, li, seen, cr, oldestMod, oldestPath)
		}
		return
	}
	filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			// Unreadable subtree: skip it, keep scanning the rest.
			if d != nil && d.IsDir() {
				return fs.SkipDir
			}
			return nil
		}
		if d.IsDir() || !d.Type().IsRegular() {
			return nil
		}
		fi, err := d.Info()
		if err != nil {
			return nil
		}
		tally(p, fi, seen, cr, oldestMod, oldestPath)
		return nil
	})
}

func tally(path string, fi os.FileInfo, seen map[string]bool, cr *CategoryReport, oldestMod *time.Time, oldestPath *string) {
	key := path
	if abs, err := filepath.Abs(path); err == nil {
		key = abs
	}
	if seen[key] {
		return
	}
	seen[key] = true
	cr.Files++
	cr.Bytes += fi.Size()
	mt := fi.ModTime()
	if oldestMod.IsZero() || mt.Before(*oldestMod) {
		*oldestMod = mt
		*oldestPath = path
	}
}

// ageDays converts a modification time into a whole number of days, rounded to
// the NEAREST day so that clock skew and DST shifts of a few hours do not move
// a "400 days ago" artifact to 399.
func ageDays(mod, now time.Time) int {
	d := now.Sub(mod).Hours() / 24
	if d < 0 {
		return 0
	}
	return int(math.Round(d))
}

func printScanSummary(w io.Writer, rep *Report, outPath string) {
	fmt.Fprintf(w, "TraceGuard scan - machine %q\n", rep.Machine)
	fmt.Fprintf(w, "Home:       %s\n", rep.Home)
	fmt.Fprintf(w, "Scanned at: %s\n\n", rep.ScannedAt)

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "CATEGORY\tFILES\tBYTES\tSIZE\tOLDEST(d)")
	for _, c := range rep.Categories {
		oldest := "-"
		if c.Present {
			oldest = fmt.Sprintf("%d", c.OldestAgeDays)
		}
		fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\n", c.Category, c.Files, c.Bytes, humanBytes(c.Bytes), oldest)
	}
	fmt.Fprintf(tw, "TOTAL\t%d\t%d\t%s\t\n", rep.TotalFiles, rep.TotalBytes, humanBytes(rep.TotalBytes))
	tw.Flush()

	if outPath != "" && outPath != "-" {
		fmt.Fprintf(w, "\nReport written to %s\n", outPath)
	}
}

// ---------------------------------------------------------------------------
// policy.
// ---------------------------------------------------------------------------

const examplePolicy = `{
  "name": "Techlosoft Baseline Privacy Policy",
  "description": "Baseline privacy posture every team laptop must meet.",
  "rules": [
    {
      "id": "cache-size",
      "category": "browser_cache",
      "type": "max_bytes",
      "limit": 52428800,
      "severity": "medium"
    },
    {
      "id": "cache-age",
      "category": "browser_cache",
      "type": "max_age_days",
      "limit": 30,
      "severity": "high"
    },
    {
      "id": "history-age",
      "category": "browsing_history",
      "type": "max_age_days",
      "limit": 90,
      "severity": "high"
    },
    {
      "id": "cookie-count",
      "category": "cookies",
      "type": "max_files",
      "limit": 4,
      "severity": "low"
    },
    {
      "id": "no-crash-dumps",
      "category": "crash_dumps",
      "type": "forbidden",
      "severity": "high"
    },
    {
      "id": "sessions-age",
      "category": "saved_sessions",
      "type": "max_age_days",
      "limit": 7,
      "severity": "medium"
    },
    {
      "id": "temp-size",
      "category": "temp_files",
      "type": "max_bytes",
      "limit": 10485760,
      "severity": "low"
    }
  ]
}
`

func cmdPolicy(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{})

	fs := newFlagSet("policy")
	example := fs.Bool("example", false, "print an example policy")
	if err := fs.Parse(args); err != nil {
		failUsage("policy: %v", err)
	}
	if rest := fs.Args(); len(rest) > 0 {
		failUsage("policy: unexpected argument %q", rest[0])
	}
	if !*example {
		failUsage("policy: nothing to do; pass --example")
	}
	fmt.Print(examplePolicy)
}

// ---------------------------------------------------------------------------
// audit.
// ---------------------------------------------------------------------------

func cmdAudit(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{"policy": true, "dir": true, "fail-on": true})

	fs := newFlagSet("audit")
	policyPath := fs.String("policy", "", "policy file")
	dir := fs.String("dir", "", "directory of report JSON files")
	failOn := fs.String("fail-on", "high", "lowest failing severity")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		failUsage("audit: %v", err)
	}
	if *policyPath == "" {
		failUsage("audit: --policy is required")
	}
	if !severityOK(*failOn) {
		failUsage("audit: --fail-on must be low, medium or high (got %q)", *failOn)
	}

	pol, warnings, err := loadPolicy(*policyPath)
	if err != nil {
		fail("audit: %v", err)
	}

	paths, err := gatherReportPaths(fs.Args(), *dir)
	if err != nil {
		fail("audit: %v", err)
	}
	if len(paths) == 0 {
		fail("audit: no report files given; pass report paths and/or --dir")
	}

	result := runAudit(pol, *policyPath, paths, *failOn, warnings, time.Now())
	if result.Summary.MachinesAudited == 0 {
		for _, s := range result.Skipped {
			fmt.Fprintf(os.Stderr, "%s: skipped %s: %s\n", toolName, s.Path, s.Reason)
		}
		fail("audit: no usable reports among %d file(s)", len(paths))
	}

	if *asJSON {
		data, err := json.MarshalIndent(result, "", "  ")
		if err != nil {
			fail("audit: encoding result: %v", err)
		}
		os.Stdout.Write(append(data, '\n'))
	} else {
		printAudit(os.Stdout, result)
	}

	if !result.Summary.FleetCompliant {
		os.Exit(2)
	}
}

// gatherReportPaths merges positional report paths with --dir contents,
// de-duplicating by absolute path while preserving order.
func gatherReportPaths(positional []string, dir string) ([]string, error) {
	var paths []string
	seen := make(map[string]bool)
	add := func(p string) {
		key := p
		if abs, err := filepath.Abs(p); err == nil {
			key = abs
		}
		if seen[key] {
			return
		}
		seen[key] = true
		paths = append(paths, p)
	}
	for _, p := range positional {
		add(p)
	}
	if dir != "" {
		info, err := os.Stat(dir)
		if err != nil {
			return nil, fmt.Errorf("cannot read --dir %s: %w", dir, err)
		}
		if !info.IsDir() {
			return nil, fmt.Errorf("--dir %s is not a directory", dir)
		}
		matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
		if err != nil {
			return nil, fmt.Errorf("listing %s: %w", dir, err)
		}
		sort.Strings(matches)
		for _, m := range matches {
			add(m)
		}
	}
	return paths, nil
}

// loadPolicy reads and validates a policy file. Rules naming an unknown
// category are dropped with a warning rather than aborting the audit, so a
// policy written for a newer catalog still audits what this build understands.
func loadPolicy(path string) (*Policy, []string, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, nil, fmt.Errorf("cannot read policy %s: %w", path, err)
	}
	var pol Policy
	if err := json.Unmarshal(raw, &pol); err != nil {
		return nil, nil, fmt.Errorf("malformed policy %s: %w", path, err)
	}

	var warnings []string
	kept := make([]Rule, 0, len(pol.Rules))
	ids := make(map[string]bool)
	for i, r := range pol.Rules {
		r.Type = strings.TrimSpace(r.Type)
		r.Category = strings.TrimSpace(r.Category)
		r.Severity = strings.ToLower(strings.TrimSpace(r.Severity))
		if r.Severity == "" {
			r.Severity = "high"
		}
		if !ruleTypeOK(r.Type) {
			return nil, nil, fmt.Errorf("policy %s: rule %d has unknown type %q (want one of %s)",
				path, i+1, r.Type, strings.Join(ruleTypes, ", "))
		}
		if !severityOK(r.Severity) {
			return nil, nil, fmt.Errorf("policy %s: rule %d has unknown severity %q (want low, medium or high)",
				path, i+1, r.Severity)
		}
		if r.Type != "forbidden" && r.Limit < 0 {
			return nil, nil, fmt.Errorf("policy %s: rule %d has negative limit %d", path, i+1, r.Limit)
		}
		if r.ID == "" {
			r.ID = fmt.Sprintf("%s/%s", r.Category, r.Type)
		}
		if ids[r.ID] {
			r.ID = fmt.Sprintf("%s#%d", r.ID, i+1)
		}
		ids[r.ID] = true
		if !knownCategory(r.Category) {
			warnings = append(warnings, fmt.Sprintf("rule %q references unknown category %q; rule ignored (known categories: %s)",
				r.ID, r.Category, strings.Join(categoryNames(), ", ")))
			continue
		}
		r.index = i
		kept = append(kept, r)
	}
	pol.Rules = kept
	if pol.Name == "" {
		pol.Name = filepath.Base(path)
	}
	return &pol, warnings, nil
}

// loadReport reads one machine report, rejecting anything that is not a
// well-formed TraceGuard report.
func loadReport(path string) (*Report, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("unreadable: %v", err)
	}
	if len(strings.TrimSpace(string(raw))) == 0 {
		return nil, errors.New("empty file")
	}
	var rep Report
	if err := json.Unmarshal(raw, &rep); err != nil {
		return nil, fmt.Errorf("malformed JSON: %v", err)
	}
	if rep.Tool != "" && rep.Tool != toolName {
		return nil, fmt.Errorf("not a traceguard report (tool=%q)", rep.Tool)
	}
	if rep.Machine == "" {
		return nil, errors.New("missing required field \"machine\"")
	}
	if rep.FormatVersion == 0 {
		return nil, errors.New("missing required field \"format_version\"")
	}
	if rep.FormatVersion > formatVersion {
		return nil, fmt.Errorf("report format_version %d is newer than supported version %d", rep.FormatVersion, formatVersion)
	}
	if len(rep.Categories) == 0 {
		return nil, errors.New("report contains no categories")
	}
	for i, c := range rep.Categories {
		if c.Category == "" {
			return nil, fmt.Errorf("category %d has no name", i+1)
		}
	}
	rep.source = path
	return &rep, nil
}

func runAudit(pol *Policy, policyPath string, paths []string, failOn string, warnings []string, now time.Time) *AuditResult {
	res := &AuditResult{
		Tool:           toolName,
		ToolVersion:    toolVersion,
		FormatVersion:  formatVersion,
		PolicyName:     pol.Name,
		PolicyPath:     policyPath,
		FailOn:         failOn,
		ActiveRules:    len(pol.Rules),
		AuditedAt:      now.UTC().Format(time.RFC3339),
		PolicyWarnings: warnings,
		Machines:       []MachineResult{},
		Skipped:        []SkippedReport{},
	}
	if res.PolicyWarnings == nil {
		res.PolicyWarnings = []string{}
	}

	ruleMachines := make(map[string]int)
	threshold := severityRank[failOn]

	for _, p := range paths {
		rep, err := loadReport(p)
		if err != nil {
			res.Skipped = append(res.Skipped, SkippedReport{Path: p, Reason: err.Error()})
			continue
		}
		mr := evaluate(rep, pol, threshold)
		res.Machines = append(res.Machines, mr)
		res.Summary.TotalExposedBytes += mr.ExposedBytes
		res.Summary.TotalExposedFiles += mr.ExposedFiles
		res.Summary.TotalViolations += len(mr.Violations)
		counted := make(map[string]bool)
		for _, v := range mr.Violations {
			if !counted[v.RuleID] {
				counted[v.RuleID] = true
				ruleMachines[v.RuleID]++
			}
		}
		if mr.Verdict == "PASS" {
			res.Summary.Passed++
		} else {
			res.Summary.Failed++
		}
	}

	res.Summary.MachinesAudited = len(res.Machines)
	res.Summary.Skipped = len(res.Skipped)
	res.Summary.FleetCompliant = res.Summary.Failed == 0

	// Most commonly violated rule: highest machine count, ties broken by the
	// rule's position in the policy file so the answer is deterministic.
	best := ""
	bestCount := 0
	bestIndex := 0
	for _, r := range pol.Rules {
		n := ruleMachines[r.ID]
		if n == 0 {
			continue
		}
		if n > bestCount || (n == bestCount && r.index < bestIndex) {
			best, bestCount, bestIndex = r.ID, n, r.index
		}
	}
	if best != "" {
		for _, r := range pol.Rules {
			if r.ID == best {
				res.Summary.MostViolatedRule = &TopRule{
					RuleID:   r.ID,
					Category: r.Category,
					Type:     r.Type,
					Machines: bestCount,
				}
				break
			}
		}
	}
	return res
}

func evaluate(rep *Report, pol *Policy, threshold int) MachineResult {
	byCat := make(map[string]CategoryReport, len(rep.Categories))
	var bytesTotal int64
	var filesTotal int
	for _, c := range rep.Categories {
		byCat[c.Category] = c
		bytesTotal += c.Bytes
		filesTotal += c.Files
	}
	// Prefer the report's own totals when present; fall back to the sum.
	if rep.TotalBytes > 0 {
		bytesTotal = rep.TotalBytes
	}
	if rep.TotalFiles > 0 {
		filesTotal = rep.TotalFiles
	}

	mr := MachineResult{
		Machine:      rep.Machine,
		Source:       rep.source,
		Verdict:      "PASS",
		ExposedBytes: bytesTotal,
		ExposedFiles: filesTotal,
		Violations:   []Violation{},
	}

	worst := 0
	for _, r := range pol.Rules {
		c, ok := byCat[r.Category]
		if !ok {
			// The report predates this category; nothing measured, nothing to
			// violate.
			continue
		}
		v, violated := checkRule(r, c)
		if !violated {
			continue
		}
		mr.Violations = append(mr.Violations, v)
		if rank := severityRank[r.Severity]; rank > worst {
			worst = rank
			mr.WorstSeverity = r.Severity
		}
	}
	if worst >= threshold && worst > 0 {
		mr.Verdict = "FAIL"
	}
	return mr
}

func checkRule(r Rule, c CategoryReport) (Violation, bool) {
	v := Violation{
		RuleID:   r.ID,
		Category: r.Category,
		Type:     r.Type,
		Severity: r.Severity,
		Limit:    r.Limit,
	}
	switch r.Type {
	case "forbidden":
		if c.Files == 0 {
			return v, false
		}
		v.Actual = int64(c.Files)
		v.Detail = fmt.Sprintf("category present: %d file(s), %s (must be absent)", c.Files, humanBytes(c.Bytes))
	case "max_bytes":
		if c.Bytes <= r.Limit {
			return v, false
		}
		v.Actual = c.Bytes
		v.Detail = fmt.Sprintf("%d bytes (%s) exceeds limit %d bytes (%s)", c.Bytes, humanBytes(c.Bytes), r.Limit, humanBytes(r.Limit))
	case "max_files":
		if int64(c.Files) <= r.Limit {
			return v, false
		}
		v.Actual = int64(c.Files)
		v.Detail = fmt.Sprintf("%d files exceeds limit %d files", c.Files, r.Limit)
	case "max_age_days":
		if !c.Present || int64(c.OldestAgeDays) <= r.Limit {
			return v, false
		}
		v.Actual = int64(c.OldestAgeDays)
		v.Detail = fmt.Sprintf("oldest artifact is %d days old, limit %d days", c.OldestAgeDays, r.Limit)
		if c.OldestPath != "" {
			v.Detail += fmt.Sprintf(" (%s)", c.OldestPath)
		}
	default:
		return v, false
	}
	return v, true
}

func printAudit(w io.Writer, res *AuditResult) {
	fmt.Fprintf(w, "TraceGuard fleet audit\n")
	fmt.Fprintf(w, "Policy:   %s (%s)\n", res.PolicyName, res.PolicyPath)
	fmt.Fprintf(w, "Fail-on:  %s severity or above\n", res.FailOn)
	fmt.Fprintf(w, "Audited:  %s\n", res.AuditedAt)
	fmt.Fprintf(w, "Rules:    %d active\n", res.ActiveRules)
	for _, warn := range res.PolicyWarnings {
		fmt.Fprintf(w, "Warning:  %s\n", warn)
	}
	if res.ActiveRules == 0 {
		fmt.Fprintf(w, "Note:     policy has no active rules; every machine passes by definition.\n")
	}
	fmt.Fprintln(w)

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "MACHINE\tVERDICT\tVIOLATIONS\tWORST\tEXPOSED\tFILES")
	for _, m := range res.Machines {
		worst := m.WorstSeverity
		if worst == "" {
			worst = "-"
		}
		fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\t%d\n",
			m.Machine, m.Verdict, len(m.Violations), worst, humanBytes(m.ExposedBytes), m.ExposedFiles)
	}
	tw.Flush()

	any := false
	for _, m := range res.Machines {
		if len(m.Violations) > 0 {
			any = true
			break
		}
	}
	if any {
		fmt.Fprintf(w, "\nVIOLATIONS\n")
		vw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
		for _, m := range res.Machines {
			for _, v := range m.Violations {
				fmt.Fprintf(vw, "  %s\t[%s]\t%s\t%s\t%s\n", m.Machine, v.Severity, v.RuleID, v.Category+" "+v.Type, v.Detail)
			}
		}
		vw.Flush()
	}

	if len(res.Skipped) > 0 {
		fmt.Fprintf(w, "\nSKIPPED REPORTS\n")
		sw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
		for _, s := range res.Skipped {
			fmt.Fprintf(sw, "  %s\t%s\n", s.Path, s.Reason)
		}
		sw.Flush()
	}

	s := res.Summary
	fmt.Fprintf(w, "\nFLEET SUMMARY\n")
	fmt.Fprintf(w, "  Machines audited:      %d\n", s.MachinesAudited)
	fmt.Fprintf(w, "  Passed:                %d\n", s.Passed)
	fmt.Fprintf(w, "  Failed:                %d\n", s.Failed)
	fmt.Fprintf(w, "  Reports skipped:       %d\n", s.Skipped)
	fmt.Fprintf(w, "  Total violations:      %d\n", s.TotalViolations)
	fmt.Fprintf(w, "  Total exposed bytes:   %d (%s)\n", s.TotalExposedBytes, humanBytes(s.TotalExposedBytes))
	fmt.Fprintf(w, "  Total exposed files:   %d\n", s.TotalExposedFiles)
	if s.MostViolatedRule != nil {
		t := s.MostViolatedRule
		fmt.Fprintf(w, "  Most violated rule:    %s (%s %s) - %d machine(s)\n", t.RuleID, t.Category, t.Type, t.Machines)
	} else {
		fmt.Fprintf(w, "  Most violated rule:    none - no rule was violated\n")
	}
	if s.FleetCompliant {
		fmt.Fprintf(w, "  Fleet status:          COMPLIANT\n")
	} else {
		fmt.Fprintf(w, "  Fleet status:          NON-COMPLIANT\n")
	}
}
