// Command spacemedic is a read-only, categorized cleanup-candidate
// diagnosis report for a directory tree.
//
// SpaceMedic is the "Pro" tier of the same product line as the sibling
// tool DrivePulse. DrivePulse already answers "where is my space going"
// with a raw folder-level space map and a largest-files list. SpaceMedic
// answers a different question: "WHAT KIND of stuff is eating my space,
// and what's safe to consider cleaning" — it buckets files by junk-type
// (temp files, logs, cache directories, stale installers, old downloads,
// large media) instead of by folder, producing a triage view for a
// professional user rather than a raw file tree.
//
// SpaceMedic is diagnosis only. It never deletes, moves, quarantines, or
// otherwise modifies anything it scans — there is no --apply flag and
// there never will be one in this tool. Pair its output with a sibling
// tool such as DupePilot (exact-duplicate detection) or PrivacySweep
// (secure deletion) to actually act on what it finds.
//
// The full SpaceMedic product concept also includes real S.M.A.R.T. disk
// health telemetry. Reading SMART attributes requires OS-specific,
// privileged hardware access (e.g. ATA PASSTHROUGH ioctls on Windows,
// smartctl-style raw device I/O on macOS/Linux) that is out of reach for
// a portable, dependency-free Go CLI, so it is not implemented here and
// is tracked as a roadmap item. See ../plan.md for the full product plan.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

// ---------------------------------------------------------------------
// Flag-reordering workaround
//
// Go's flag package stops parsing at the first positional argument, so a
// command line like `spacemedic report /some/dir --top 5` would otherwise
// leave --top unparsed. reorderFlags walks the raw args and moves every
// recognized flag (and, for value flags, the value that follows it) to
// the front, leaving positional arguments at the end, before handing the
// result to flag.FlagSet.Parse.
// ---------------------------------------------------------------------

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

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

func fatalf(format string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, "spacemedic: "+format+"\n", args...)
	os.Exit(1)
}

// ---------------------------------------------------------------------
// Duration parsing: digits followed by a d/h/m unit suffix, where "d"
// means a 24-hour day (not a calendar day). This is deliberately not
// Go's time.ParseDuration, which has no "d" unit.
// ---------------------------------------------------------------------

func parseAge(s string) (time.Duration, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, fmt.Errorf("empty duration")
	}
	i := len(s)
	for i > 0 {
		c := s[i-1]
		if (c >= '0' && c <= '9') || c == '.' {
			break
		}
		i--
	}
	numPart := s[:i]
	unit := strings.ToLower(s[i:])
	if numPart == "" {
		return 0, fmt.Errorf("invalid duration %q: missing number", s)
	}
	f, err := strconv.ParseFloat(numPart, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid duration %q: %w", s, err)
	}
	switch unit {
	case "d":
		return time.Duration(f * float64(24*time.Hour)), nil
	case "h":
		return time.Duration(f * float64(time.Hour)), nil
	case "m":
		return time.Duration(f * float64(time.Minute)), nil
	case "":
		return 0, fmt.Errorf("invalid duration %q: missing unit (use d, h, or m)", s)
	default:
		return 0, fmt.Errorf("invalid duration %q: unknown unit %q (use d, h, or m)", s, unit)
	}
}

// ---------------------------------------------------------------------
// Size parsing: a number followed by an optional unit (B, KB/KiB,
// MB/MiB, GB/GiB, TB/TiB — case-insensitive, binary/1024-based). A bare
// number is treated as raw bytes.
// ---------------------------------------------------------------------

func parseSize(s string) (int64, error) {
	orig := s
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, fmt.Errorf("empty size")
	}
	upper := strings.ToUpper(s)
	units := []struct {
		suffix string
		mult   int64
	}{
		{"TIB", 1 << 40}, {"GIB", 1 << 30}, {"MIB", 1 << 20}, {"KIB", 1 << 10},
		{"TB", 1 << 40}, {"GB", 1 << 30}, {"MB", 1 << 20}, {"KB", 1 << 10},
		{"T", 1 << 40}, {"G", 1 << 30}, {"M", 1 << 20}, {"K", 1 << 10},
		{"B", 1},
	}
	for _, u := range units {
		if strings.HasSuffix(upper, u.suffix) {
			numPart := strings.TrimSpace(s[:len(s)-len(u.suffix)])
			if numPart == "" {
				return 0, fmt.Errorf("invalid size %q: missing number", orig)
			}
			f, err := strconv.ParseFloat(numPart, 64)
			if err != nil {
				return 0, fmt.Errorf("invalid size %q: %w", orig, err)
			}
			return int64(f * float64(u.mult)), nil
		}
	}
	f, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid size %q", orig)
	}
	return int64(f), nil
}

// ---------------------------------------------------------------------
// Category model and classification
// ---------------------------------------------------------------------

type categoryKey string

const (
	catTemp          categoryKey = "temp_files"
	catLog           categoryKey = "log_files"
	catCache         categoryKey = "cache_like"
	catInstaller     categoryKey = "old_installers_archives"
	catDownload      categoryKey = "old_downloads"
	catMedia         categoryKey = "large_media"
	catUncategorized categoryKey = "uncategorized"
)

var categoryNames = map[categoryKey]string{
	catTemp:          "Temp files",
	catLog:           "Log files",
	catCache:         "Cache-like",
	catInstaller:     "Old installers/archives",
	catDownload:      "Old downloads",
	catMedia:         "Large media",
	catUncategorized: "Uncategorized",
}

// actionableOrder lists every category except Uncategorized, in
// classification-priority order. Report output re-sorts these by total
// size descending; this order only matters for classify().
var actionableOrder = []categoryKey{
	catTemp, catLog, catCache, catInstaller, catDownload, catMedia,
}

var installerExts = []string{".msi", ".exe", ".dmg", ".pkg", ".zip", ".tar", ".tar.gz", ".tgz"}
var mediaExts = []string{".mp4", ".mov", ".avi", ".mkv", ".iso"}
var tempExts = []string{".tmp", ".temp"}

func hasAnyExtSuffix(lowerName string, exts []string) bool {
	for _, e := range exts {
		if strings.HasSuffix(lowerName, e) {
			return true
		}
	}
	return false
}

// hasDirSegment reports whether any path segment of rel EXCLUDING the
// filename itself case-insensitively equals one of names.
func hasDirSegment(rel string, names []string) bool {
	rel = filepath.ToSlash(rel)
	parts := strings.Split(rel, "/")
	if len(parts) > 0 {
		parts = parts[:len(parts)-1]
	}
	set := make(map[string]bool, len(names))
	for _, n := range names {
		set[strings.ToLower(n)] = true
	}
	for _, p := range parts {
		if set[strings.ToLower(p)] {
			return true
		}
	}
	return false
}

// classify buckets one file into exactly one category, checking rules in
// priority order (first match wins), per SpaceMedic's category spec.
func classify(rel string, size int64, modTime, now time.Time, installerAge, downloadAge time.Duration, mediaSize int64) categoryKey {
	base := filepath.Base(rel)
	lowerBase := strings.ToLower(base)

	// 1. Temp files
	if strings.HasPrefix(base, "~") || hasAnyExtSuffix(lowerBase, tempExts) {
		return catTemp
	}

	// 2. Log files (including rotated logs like app.log.1)
	if strings.HasSuffix(lowerBase, ".log") {
		return catLog
	}
	if ok, _ := filepath.Match("*.log.*", lowerBase); ok {
		return catLog
	}

	// 3. Cache-like: a directory segment named cache/caches/.cache/tmp
	if hasDirSegment(rel, []string{"cache", "caches", ".cache", "tmp"}) {
		return catCache
	}

	// 4. Old installers/archives: matching extension AND old enough
	if hasAnyExtSuffix(lowerBase, installerExts) && now.Sub(modTime) > installerAge {
		return catInstaller
	}

	// 5. Old downloads: inside a download(s) directory AND old enough
	if hasDirSegment(rel, []string{"download", "downloads"}) && now.Sub(modTime) > downloadAge {
		return catDownload
	}

	// 6. Large media: matching extension AND size at/above the threshold
	if hasAnyExtSuffix(lowerBase, mediaExts) && size >= mediaSize {
		return catMedia
	}

	return catUncategorized
}

// ---------------------------------------------------------------------
// Report model
// ---------------------------------------------------------------------

type fileRec struct {
	Path    string
	Size    int64
	ModTime time.Time
}

type topFile struct {
	Path    string `json:"path"`
	Bytes   int64  `json:"bytes"`
	Human   string `json:"human_size"`
	ModTime string `json:"mod_time_utc"`
}

type categoryResult struct {
	Key        string    `json:"key"`
	Name       string    `json:"name"`
	FileCount  int       `json:"file_count"`
	TotalBytes int64     `json:"total_bytes"`
	Human      string    `json:"human_total"`
	TopFiles   []topFile `json:"top_files,omitempty"`
}

type reportOut struct {
	Dir              string           `json:"dir"`
	GeneratedAtUTC   string           `json:"generated_at_utc"`
	InstallerAge     string           `json:"installer_age"`
	DownloadAge      string           `json:"download_age"`
	LargeMediaSize   string           `json:"large_media_size"`
	Top              int              `json:"top"`
	Categories       []categoryResult `json:"categories"`
	Uncategorized    categoryResult   `json:"uncategorized"`
	TotalFiles       int              `json:"total_files"`
	TotalBytes       int64            `json:"total_bytes"`
	ActionableBytes  int64            `json:"actionable_bytes"`
	ActionablePctStr string           `json:"actionable_pct"`
	ReadOnly         bool             `json:"read_only"`
}

// ---------------------------------------------------------------------
// main / dispatch
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `SpaceMedic - categorized cleanup-candidate diagnosis report (READ-ONLY)

Usage:
  spacemedic report <dir> [--installer-age 90d] [--download-age 30d]
                           [--large-media-size 500MB] [--top 10] [--json]
  spacemedic help

SpaceMedic scans a directory tree and buckets every regular file into a
junk-type category (temp files, logs, cache directories, stale
installers/archives, old downloads, large media) so you can see WHAT KIND
of thing is eating your space, not just which folder. It is diagnosis
only: it never deletes, moves, or quarantines anything. Pair it with
DupePilot (exact-duplicate detection) or PrivacySweep (secure deletion)
to act on what it finds.

Run 'spacemedic report -h' for flag details.
`)
}

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

	cmd := os.Args[1]
	if isHelp(cmd) {
		usage()
		return
	}

	rest := os.Args[2:]
	switch cmd {
	case "report":
		if len(rest) > 0 && isHelp(rest[0]) {
			cmdReport([]string{"-h"})
			return
		}
		cmdReport(rest)
	default:
		fmt.Fprintf(os.Stderr, "spacemedic: unknown command %q\n\n", cmd)
		usage()
		os.Exit(1)
	}
}

// ---------------------------------------------------------------------
// report
// ---------------------------------------------------------------------

func cmdReport(args []string) {
	fset := flag.NewFlagSet("report", flag.ExitOnError)
	installerAgeStr := fset.String("installer-age", "90d", "minimum age for an installer/archive file to count as stale (e.g. 90d, 12h)")
	downloadAgeStr := fset.String("download-age", "30d", "minimum age for a file in a Downloads folder to count as old (e.g. 30d, 12h)")
	largeMediaSizeStr := fset.String("large-media-size", "500MB", "minimum size for a media file to count as large media (e.g. 500MB, 1GB)")
	top := fset.Int("top", 10, "largest N files to list per actionable category")
	asJSON := fset.Bool("json", false, "output JSON")
	fset.Usage = func() {
		fmt.Fprint(os.Stderr, `Usage: spacemedic report <dir> [--installer-age 90d] [--download-age 30d] [--large-media-size 500MB] [--top 10] [--json]

READ-ONLY. Walks <dir> recursively, classifies every regular file into
exactly one category (first matching rule wins):

  Temp files               extension .tmp/.temp, or filename starts with ~
  Log files                extension .log, or name matches *.log.* (rotated logs)
  Cache-like                path has a directory segment named (case-insensitive)
                            cache, caches, .cache, or tmp
  Old installers/archives  extension .msi/.exe/.dmg/.pkg/.zip/.tar/.tar.gz/.tgz
                            AND mtime older than --installer-age (default 90d)
  Old downloads            path has a directory segment named (case-insensitive)
                            download or downloads, AND mtime older than
                            --download-age (default 30d)
  Large media              extension .mp4/.mov/.avi/.mkv/.iso AND size >=
                            --large-media-size (default 500MB)
  Uncategorized            everything else

For every actionable category (all but Uncategorized) with at least one
match, reports total file count, total size, and the --top N largest
individual files. Uncategorized gets a count + size only, no per-file
listing. Actionable categories are sorted by total size descending.

SpaceMedic never deletes, moves, or modifies anything it scans.
`)
	}
	args = reorderFlags(args, map[string]bool{
		"installer-age": true, "download-age": true, "large-media-size": true, "top": true,
	})
	fset.Parse(args)

	pos := fset.Args()
	if len(pos) != 1 {
		fset.Usage()
		os.Exit(1)
	}
	dir := pos[0]

	installerAge, err := parseAge(*installerAgeStr)
	if err != nil {
		fatalf("--installer-age: %v", err)
	}
	downloadAge, err := parseAge(*downloadAgeStr)
	if err != nil {
		fatalf("--download-age: %v", err)
	}
	mediaSize, err := parseSize(*largeMediaSizeStr)
	if err != nil {
		fatalf("--large-media-size: %v", err)
	}
	if *top < 0 {
		fatalf("--top must be >= 0")
	}

	info, err := os.Stat(dir)
	if err != nil {
		fatalf("cannot access %q: %v", dir, err)
	}
	if !info.IsDir() {
		fatalf("%q is not a directory", dir)
	}

	now := time.Now()
	byCategory := make(map[categoryKey][]fileRec)
	var totalFiles int
	var totalBytes int64

	walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			fmt.Fprintf(os.Stderr, "spacemedic: warning: %v\n", err)
			if d != nil && d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		fi, err := d.Info()
		if err != nil {
			fmt.Fprintf(os.Stderr, "spacemedic: warning: stat %q: %v\n", path, err)
			return nil
		}
		if !fi.Mode().IsRegular() {
			return nil
		}
		rel, err := filepath.Rel(dir, path)
		if err != nil {
			rel = path
		}
		rel = filepath.ToSlash(rel)

		size := fi.Size()
		modTime := fi.ModTime()
		key := classify(rel, size, modTime, now, installerAge, downloadAge, mediaSize)

		byCategory[key] = append(byCategory[key], fileRec{Path: rel, Size: size, ModTime: modTime})
		totalFiles++
		totalBytes += size
		return nil
	})
	if walkErr != nil {
		fatalf("walking %q: %v", dir, walkErr)
	}

	buildResult := func(key categoryKey, includeTop bool) categoryResult {
		recs := byCategory[key]
		var sum int64
		for _, r := range recs {
			sum += r.Size
		}
		cr := categoryResult{
			Key:        string(key),
			Name:       categoryNames[key],
			FileCount:  len(recs),
			TotalBytes: sum,
			Human:      humanBytes(sum),
		}
		if includeTop && len(recs) > 0 {
			sorted := make([]fileRec, len(recs))
			copy(sorted, recs)
			sort.Slice(sorted, func(i, j int) bool { return sorted[i].Size > sorted[j].Size })
			n := *top
			if n > len(sorted) {
				n = len(sorted)
			}
			for _, r := range sorted[:n] {
				cr.TopFiles = append(cr.TopFiles, topFile{
					Path:    r.Path,
					Bytes:   r.Size,
					Human:   humanBytes(r.Size),
					ModTime: r.ModTime.UTC().Format(time.RFC3339),
				})
			}
		}
		return cr
	}

	var actionable []categoryResult
	var actionableBytes int64
	for _, key := range actionableOrder {
		if len(byCategory[key]) == 0 {
			continue
		}
		cr := buildResult(key, true)
		actionable = append(actionable, cr)
		actionableBytes += cr.TotalBytes
	}
	sort.SliceStable(actionable, func(i, j int) bool { return actionable[i].TotalBytes > actionable[j].TotalBytes })

	uncategorized := buildResult(catUncategorized, false)

	pct := 0.0
	if totalBytes > 0 {
		pct = float64(actionableBytes) / float64(totalBytes) * 100
	}

	out := reportOut{
		Dir:              dir,
		GeneratedAtUTC:   now.UTC().Format(time.RFC3339),
		InstallerAge:     *installerAgeStr,
		DownloadAge:      *downloadAgeStr,
		LargeMediaSize:   *largeMediaSizeStr,
		Top:              *top,
		Categories:       actionable,
		Uncategorized:    uncategorized,
		TotalFiles:       totalFiles,
		TotalBytes:       totalBytes,
		ActionableBytes:  actionableBytes,
		ActionablePctStr: fmt.Sprintf("%.1f%%", pct),
		ReadOnly:         true,
	}

	if *asJSON {
		if out.Categories == nil {
			out.Categories = []categoryResult{}
		}
		data, err := json.MarshalIndent(out, "", "  ")
		if err != nil {
			fatalf("encoding JSON: %v", err)
		}
		fmt.Println(string(data))
		return
	}

	fmt.Printf("SpaceMedic cleanup-candidate report for %s (READ-ONLY diagnosis, nothing modified)\n", dir)
	fmt.Printf("Generated: %s\n", out.GeneratedAtUTC)
	fmt.Printf("Thresholds: installer-age=%s  download-age=%s  large-media-size=%s  top=%d\n\n",
		out.InstallerAge, out.DownloadAge, out.LargeMediaSize, out.Top)

	if len(actionable) == 0 {
		fmt.Println("No actionable cleanup candidates found.")
	}
	for _, cr := range actionable {
		fmt.Printf("%s — %d file(s), %s\n", cr.Name, cr.FileCount, cr.Human)
		for _, tf := range cr.TopFiles {
			fmt.Printf("    %-10s %s\n", tf.Human, tf.Path)
		}
		fmt.Println()
	}

	fmt.Printf("%s — %d file(s), %s (not actionable, no listing)\n\n", uncategorized.Name, uncategorized.FileCount, uncategorized.Human)

	fmt.Printf("Grand total: %d file(s) scanned, %s\n", out.TotalFiles, humanBytes(out.TotalBytes))
	fmt.Printf("Actionable categories: %s (%s of total)\n", humanBytes(out.ActionableBytes), out.ActionablePctStr)
	fmt.Println("\nSpaceMedic is diagnosis-only: nothing above was deleted, moved, or quarantined.")
}
