// EraseProof — policy-driven, recurring secure erasure with a cumulative
// append-only proof-of-erasure ledger.
//
// Usage:
//
//	eraseproof scan --policy policies.json <policy-name> [--json]
//	eraseproof run  --policy policies.json <policy-name> --ledger ledger.jsonl [--apply] [--passes N]
//	eraseproof ledger --ledger ledger.jsonl [--policy <name>] [--json]
//
// EraseProof is the Pro-tier sibling of PrivacySweep. Where PrivacySweep
// does one-shot secure deletion of files you name explicitly and produces
// a single report for that run, EraseProof works from NAMED policies in a
// config file (target directory + glob pattern + age filter + pass count)
// that can be re-run over time, and every --apply run APPENDS its erasure
// records to a persistent ledger file rather than replacing it — so months
// later a compliance-minded user can point at ledger.jsonl and prove
// exactly what was erased, when, and under which policy, across every run
// that ever touched it. The underlying secure-overwrite mechanism (random
// data, N passes, fsync'd, then removed) is identical to PrivacySweep's —
// that part is deliberately not reinvented.
package main

import (
	"bufio"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

// policy describes one named erasure rule loaded from the policy file.
type policy struct {
	Name      string `json:"name"`
	TargetDir string `json:"target_dir"`
	Pattern   string `json:"pattern"`
	OlderThan string `json:"older_than"`
	Passes    int    `json:"passes"`
}

type policyFile struct {
	Policies []policy `json:"policies"`
}

// ledgerRecord is one append-only proof-of-erasure line in the ledger.
// The ledger file is JSON-lines: one record per erased file, ever.
type ledgerRecord struct {
	Path        string `json:"path"`
	SizeBytes   int64  `json:"size_bytes"`
	Sha256      string `json:"sha256_before_erase"`
	Policy      string `json:"policy"`
	Passes      int    `json:"overwrite_passes"`
	ErasedAtUTC string `json:"erased_at_utc"`
	Method      string `json:"method"`
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions 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 "scan":
		cmdScan(os.Args[2:])
	case "run":
		cmdRun(os.Args[2:])
	case "ledger":
		cmdLedger(os.Args[2:])
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `EraseProof — policy-driven, recurring secure erasure with a
cumulative append-only proof-of-erasure ledger

Usage:
  eraseproof scan   --policy FILE <policy-name> [--json]
  eraseproof run    --policy FILE <policy-name> --ledger FILE [--apply] [--passes N]
  eraseproof ledger --ledger FILE [--policy NAME] [--json]

scan is read-only: it previews which files currently match a named
policy's target directory, glob pattern, and age filter. Nothing is
touched.

run without --apply is a dry run identical in spirit to scan, framed
as "this run would erase". With --apply, every matching file is
overwritten with random data for the policy's pass count (or
--passes, default 3), fsync'd each pass, then removed — the same
mechanism PrivacySweep uses for shred. One JSON-lines record is then
APPENDED to --ledger per erased file; prior runs' records (possibly
from other policies, at other times) are never truncated or rewritten.

ledger prints a summary of the cumulative ledger: total files erased,
total bytes, and a per-policy breakdown, optionally filtered to one
--policy name. This is the durable proof of everything ever erased.
`)
}

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

// parseAge hand-parses a duration-like string with a digits+unit suffix
// where unit is d (days), h (hours), or m (minutes) — time.ParseDuration
// doesn't support days, which is the unit most useful for "stale file"
// retention rules. "0d" (or "" ) means no age filter.
func parseAge(s string) (time.Duration, error) {
	if s == "" {
		return 0, nil
	}
	if len(s) < 2 {
		return 0, fmt.Errorf("invalid duration %q (want e.g. 30d, 12h, 45m)", s)
	}
	unit := s[len(s)-1]
	numPart := s[:len(s)-1]
	n, err := strconv.Atoi(numPart)
	if err != nil || n < 0 {
		return 0, fmt.Errorf("invalid duration %q (want e.g. 30d, 12h, 45m)", s)
	}
	switch unit {
	case 'd':
		return time.Duration(n) * 24 * time.Hour, nil
	case 'h':
		return time.Duration(n) * time.Hour, nil
	case 'm':
		return time.Duration(n) * time.Minute, nil
	default:
		return 0, fmt.Errorf("invalid duration unit in %q (use d, h, or m)", s)
	}
}

// loadPolicies reads and parses the policy file.
func loadPolicies(path string) ([]policy, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("reading policy file: %w", err)
	}
	var pf policyFile
	if err := json.Unmarshal(data, &pf); err != nil {
		return nil, fmt.Errorf("parsing policy file: %w", err)
	}
	return pf.Policies, nil
}

// findPolicy looks up a named policy, returning a clean error (not a
// panic) if it isn't defined in the file.
func findPolicy(policies []policy, name string) (policy, error) {
	for _, p := range policies {
		if p.Name == name {
			return p, nil
		}
	}
	var names []string
	for _, p := range policies {
		names = append(names, p.Name)
	}
	return policy{}, fmt.Errorf("no policy named %q (defined: %s)", name, strings.Join(names, ", "))
}

// fileEntry describes one file matched by a policy.
type fileEntry struct {
	Path string `json:"path"`
	Size int64  `json:"size_bytes"`
}

// matchFiles walks a policy's target_dir and returns every regular file
// whose base name matches the policy's glob pattern and whose age
// satisfies older_than. This is the shared discovery step used by both
// scan (read-only preview) and run (which additionally erases what it
// finds when --apply is set).
func matchFiles(p policy) ([]fileEntry, error) {
	age, err := parseAge(p.OlderThan)
	if err != nil {
		return nil, err
	}
	pattern := p.Pattern
	if pattern == "" {
		pattern = "*"
	}

	info, err := os.Stat(p.TargetDir)
	if err != nil {
		if os.IsNotExist(err) {
			// A target directory that doesn't exist (yet) simply has no
			// matches — not an error condition worth aborting the run over.
			return nil, nil
		}
		return nil, fmt.Errorf("stat target_dir %q: %w", p.TargetDir, err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("target_dir %q is not a directory", p.TargetDir)
	}

	cutoff := time.Now().Add(-age)
	var out []fileEntry
	err = filepath.WalkDir(p.TargetDir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		ok, matchErr := filepath.Match(pattern, d.Name())
		if matchErr != nil || !ok {
			return nil
		}
		fi, statErr := d.Info()
		if statErr != nil {
			return nil
		}
		if age > 0 && fi.ModTime().After(cutoff) {
			return nil
		}
		out = append(out, fileEntry{Path: path, Size: fi.Size()})
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return out, nil
}

func cmdScan(args []string) {
	fs := flag.NewFlagSet("scan", flag.ExitOnError)
	policyPath := fs.String("policy", "", "policy file (JSON)")
	asJSON := fs.Bool("json", false, "emit JSON instead of a text table")
	fs.Parse(reorderFlags(args, map[string]bool{"policy": true}))
	targets := fs.Args()

	if *policyPath == "" || len(targets) != 1 {
		fmt.Fprintln(os.Stderr, "usage: eraseproof scan --policy FILE <policy-name> [--json]")
		os.Exit(1)
	}
	name := targets[0]

	policies, err := loadPolicies(*policyPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	p, err := findPolicy(policies, name)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	matches, err := matchFiles(p)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	var total int64
	for _, m := range matches {
		total += m.Size
	}

	if *asJSON {
		out, _ := json.MarshalIndent(map[string]any{
			"tool":         "eraseproof",
			"command":      "scan",
			"policy":       p.Name,
			"target_dir":   p.TargetDir,
			"pattern":      p.Pattern,
			"older_than":   p.OlderThan,
			"matches":      matches,
			"total_files":  len(matches),
			"total_bytes":  total,
			"generated_at": time.Now().UTC().Format(time.RFC3339),
		}, "", "  ")
		fmt.Println(string(out))
		return
	}

	fmt.Printf("Policy %q — %s (pattern %q, older_than %q)\n", p.Name, p.TargetDir, p.Pattern, p.OlderThan)
	if len(matches) == 0 {
		fmt.Println("  no matching files")
		return
	}
	for _, m := range matches {
		fmt.Printf("  %8s  %s\n", humanBytes(m.Size), m.Path)
	}
	fmt.Printf("\n%d file(s), %s total\n", len(matches), humanBytes(total))
}

func cmdRun(args []string) {
	fs := flag.NewFlagSet("run", flag.ExitOnError)
	policyPath := fs.String("policy", "", "policy file (JSON)")
	ledgerPath := fs.String("ledger", "", "cumulative append-only ledger file (JSON-lines)")
	apply := fs.Bool("apply", false, "actually erase (default is a dry run)")
	defaultPasses := fs.Int("passes", 3, "overwrite passes when a policy doesn't specify its own")
	fs.Parse(reorderFlags(args, map[string]bool{"policy": true, "ledger": true, "passes": true}))
	targets := fs.Args()

	if *policyPath == "" || *ledgerPath == "" || len(targets) != 1 {
		fmt.Fprintln(os.Stderr, "usage: eraseproof run --policy FILE <policy-name> --ledger FILE [--apply] [--passes N]")
		os.Exit(1)
	}
	name := targets[0]

	policies, err := loadPolicies(*policyPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	p, err := findPolicy(policies, name)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	passes := p.Passes
	if passes < 1 {
		passes = *defaultPasses
	}
	if passes < 1 {
		passes = 1
	}

	matches, err := matchFiles(p)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	if len(matches) == 0 {
		fmt.Printf("Policy %q — no matching files, nothing to do\n", p.Name)
		return
	}

	if !*apply {
		var total int64
		fmt.Printf("Policy %q — dry run, this run would erase:\n", p.Name)
		for _, m := range matches {
			fmt.Printf("  would erase  %8s  %s\n", humanBytes(m.Size), m.Path)
			total += m.Size
		}
		fmt.Printf("\n%d file(s), %s would be erased (%d passes) — re-run with --apply\n", len(matches), humanBytes(total), passes)
		fmt.Println("No files touched. No ledger entries written.")
		return
	}

	ledgerFile, err := os.OpenFile(*ledgerPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		fmt.Fprintln(os.Stderr, "failed to open ledger for append:", err)
		os.Exit(1)
	}
	defer ledgerFile.Close()

	var erasedFiles, erasedBytes int64
	for _, m := range matches {
		hash, err := hashFile(m.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "  skip %s: %v\n", m.Path, err)
			continue
		}
		if err := overwritePasses(m.Path, m.Size, passes); err != nil {
			fmt.Fprintf(os.Stderr, "  FAILED to overwrite %s: %v\n", m.Path, err)
			continue
		}
		if err := os.Remove(m.Path); err != nil {
			fmt.Fprintf(os.Stderr, "  overwritten but FAILED to remove %s: %v\n", m.Path, err)
			continue
		}

		rec := ledgerRecord{
			Path:        m.Path,
			SizeBytes:   m.Size,
			Sha256:      hash,
			Policy:      p.Name,
			Passes:      passes,
			ErasedAtUTC: time.Now().UTC().Format(time.RFC3339),
			Method:      "random-overwrite+fsync",
		}
		line, _ := json.Marshal(rec)
		if _, err := ledgerFile.Write(append(line, '\n')); err != nil {
			fmt.Fprintf(os.Stderr, "  erased %s but FAILED to append ledger entry: %v\n", m.Path, err)
			continue
		}
		if err := ledgerFile.Sync(); err != nil {
			fmt.Fprintf(os.Stderr, "  erased %s but ledger sync failed: %v\n", m.Path, err)
		}

		fmt.Printf("  erased       %8s  %s\n", humanBytes(m.Size), m.Path)
		erasedFiles++
		erasedBytes += m.Size
	}

	fmt.Printf("\n%d file(s) erased, %s destroyed under policy %q (%d passes)\n", erasedFiles, humanBytes(erasedBytes), p.Name, passes)
	fmt.Printf("Ledger entries appended to %s\n", *ledgerPath)
}

func cmdLedger(args []string) {
	fs := flag.NewFlagSet("ledger", flag.ExitOnError)
	ledgerPath := fs.String("ledger", "", "cumulative append-only ledger file (JSON-lines)")
	filterPolicy := fs.String("policy", "", "restrict the summary to one policy name")
	asJSON := fs.Bool("json", false, "emit JSON instead of a text summary")
	fs.Parse(reorderFlags(args, map[string]bool{"ledger": true, "policy": true}))

	if *ledgerPath == "" {
		fmt.Fprintln(os.Stderr, "usage: eraseproof ledger --ledger FILE [--policy NAME] [--json]")
		os.Exit(1)
	}

	records, err := readLedger(*ledgerPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	type policyTotal struct {
		Files int   `json:"files"`
		Bytes int64 `json:"bytes"`
	}
	totals := map[string]*policyTotal{}
	var grandFiles int
	var grandBytes int64
	var firstAt, lastAt string

	for _, r := range records {
		if *filterPolicy != "" && r.Policy != *filterPolicy {
			continue
		}
		t, ok := totals[r.Policy]
		if !ok {
			t = &policyTotal{}
			totals[r.Policy] = t
		}
		t.Files++
		t.Bytes += r.SizeBytes
		grandFiles++
		grandBytes += r.SizeBytes
		if firstAt == "" || r.ErasedAtUTC < firstAt {
			firstAt = r.ErasedAtUTC
		}
		if lastAt == "" || r.ErasedAtUTC > lastAt {
			lastAt = r.ErasedAtUTC
		}
	}

	if *asJSON {
		out, _ := json.MarshalIndent(map[string]any{
			"tool":                      "eraseproof",
			"command":                   "ledger",
			"ledger_file":               *ledgerPath,
			"policy_filter":             *filterPolicy,
			"total_files":               grandFiles,
			"total_bytes":               grandBytes,
			"first_erased":              firstAt,
			"last_erased":               lastAt,
			"per_policy":                totals,
			"matching_ledger_entries":   grandFiles,
			"ledger_file_total_entries": len(records),
		}, "", "  ")
		fmt.Println(string(out))
		return
	}

	fmt.Printf("Ledger: %s\n", *ledgerPath)
	if *filterPolicy != "" {
		fmt.Printf("Filtered to policy %q\n", *filterPolicy)
	}
	if grandFiles == 0 {
		fmt.Println("  no matching ledger entries")
		return
	}
	var names []string
	for name := range totals {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		t := totals[name]
		fmt.Printf("  %-20s %6d file(s)  %10s\n", name, t.Files, humanBytes(t.Bytes))
	}
	fmt.Printf("\nTotal: %d file(s) erased, %s destroyed, across %d ledger entr%s\n",
		grandFiles, humanBytes(grandBytes), grandFiles, plural(grandFiles))
	fmt.Printf("First erasure: %s   Last erasure: %s\n", firstAt, lastAt)
}

func plural(n int) string {
	if n == 1 {
		return "y"
	}
	return "ies"
}

// readLedger parses every JSON-lines record in the ledger file. Blank
// lines are skipped; the file may not exist yet (empty ledger).
func readLedger(path string) ([]ledgerRecord, error) {
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("reading ledger: %w", err)
	}
	defer f.Close()

	var records []ledgerRecord
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	lineNo := 0
	for scanner.Scan() {
		lineNo++
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		var r ledgerRecord
		if err := json.Unmarshal([]byte(line), &r); err != nil {
			return nil, fmt.Errorf("ledger line %d: %w", lineNo, err)
		}
		records = append(records, r)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("reading ledger: %w", err)
	}
	return records, nil
}

// overwritePasses overwrites the file's existing byte range with random
// data for the given number of passes, fsyncing after each so the pass
// actually reaches disk before the next one starts. Identical mechanism
// to PrivacySweep's shred — deliberately not reinvented here.
func overwritePasses(path string, size int64, passes int) error {
	f, err := os.OpenFile(path, os.O_WRONLY, 0)
	if err != nil {
		return err
	}
	defer f.Close()

	buf := make([]byte, 32*1024)
	for p := 0; p < passes; p++ {
		if _, err := f.Seek(0, io.SeekStart); err != nil {
			return err
		}
		var written int64
		for written < size {
			n := int64(len(buf))
			if remain := size - written; remain < n {
				n = remain
			}
			if _, err := rand.Read(buf[:n]); err != nil {
				return err
			}
			if _, err := f.Write(buf[:n]); err != nil {
				return err
			}
			written += n
		}
		if err := f.Sync(); err != nil {
			return err
		}
	}
	return nil
}

func hashFile(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
}
