package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"time"
)

// ---------------------------------------------------------------------------
// verify
//
// Re-hash everything the ledger claims landed, and report drift. This is the
// answer to "the move said it worked three weeks ago - is it still true?"
// ---------------------------------------------------------------------------

// Verification results.
const (
	vOK       = "ok"
	vMissing  = "missing"
	vResized  = "size-changed"
	vChanged  = "content-changed"
	vUnread   = "unreadable"
	vRecorded = "recorded-failure"
)

// VerifyItem is one ledger entry re-checked against the filesystem.
type VerifyItem struct {
	Rel      string `json:"rel"`
	DestRel  string `json:"dest_rel"`
	Dest     string `json:"dest_root"`
	Bytes    int64  `json:"ledger_bytes"`
	NowBytes int64  `json:"actual_bytes,omitempty"`
	SHA256   string `json:"ledger_sha256"`
	NowSHA   string `json:"actual_sha256,omitempty"`
	Result   string `json:"result"`
	Detail   string `json:"detail,omitempty"`
}

// VerifyReport is the whole re-check.
type VerifyReport struct {
	Ledger       string       `json:"ledger"`
	Entries      int          `json:"ledger_entries"`
	Checked      int          `json:"checked"`
	OK           int          `json:"ok"`
	Missing      int          `json:"missing"`
	Changed      int          `json:"content_changed"`
	Resized      int          `json:"size_changed"`
	Unreadable   int          `json:"unreadable"`
	RecordedFail int          `json:"recorded_failures"`
	Drift        int          `json:"drift"`
	Bytes        int64        `json:"bytes_verified"`
	Seconds      float64      `json:"elapsed_seconds"`
	Items        []VerifyItem `json:"items"`
}

func verifyLedger(path string) (*VerifyReport, error) {
	entries, err := loadLedger(path)
	if err != nil {
		return nil, err
	}
	rep := &VerifyReport{Ledger: path, Entries: len(entries)}
	start := time.Now()

	// Later entries for the same destination file supersede earlier ones: a
	// re-transferred item must be judged by its most recent record.
	latest := map[string]LedgerEntry{}
	var order []string
	for _, e := range entries {
		key := e.Dst + "\x00" + e.DestRel
		if _, ok := latest[key]; !ok {
			order = append(order, key)
		}
		latest[key] = e
	}
	sort.Strings(order)

	for _, key := range order {
		e := latest[key]
		vi := VerifyItem{
			Rel: e.Rel, DestRel: e.DestRel, Dest: e.Dst,
			Bytes: e.Bytes, SHA256: e.SHA256,
		}
		if e.Status == statusFailed {
			vi.Result = vRecorded
			vi.Detail = "the ledger already records this item as failed: " + e.Detail
			rep.RecordedFail++
			rep.Items = append(rep.Items, vi)
			continue
		}
		rep.Checked++
		target := filepath.Join(e.Dst, filepath.FromSlash(e.DestRel))
		info, serr := os.Stat(target)
		if serr != nil {
			if os.IsNotExist(serr) {
				vi.Result = vMissing
				vi.Detail = "the ledger says this was transferred, but it is not there now"
				rep.Missing++
			} else {
				vi.Result = vUnread
				vi.Detail = serr.Error()
				rep.Unreadable++
			}
			rep.Drift++
			rep.Items = append(rep.Items, vi)
			continue
		}
		vi.NowBytes = info.Size()
		got, n, herr := hashFile(target)
		if herr != nil {
			vi.Result = vUnread
			vi.Detail = herr.Error()
			rep.Unreadable++
			rep.Drift++
			rep.Items = append(rep.Items, vi)
			continue
		}
		vi.NowSHA = got
		rep.Bytes += n
		switch {
		case got == e.SHA256:
			vi.Result = vOK
			rep.OK++
		case n != e.Bytes:
			vi.Result = vResized
			vi.Detail = fmt.Sprintf("ledger recorded %d bytes, the file is now %d", e.Bytes, n)
			rep.Resized++
			rep.Drift++
		default:
			vi.Result = vChanged
			vi.Detail = fmt.Sprintf("same size, different content: ledger %s, now %s", short(e.SHA256), short(got))
			rep.Changed++
			rep.Drift++
		}
		rep.Items = append(rep.Items, vi)
	}
	rep.Seconds = time.Since(start).Seconds()
	return rep, nil
}

func cmdVerify(argv []string) {
	fs := newFlagSet("verify")
	ledger := fs.String("ledger", "", "ledger to verify")
	fs.StringVar(ledger, "l", "", "shorthand for --ledger")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *ledger == "" && fs.NArg() > 0 {
		*ledger = fs.Arg(0)
	}
	if *ledger == "" {
		usageErr("verify needs --ledger <file.jsonl>")
	}
	abs, err := filepath.Abs(*ledger)
	if err != nil {
		fail("cannot resolve ledger %q: %v", *ledger, err)
	}
	rep, err := verifyLedger(abs)
	if err != nil {
		if errors.Is(err, errNoLedger) {
			fmt.Fprintf(os.Stderr, "%s: no ledger file at %s\n", appName, abs)
			fmt.Fprintf(os.Stderr, "Run a transfer first, for example:\n")
			fmt.Fprintf(os.Stderr, "  %s transfer --src <old> --dst <new> --ledger %s --apply\n", appName, abs)
			os.Exit(1)
		}
		fail("%v", err)
	}

	if *asJSON {
		emitJSON(rep)
	} else {
		printVerify(rep)
	}
	if rep.Drift > 0 {
		os.Exit(2)
	}
}

func printVerify(rep *VerifyReport) {
	fmt.Printf("MovePhone verification\n")
	fmt.Printf("ledger    : %s (%d entries)\n", rep.Ledger, rep.Entries)
	fmt.Printf("re-hashed : %d files, %s in %s\n", rep.Checked, humanBytes(rep.Bytes),
		humanDuration(time.Duration(rep.Seconds*float64(time.Second))))
	fmt.Println()
	fmt.Printf("  %-18s %6d\n", vOK, rep.OK)
	fmt.Printf("  %-18s %6d\n", vMissing, rep.Missing)
	fmt.Printf("  %-18s %6d\n", vResized, rep.Resized)
	fmt.Printf("  %-18s %6d\n", vChanged, rep.Changed)
	fmt.Printf("  %-18s %6d\n", vUnread, rep.Unreadable)
	fmt.Printf("  %-18s %6d\n", vRecorded, rep.RecordedFail)
	fmt.Println()
	if rep.Drift == 0 {
		fmt.Printf("No drift. Every file the ledger claims was transferred still hashes exactly as recorded.\n")
		if rep.RecordedFail > 0 {
			fmt.Printf("Note: %d item(s) were already recorded as FAILED at transfer time and were not expected to be present.\n", rep.RecordedFail)
		}
		return
	}
	fmt.Printf("DRIFT DETECTED (%d)\n", rep.Drift)
	for _, it := range rep.Items {
		if it.Result == vOK || it.Result == vRecorded {
			continue
		}
		fmt.Printf("  [%s] %s\n      %s\n      %s\n", it.Result, it.DestRel,
			filepath.Join(it.Dest, filepath.FromSlash(it.DestRel)), it.Detail)
	}
}
