// Command devicepulse is a CLI-native, persistent, multi-metric vitals-trend
// tracker. It takes "vitals snapshots" (logical CPU count, this process's Go
// runtime memory stats, and on-disk usage of one or more watched directory
// trees), appends every snapshot to a JSON-lines history log, and flags
// threshold-crossing growth in watched-path disk usage between consecutive
// snapshots as warnings.
package main

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

// ---------------------------------------------------------------------------
// shared helpers (match conventions used across the sibling tool suite)
// ---------------------------------------------------------------------------

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

// multiFlag collects repeated -watch/--watch flag occurrences.
type multiFlag []string

func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error {
	*m = append(*m, v)
	return nil
}

// ---------------------------------------------------------------------------
// data model
// ---------------------------------------------------------------------------

// WatchedPath is the disk-usage measurement of one --watch directory tree,
// as recorded in a single snapshot.
type WatchedPath struct {
	Path  string `json:"path"`
	Bytes int64  `json:"bytes"`
}

// Snapshot is one line of the JSON-lines history file.
type Snapshot struct {
	TimestampUTC     string        `json:"timestamp_utc"`
	NumCPU           int           `json:"num_cpu"`
	GoHeapAllocBytes uint64        `json:"go_heap_alloc_bytes"`
	GoSysBytes       uint64        `json:"go_sys_bytes"`
	Watched          []WatchedPath `json:"watched"`
}

// ---------------------------------------------------------------------------
// vitals collection
// ---------------------------------------------------------------------------

// dirSize walks a directory tree and sums the size of every regular file
// found. Entries that cannot be read (permission errors, races with files
// disappearing mid-walk, etc.) are skipped rather than aborting the whole
// walk, so a snapshot still succeeds against a partially-unreadable tree.
func dirSize(root string) (int64, error) {
	info, err := os.Lstat(root)
	if err != nil {
		return 0, err
	}
	if !info.IsDir() {
		if info.Mode().IsRegular() {
			return info.Size(), nil
		}
		return 0, nil
	}

	var total int64
	walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			// Skip unreadable entries but keep walking the rest of the tree.
			return nil
		}
		if d.Type().IsRegular() {
			fi, ferr := d.Info()
			if ferr == nil {
				total += fi.Size()
			}
		}
		return nil
	})
	if walkErr != nil {
		return total, walkErr
	}
	return total, nil
}

func takeSnapshot(watchDirs []string) (Snapshot, error) {
	var mem runtime.MemStats
	runtime.ReadMemStats(&mem)

	snap := Snapshot{
		TimestampUTC:     time.Now().UTC().Format(time.RFC3339),
		NumCPU:           runtime.NumCPU(),
		GoHeapAllocBytes: mem.HeapAlloc,
		GoSysBytes:       mem.Sys,
	}

	for _, w := range watchDirs {
		clean := filepath.Clean(w)
		size, err := dirSize(clean)
		if err != nil {
			return snap, fmt.Errorf("watch path %q: %w", w, err)
		}
		snap.Watched = append(snap.Watched, WatchedPath{Path: clean, Bytes: size})
	}
	return snap, nil
}

// ---------------------------------------------------------------------------
// history file I/O
// ---------------------------------------------------------------------------

// readHistory parses every JSON-lines snapshot in path. A missing file is
// not an error: it returns (nil, false, nil) so callers can print a clean
// "no history yet" message instead of crashing.
func readHistory(path string) ([]Snapshot, bool, error) {
	f, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, false, nil
		}
		return nil, false, err
	}
	defer f.Close()

	var snaps []Snapshot
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
	lineNo := 0
	for scanner.Scan() {
		lineNo++
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		var s Snapshot
		if err := json.Unmarshal([]byte(line), &s); err != nil {
			return nil, true, fmt.Errorf("history file %q: invalid JSON on line %d: %w", path, lineNo, err)
		}
		snaps = append(snaps, s)
	}
	if err := scanner.Err(); err != nil {
		return nil, true, err
	}
	return snaps, true, nil
}

func appendHistory(path string, snap Snapshot) error {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer f.Close()

	line, err := json.Marshal(snap)
	if err != nil {
		return err
	}
	if _, err := f.Write(append(line, '\n')); err != nil {
		return err
	}
	return nil
}

// lastBytesForPath scans history (oldest to newest, already in that order)
// and returns the Bytes value from the most recent snapshot that recorded
// the given path, if any.
func lastBytesForPath(history []Snapshot, path string) (int64, bool) {
	for i := len(history) - 1; i >= 0; i-- {
		for _, w := range history[i].Watched {
			if w.Path == path {
				return w.Bytes, true
			}
		}
	}
	return 0, false
}

// ---------------------------------------------------------------------------
// commands
// ---------------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `devicepulse - persistent multi-metric vitals-trend tracker

Usage:
  devicepulse snapshot --watch <dir> [--watch <dir> ...] --history <file> [--warn-growth-pct N]
  devicepulse trend --history <file> [--watch <dir>] [--last N] [--json]
  devicepulse status --watch <dir> [--watch <dir> ...] [--json]
  devicepulse help

Commands:
  snapshot   Take a vitals snapshot, append it to the history file, and warn
             on watched-path disk-usage growth beyond the threshold since the
             previous snapshot of the same path.
  trend      Read-only: print snapshot history and per-watched-path usage
             over time, with min/max/latest across the shown window.
  status     Take one snapshot and print current vitals only. Never touches
             any history file.

Run "devicepulse <command> -h" is not needed; flags are listed above.
`)
}

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()
		os.Exit(0)
	case "snapshot":
		cmdSnapshot(os.Args[2:])
	case "trend":
		cmdTrend(os.Args[2:])
	case "status":
		cmdStatus(os.Args[2:])
	default:
		usage()
		os.Exit(1)
	}
}

func cmdSnapshot(args []string) {
	fs := flag.NewFlagSet("snapshot", flag.ExitOnError)
	var watch multiFlag
	fs.Var(&watch, "watch", "directory tree to measure disk usage of (repeatable, required)")
	history := fs.String("history", "", "path to the JSON-lines history file (required)")
	warnPct := fs.Float64("warn-growth-pct", 25, "warn if a watched path's disk usage grows by more than this percent since its last snapshot")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: devicepulse snapshot --watch <dir> [--watch <dir> ...] --history <file> [--warn-growth-pct 25]")
		fs.PrintDefaults()
	}

	valueFlags := map[string]bool{"watch": true, "history": true, "warn-growth-pct": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}

	if len(watch) == 0 {
		fmt.Fprintln(os.Stderr, "error: at least one --watch <dir> is required")
		fs.Usage()
		os.Exit(1)
	}
	if *history == "" {
		fmt.Fprintln(os.Stderr, "error: --history <file> is required")
		fs.Usage()
		os.Exit(1)
	}

	prevHistory, _, err := readHistory(*history)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	snap, err := takeSnapshot(watch)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	if err := appendHistory(*history, snap); err != nil {
		fmt.Fprintf(os.Stderr, "error: could not write history: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("snapshot taken at %s\n", snap.TimestampUTC)
	fmt.Printf("  num_cpu:             %d\n", snap.NumCPU)
	fmt.Printf("  go_heap_alloc_bytes: %d (%s)\n", snap.GoHeapAllocBytes, humanBytes(int64(snap.GoHeapAllocBytes)))
	fmt.Printf("  go_sys_bytes:        %d (%s)\n", snap.GoSysBytes, humanBytes(int64(snap.GoSysBytes)))
	fmt.Println()

	warnings := 0
	for _, w := range snap.Watched {
		prevBytes, ok := lastBytesForPath(prevHistory, w.Path)
		if !ok {
			fmt.Printf("  %s: %s (%d bytes) [no prior snapshot to compare against]\n", w.Path, humanBytes(w.Bytes), w.Bytes)
			continue
		}
		if prevBytes == 0 {
			if w.Bytes == 0 {
				fmt.Printf("  %s: %s (%d bytes) [no change since last snapshot]\n", w.Path, humanBytes(w.Bytes), w.Bytes)
			} else {
				warnings++
				fmt.Printf("  WARNING %s: grew from 0 to %s (%d bytes) since last snapshot (was empty; percentage growth undefined)\n", w.Path, humanBytes(w.Bytes), w.Bytes)
			}
			continue
		}
		growthPct := (float64(w.Bytes) - float64(prevBytes)) / float64(prevBytes) * 100
		if growthPct > *warnPct {
			warnings++
			fmt.Printf("  WARNING %s: usage grew by %.2f%% since last snapshot (%d -> %d bytes; threshold %.2f%%)\n", w.Path, growthPct, prevBytes, w.Bytes, *warnPct)
		} else {
			fmt.Printf("  %s: %s (%d bytes), %.2f%% change since last snapshot (threshold %.2f%%)\n", w.Path, humanBytes(w.Bytes), w.Bytes, growthPct, *warnPct)
		}
	}

	fmt.Println()
	fmt.Printf("appended to %s (%d warning(s))\n", *history, warnings)
	if warnings > 0 {
		os.Exit(2)
	}
}

func cmdStatus(args []string) {
	fs := flag.NewFlagSet("status", flag.ExitOnError)
	var watch multiFlag
	fs.Var(&watch, "watch", "directory tree to measure disk usage of (repeatable, required)")
	asJSON := fs.Bool("json", false, "print machine-readable JSON instead of a table")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: devicepulse status --watch <dir> [--watch <dir> ...] [--json]")
		fs.PrintDefaults()
	}

	valueFlags := map[string]bool{"watch": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}

	if len(watch) == 0 {
		fmt.Fprintln(os.Stderr, "error: at least one --watch <dir> is required")
		fs.Usage()
		os.Exit(1)
	}

	snap, err := takeSnapshot(watch)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(snap); err != nil {
			fmt.Fprintf(os.Stderr, "error: %v\n", err)
			os.Exit(1)
		}
		return
	}

	fmt.Printf("devicepulse status at %s (no history file touched)\n\n", snap.TimestampUTC)
	fmt.Printf("  num_cpu:             %d\n", snap.NumCPU)
	fmt.Printf("  go_heap_alloc_bytes: %d (%s)\n", snap.GoHeapAllocBytes, humanBytes(int64(snap.GoHeapAllocBytes)))
	fmt.Printf("  go_sys_bytes:        %d (%s)\n", snap.GoSysBytes, humanBytes(int64(snap.GoSysBytes)))
	fmt.Println()
	for _, w := range snap.Watched {
		fmt.Printf("  %s: %s (%d bytes)\n", w.Path, humanBytes(w.Bytes), w.Bytes)
	}
}

func cmdTrend(args []string) {
	fs := flag.NewFlagSet("trend", flag.ExitOnError)
	history := fs.String("history", "", "path to the JSON-lines history file (required)")
	watchFilter := fs.String("watch", "", "restrict per-path output to this watched path")
	last := fs.Int("last", 0, "show only the last N snapshots (default: all)")
	asJSON := fs.Bool("json", false, "print machine-readable JSON instead of a table")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: devicepulse trend --history <file> [--watch <dir>] [--last N] [--json]")
		fs.PrintDefaults()
	}

	valueFlags := map[string]bool{"history": true, "watch": true, "last": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}

	if *history == "" {
		fmt.Fprintln(os.Stderr, "error: --history <file> is required")
		fs.Usage()
		os.Exit(1)
	}

	snaps, existed, err := readHistory(*history)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if !existed {
		fmt.Printf("no history yet: %s does not exist. Run \"devicepulse snapshot\" first.\n", *history)
		return
	}
	if len(snaps) == 0 {
		fmt.Printf("no history yet: %s exists but contains no snapshots.\n", *history)
		return
	}

	window := snaps
	if *last > 0 && *last < len(snaps) {
		window = snaps[len(snaps)-*last:]
	}

	var pathFilter string
	if *watchFilter != "" {
		pathFilter = filepath.Clean(*watchFilter)
	}

	if *asJSON {
		out := struct {
			Snapshots []Snapshot `json:"snapshots"`
		}{Snapshots: window}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fmt.Fprintf(os.Stderr, "error: %v\n", err)
			os.Exit(1)
		}
		return
	}

	fmt.Printf("history: %s (%d total snapshot(s), showing %d)\n\n", *history, len(snaps), len(window))
	fmt.Printf("%-25s %6s %14s %14s\n", "TIMESTAMP_UTC", "CPU", "HEAP_ALLOC", "SYS")
	for _, s := range window {
		fmt.Printf("%-25s %6d %14s %14s\n", s.TimestampUTC, s.NumCPU, humanBytes(int64(s.GoHeapAllocBytes)), humanBytes(int64(s.GoSysBytes)))
	}

	// Per-watched-path usage over time, restricted to pathFilter if given.
	type series struct {
		values []int64
	}
	byPath := map[string]*series{}
	var order []string
	for _, s := range window {
		for _, w := range s.Watched {
			if pathFilter != "" && w.Path != pathFilter {
				continue
			}
			sr, ok := byPath[w.Path]
			if !ok {
				sr = &series{}
				byPath[w.Path] = sr
				order = append(order, w.Path)
			}
			sr.values = append(sr.values, w.Bytes)
		}
	}
	sort.Strings(order)

	if len(order) == 0 {
		if pathFilter != "" {
			fmt.Printf("\nno snapshots in the shown window recorded watched path %q\n", pathFilter)
		}
		return
	}

	for _, p := range order {
		vals := byPath[p].values
		min, max := vals[0], vals[0]
		for _, v := range vals {
			if v < min {
				min = v
			}
			if v > max {
				max = v
			}
		}
		latest := vals[len(vals)-1]
		fmt.Printf("\nwatched path: %s\n", p)
		fmt.Printf("  samples in window: %d\n", len(vals))
		fmt.Printf("  min:    %s (%d bytes)\n", humanBytes(min), min)
		fmt.Printf("  max:    %s (%d bytes)\n", humanBytes(max), max)
		fmt.Printf("  latest: %s (%d bytes)\n", humanBytes(latest), latest)
	}
}
