// Command cleangallery is a multi-target, read-only duplicate-file waste
// audit tool for IT admins. It surveys several directories/shares/user
// profiles at once and produces a consolidated, exportable report ranking
// each target by how much reclaimable space it has, so an admin can
// prioritize where to focus cleanup effort across a fleet.
//
// CleanGallery is deliberately audit-only: it never deletes, moves, or
// quarantines anything. Once an admin has used CleanGallery to find the
// worst offender, the sibling tool DupePilot is the one to run against
// that single target to actually clean it up.
package main

import (
	"crypto/sha256"
	"encoding/csv"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"
)

const version = "1.0.0"

func main() {
	if len(os.Args) < 2 {
		// 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.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "audit":
		cmdAudit(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "cleangallery: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprintf(os.Stderr, `cleangallery %s - multi-target duplicate-waste audit for IT admins

USAGE:
    cleangallery audit <target1> [<target2> ...] [flags]

COMMANDS:
    audit    Scan one or more independent target directories for exact
             duplicate files and report reclaimable space per target,
             ranked worst-offender first.

FLAGS (audit):
    --min-size <bytes>   Ignore files smaller than this (default 1)
    --csv <file>         Write a per-target summary CSV to <file>
    --json                Print full structured report (summary + full
                          per-target duplicate-set detail) as JSON

    -h, --help            Show this help

NOTES:
    CleanGallery is READ-ONLY. It never modifies, moves, or deletes any
    file. Each target is scanned INDEPENDENTLY: a file that is identical
    between target1 and target2 is NOT reported as a duplicate across
    targets, only within each target. Use the sibling tool DupePilot for
    interactive, single-target cleanup once you've decided where to focus.

EXAMPLES:
    cleangallery audit \\\\fileserver\\shareA \\\\fileserver\\shareB
    cleangallery audit /mnt/profiles/alice /mnt/profiles/bob --min-size 4096
    cleangallery audit ./share1 ./share2 --csv report.csv
    cleangallery audit ./share1 ./share2 --json > report.json
`, version)
}

// reorderFlags works around the stdlib flag package's behavior of stopping
// flag parsing at the first positional argument, by moving all recognized
// flags (and their values, for value-taking flags) to the front.
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...)
}

// humanBytes renders a byte count using IEC (1024-based) binary prefixes.
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])
}

// ---------------------------------------------------------------------
// Duplicate detection (size-bucket, then SHA-256) - same proven technique
// used by the sibling tool DupePilot, reimplemented here because
// CleanGallery's scope (multiple independent targets) and output (a
// ranked, exportable admin report) are different from DupePilot's job.
// ---------------------------------------------------------------------

// DupGroup is one set of files in a single target that are byte-for-byte
// identical (same size and same SHA-256 digest).
type DupGroup struct {
	SHA256 string   `json:"sha256"`
	Size   int64    `json:"size_bytes"`
	Count  int      `json:"count"`
	Waste  int64    `json:"reclaimable_bytes"`
	Files  []string `json:"files"`
}

// TargetResult is the audit outcome for a single independent scan root.
type TargetResult struct {
	Path              string     `json:"path"`
	Error             string     `json:"error,omitempty"`
	FilesScanned      int        `json:"files_scanned"`
	DuplicateSetCount int        `json:"duplicate_set_count"`
	ReclaimableBytes  int64      `json:"reclaimable_bytes"`
	ReclaimableHuman  string     `json:"reclaimable_human"`
	DuplicateGroups   []DupGroup `json:"duplicate_groups"`
}

// auditTarget walks a single target root independently of every other
// target, buckets regular files by size, then hashes files within any
// bucket that has more than one file, and groups identical hashes into
// duplicate sets.
func auditTarget(root string, minSize int64) TargetResult {
	res := TargetResult{Path: root}

	info, err := os.Stat(root)
	if err != nil {
		res.Error = err.Error()
		return res
	}
	if !info.IsDir() {
		res.Error = "not a directory"
		return res
	}

	sizeBuckets := make(map[int64][]string)

	walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			// Skip unreadable entries but keep scanning the rest of the tree.
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			// Skip symlinks, devices, sockets, etc. - only real file
			// content can be a genuine duplicate.
			return nil
		}
		fi, err := d.Info()
		if err != nil {
			return nil
		}
		if fi.Size() < minSize {
			return nil
		}
		res.FilesScanned++
		sizeBuckets[fi.Size()] = append(sizeBuckets[fi.Size()], path)
		return nil
	})
	if walkErr != nil {
		res.Error = walkErr.Error()
		return res
	}

	groups := []DupGroup{}
	for size, paths := range sizeBuckets {
		if len(paths) < 2 {
			continue
		}
		hashBuckets := make(map[string][]string)
		for _, p := range paths {
			sum, err := sha256File(p)
			if err != nil {
				// Unreadable file (permissions, etc.) - skip it, it can't
				// be confirmed as a duplicate.
				continue
			}
			hashBuckets[sum] = append(hashBuckets[sum], p)
		}
		for sum, files := range hashBuckets {
			if len(files) < 2 {
				continue
			}
			sort.Strings(files)
			groups = append(groups, DupGroup{
				SHA256: sum,
				Size:   size,
				Count:  len(files),
				Waste:  size * int64(len(files)-1),
				Files:  files,
			})
		}
	}

	sort.Slice(groups, func(i, j int) bool {
		if groups[i].Waste != groups[j].Waste {
			return groups[i].Waste > groups[j].Waste
		}
		return groups[i].SHA256 < groups[j].SHA256
	})

	var totalWaste int64
	for _, g := range groups {
		totalWaste += g.Waste
	}

	res.DuplicateGroups = groups
	res.DuplicateSetCount = len(groups)
	res.ReclaimableBytes = totalWaste
	res.ReclaimableHuman = humanBytes(totalWaste)
	return res
}

func sha256File(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// ---------------------------------------------------------------------
// audit subcommand
// ---------------------------------------------------------------------

func cmdAudit(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" {
			auditUsage()
			return
		}
	}

	valueFlags := map[string]bool{"min-size": true, "csv": true}
	args = reorderFlags(args, valueFlags)

	var minSize int64 = 1
	var csvPath string
	var jsonOut bool

	i := 0
	var targets []string
	for i < len(args) {
		a := args[i]
		switch {
		case a == "--min-size" || a == "-min-size":
			if i+1 >= len(args) {
				fmt.Fprintln(os.Stderr, "cleangallery audit: --min-size requires a value")
				os.Exit(1)
			}
			v, err := strconv.ParseInt(args[i+1], 10, 64)
			if err != nil || v < 0 {
				fmt.Fprintf(os.Stderr, "cleangallery audit: invalid --min-size value %q\n", args[i+1])
				os.Exit(1)
			}
			minSize = v
			i += 2
		case a == "--csv" || a == "-csv":
			if i+1 >= len(args) {
				fmt.Fprintln(os.Stderr, "cleangallery audit: --csv requires a file path")
				os.Exit(1)
			}
			csvPath = args[i+1]
			i += 2
		case a == "--json" || a == "-json":
			jsonOut = true
			i++
		case strings.HasPrefix(a, "-"):
			fmt.Fprintf(os.Stderr, "cleangallery audit: unknown flag %q\n\n", a)
			auditUsage()
			os.Exit(1)
		default:
			targets = append(targets, a)
			i++
		}
	}

	if len(targets) == 0 {
		fmt.Fprintln(os.Stderr, "cleangallery audit: at least one target directory is required")
		auditUsage()
		os.Exit(1)
	}

	results := make([]TargetResult, 0, len(targets))
	for _, t := range targets {
		results = append(results, auditTarget(t, minSize))
	}

	// Worst offender first: sort by reclaimable bytes descending.
	sort.SliceStable(results, func(i, j int) bool {
		return results[i].ReclaimableBytes > results[j].ReclaimableBytes
	})

	var fleetTotal int64
	var fleetSets int
	for _, r := range results {
		fleetTotal += r.ReclaimableBytes
		fleetSets += r.DuplicateSetCount
	}

	if jsonOut {
		out := struct {
			MinSizeBytes          int64          `json:"min_size_bytes"`
			Targets               []TargetResult `json:"targets"`
			FleetDuplicateSets    int            `json:"fleet_duplicate_set_count"`
			FleetReclaimableBytes int64          `json:"fleet_reclaimable_bytes"`
			FleetReclaimableHuman string         `json:"fleet_reclaimable_human"`
			ScopeNote             string         `json:"scope_note"`
		}{
			MinSizeBytes:          minSize,
			Targets:               results,
			FleetDuplicateSets:    fleetSets,
			FleetReclaimableBytes: fleetTotal,
			FleetReclaimableHuman: humanBytes(fleetTotal),
			ScopeNote:             "Each target is deduplicated independently; identical files across different targets are not reported as duplicates.",
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fmt.Fprintf(os.Stderr, "cleangallery: failed to encode JSON: %v\n", err)
			os.Exit(1)
		}
	} else {
		printSummary(results, fleetSets, fleetTotal, minSize)
	}

	if csvPath != "" {
		if err := writeCSV(csvPath, results); err != nil {
			fmt.Fprintf(os.Stderr, "cleangallery: failed to write CSV: %v\n", err)
			os.Exit(1)
		}
		if !jsonOut {
			fmt.Printf("\nCSV report written to %s\n", csvPath)
		}
	}

	for _, r := range results {
		if r.Error != "" {
			os.Exit(1)
		}
	}
}

func auditUsage() {
	fmt.Fprint(os.Stderr, `cleangallery audit - survey duplicate-file waste across multiple targets

USAGE:
    cleangallery audit <target1> [<target2> ...] [flags]

Each target is an independent scan root: duplicate detection (size-bucket,
then SHA-256) runs separately per target. A file identical between two
targets is NOT treated as a cross-target duplicate - this tool reports how
much duplicate waste exists inside each share/profile on its own, not a
global fleet-wide dedup.

FLAGS:
    --min-size <bytes>   Ignore files smaller than this (default 1)
    --csv <file>         Write a per-target summary CSV to <file>
    --json                Print full structured report as JSON instead of
                          the plain-text summary table
    -h, --help            Show this help

CleanGallery is read-only: it never modifies, moves, or deletes files.
`)
}

func printSummary(results []TargetResult, fleetSets int, fleetTotal int64, minSize int64) {
	fmt.Printf("CleanGallery duplicate-waste audit (min-size: %s)\n", humanBytes(minSize))
	fmt.Printf("Scope: each target scanned independently (no cross-target dedup)\n\n")

	tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
	fmt.Fprintln(tw, "TARGET\tDUP SETS\tRECLAIMABLE\tFILES SCANNED\tSTATUS")
	for _, r := range results {
		status := "ok"
		if r.Error != "" {
			status = "ERROR: " + r.Error
		}
		fmt.Fprintf(tw, "%s\t%d\t%s (%d B)\t%d\t%s\n",
			r.Path, r.DuplicateSetCount, humanBytes(r.ReclaimableBytes), r.ReclaimableBytes, r.FilesScanned, status)
	}
	tw.Flush()

	fmt.Printf("\nFLEET TOTAL: %d duplicate set(s) across %d target(s), %s (%d B) reclaimable\n",
		fleetSets, len(results), humanBytes(fleetTotal), fleetTotal)
}

func writeCSV(path string, results []TargetResult) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()

	w := csv.NewWriter(f)
	defer w.Flush()

	if err := w.Write([]string{"path", "duplicate_set_count", "reclaimable_bytes", "human_readable_size"}); err != nil {
		return err
	}
	for _, r := range results {
		row := []string{
			r.Path,
			strconv.Itoa(r.DuplicateSetCount),
			strconv.FormatInt(r.ReclaimableBytes, 10),
			humanBytes(r.ReclaimableBytes),
		}
		if err := w.Write(row); err != nil {
			return err
		}
	}
	return w.Error()
}
