package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"time"
)

// ---------------------------------------------------------------------------
// Inventory
// ---------------------------------------------------------------------------

// Item is one regular file, identified and hashed.
type Item struct {
	Rel        string    `json:"rel"`
	Bytes      int64     `json:"bytes"`
	ModTime    time.Time `json:"modtime"`
	SHA256     string    `json:"sha256"`
	Kind       string    `json:"kind"`
	Category   string    `json:"category"`
	DetectedBy string    `json:"detected_by"`
}

// KindCount is one row of the per-class breakdown.
type KindCount struct {
	Name  string `json:"name"`
	Files int    `json:"files"`
	Bytes int64  `json:"bytes"`
	Human string `json:"human"`
}

// Skip records something the walk deliberately did not inventory.
type Skip struct {
	Rel    string `json:"rel"`
	Reason string `json:"reason"`
}

// Inventory is the full result of walking one tree.
type Inventory struct {
	Root         string      `json:"root"`
	Files        int         `json:"files"`
	Dirs         int         `json:"dirs"`
	Bytes        int64       `json:"bytes"`
	BytesHuman   string      `json:"bytes_human"`
	UniqueFiles  int         `json:"unique_contents"`
	UniqueBytes  int64       `json:"dedup_adjusted_bytes"`
	UniqueHuman  string      `json:"dedup_adjusted_human"`
	RedundantB   int64       `json:"redundant_bytes"`
	Categories   []KindCount `json:"categories"`
	Kinds        []KindCount `json:"kinds"`
	ByMagic      int         `json:"identified_by_magic"`
	ByExtension  int         `json:"identified_by_extension"`
	Unidentified int         `json:"unidentified"`
	Skipped      []Skip      `json:"skipped,omitempty"`
	ScanSeconds  float64     `json:"scan_seconds"`
	ReadRate     float64     `json:"read_hash_bytes_per_sec"`
	Items        []Item      `json:"items"`
}

// byRel indexes the items by relative path.
func (inv *Inventory) byRel() map[string]Item {
	m := make(map[string]Item, len(inv.Items))
	for _, it := range inv.Items {
		m[it.Rel] = it
	}
	return m
}

// scanTree walks root, identifies and SHA-256 hashes every regular file, and
// returns a sorted inventory. The tree is only ever opened for reading.
//
// Symlinks are never followed and never inventoried: a phone mount can contain
// links that point back out of the tree, and copying through them would either
// duplicate content or escape the destination.
func scanTree(root string) (*Inventory, error) {
	inv := &Inventory{Root: root}
	start := time.Now()

	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			rel := relOf(root, p)
			inv.Skipped = append(inv.Skipped, Skip{rel, fmt.Sprintf("unreadable: %v", err)})
			if d != nil && d.IsDir() {
				return fs.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			if p != root {
				inv.Dirs++
			}
			return nil
		}
		rel := relOf(root, p)
		if !d.Type().IsRegular() {
			inv.Skipped = append(inv.Skipped, Skip{rel, "not a regular file (" + d.Type().String() + ")"})
			return nil
		}
		info, err := d.Info()
		if err != nil {
			inv.Skipped = append(inv.Skipped, Skip{rel, fmt.Sprintf("unreadable: %v", err)})
			return nil
		}
		it, err := inspect(p, rel, info)
		if err != nil {
			inv.Skipped = append(inv.Skipped, Skip{rel, fmt.Sprintf("unreadable: %v", err)})
			return nil
		}
		inv.Items = append(inv.Items, it)
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("cannot walk %s: %w", root, err)
	}

	sort.Slice(inv.Items, func(i, j int) bool { return inv.Items[i].Rel < inv.Items[j].Rel })
	summarise(inv)
	inv.ScanSeconds = time.Since(start).Seconds()
	if inv.ScanSeconds > 0 && inv.Bytes > 0 {
		inv.ReadRate = float64(inv.Bytes) / inv.ScanSeconds
	}
	return inv, nil
}

func relOf(root, p string) string {
	rel, err := filepath.Rel(root, p)
	if err != nil {
		return p
	}
	return filepath.ToSlash(rel)
}

// inspect reads a file once: the head is used to identify it and the whole
// stream feeds the SHA-256 hash.
func inspect(path, rel string, info fs.FileInfo) (Item, error) {
	f, err := os.Open(path) // read-only, always
	if err != nil {
		return Item{}, err
	}
	defer f.Close()

	h := sha256.New()
	head := make([]byte, sniffLen)
	n, err := io.ReadFull(f, head)
	if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
		return Item{}, err
	}
	head = head[:n]
	h.Write(head)
	written, err := io.Copy(h, f)
	if err != nil {
		return Item{}, err
	}

	kind, method := sniff(head, rel)
	return Item{
		Rel:        rel,
		Bytes:      int64(n) + written,
		ModTime:    info.ModTime().UTC(),
		SHA256:     hex.EncodeToString(h.Sum(nil)),
		Kind:       kind,
		Category:   categoryOf(kind),
		DetectedBy: method,
	}, nil
}

// summarise fills in the totals, the per-class breakdowns and the
// dedup-adjusted size (the bytes you would move if identical contents were
// stored once).
func summarise(inv *Inventory) {
	kinds := map[string]*KindCount{}
	cats := map[string]*KindCount{}
	seen := map[string]bool{}

	inv.Files = len(inv.Items)
	inv.Bytes = 0
	inv.UniqueBytes = 0
	inv.UniqueFiles = 0
	inv.ByMagic, inv.ByExtension, inv.Unidentified = 0, 0, 0

	for _, it := range inv.Items {
		inv.Bytes += it.Bytes
		if !seen[it.SHA256] {
			seen[it.SHA256] = true
			inv.UniqueFiles++
			inv.UniqueBytes += it.Bytes
		}
		switch it.DetectedBy {
		case byMagic, byMagicExt:
			inv.ByMagic++
		case byExtension:
			inv.ByExtension++
		default:
			inv.Unidentified++
		}
		bump(kinds, it.Kind, it.Bytes)
		bump(cats, it.Category, it.Bytes)
	}
	inv.RedundantB = inv.Bytes - inv.UniqueBytes
	inv.BytesHuman = humanBytes(inv.Bytes)
	inv.UniqueHuman = humanBytes(inv.UniqueBytes)
	inv.Kinds = flatten(kinds)
	inv.Categories = flatten(cats)
}

func bump(m map[string]*KindCount, name string, n int64) {
	c, ok := m[name]
	if !ok {
		c = &KindCount{Name: name}
		m[name] = c
	}
	c.Files++
	c.Bytes += n
}

// flatten orders a breakdown by descending bytes, then by name so the output is
// stable when two classes tie.
func flatten(m map[string]*KindCount) []KindCount {
	out := make([]KindCount, 0, len(m))
	for _, c := range m {
		c.Human = humanBytes(c.Bytes)
		out = append(out, *c)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Bytes != out[j].Bytes {
			return out[i].Bytes > out[j].Bytes
		}
		return out[i].Name < out[j].Name
	})
	return out
}

// ---------------------------------------------------------------------------
// inventory command
// ---------------------------------------------------------------------------

func cmdInventory(argv []string) {
	fs := newFlagSet("inventory")
	src := fs.String("src", "", "source tree to inventory")
	fs.StringVar(src, "s", "", "shorthand for --src")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *src == "" && fs.NArg() > 0 {
		*src = fs.Arg(0)
	}
	if *src == "" {
		usageErr("inventory needs --src <dir>")
	}
	root := resolveDir("source", *src)

	inv, err := scanTree(root)
	if err != nil {
		fail("%v", err)
	}

	if *asJSON {
		emitJSON(inv)
		return
	}
	printInventory(inv)
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fail("%v", err)
	}
}

func printInventory(inv *Inventory) {
	fmt.Printf("MovePhone inventory\n")
	fmt.Printf("tree      : %s\n", inv.Root)
	fmt.Printf("files     : %d in %d dirs\n", inv.Files, inv.Dirs)
	fmt.Printf("size      : %s (%d bytes)\n", inv.BytesHuman, inv.Bytes)
	fmt.Printf("unique    : %d distinct contents, %s dedup-adjusted (%d bytes)\n",
		inv.UniqueFiles, inv.UniqueHuman, inv.UniqueBytes)
	if inv.RedundantB > 0 {
		fmt.Printf("redundant : %s held in duplicate copies inside this tree\n", humanBytes(inv.RedundantB))
	}
	fmt.Printf("identified: %d by magic bytes, %d by extension only, %d unrecognised\n",
		inv.ByMagic, inv.ByExtension, inv.Unidentified)
	fmt.Printf("scan      : %s, read+hash %s\n",
		humanDuration(time.Duration(inv.ScanSeconds*float64(time.Second))), humanRate(inv.ReadRate))
	fmt.Println()

	if len(inv.Categories) > 0 {
		fmt.Println("BY CATEGORY")
		for _, c := range inv.Categories {
			fmt.Printf("  %-10s %6d files  %12s  (%d bytes)\n", c.Name, c.Files, c.Human, c.Bytes)
		}
		fmt.Println()
	}
	if len(inv.Kinds) > 0 {
		fmt.Println("BY CONTENT TYPE")
		for _, c := range inv.Kinds {
			fmt.Printf("  %-64s %6d files  %12s\n", c.Name, c.Files, c.Human)
		}
		fmt.Println()
	}
	if len(inv.Skipped) > 0 {
		fmt.Printf("SKIPPED (%d)\n", len(inv.Skipped))
		for _, s := range inv.Skipped {
			fmt.Printf("  %-50s %s\n", s.Rel, s.Reason)
		}
		fmt.Println()
	}
	if inv.Files == 0 {
		fmt.Println("(no regular files found)")
	}
}
