package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
)

// These tests exist because MoveGuard's dry run is load-bearing in a way most
// are not. It is the only preview the tool can offer — the real command deletes
// the customer's originals — so the two claims made for it have to be checked
// rather than assumed: that running it changes nothing at all, and that what it
// says about a move is what the move would really do.

// snapshot records every file under root with its exact bytes, so a tree can be
// compared with itself later.
func snapshot(t *testing.T, root string) map[string]string {
	t.Helper()
	out := map[string]string{}
	err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		if fi.IsDir() {
			out[filepath.ToSlash(rel)+"/"] = "<dir>"
			return nil
		}
		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
		}
		out[filepath.ToSlash(rel)] = fmt.Sprintf("%d:%s", fi.Size(), hex.EncodeToString(h.Sum(nil)))
		return nil
	})
	if err != nil {
		t.Fatalf("snapshotting %s: %v", root, err)
	}
	return out
}

func diffSnapshots(t *testing.T, what string, before, after map[string]string) {
	t.Helper()
	for name, was := range before {
		now, still := after[name]
		switch {
		case !still:
			t.Errorf("%s: %s disappeared", what, name)
		case now != was:
			t.Errorf("%s: %s changed (%s -> %s)", what, name, was, now)
		}
	}
	var added []string
	for name := range after {
		if _, had := before[name]; !had {
			added = append(added, name)
		}
	}
	sort.Strings(added)
	if len(added) > 0 {
		t.Errorf("%s: these appeared, so something was written: %s", what, strings.Join(added, ", "))
	}
}

func write(t *testing.T, path, content string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
}

// captureStdout runs fn with stdout redirected, and returns what it printed.
// The dry run reports to stdout, so this is how its report gets read.
func captureStdout(t *testing.T, fn func()) string {
	t.Helper()
	tmp, err := os.CreateTemp(t.TempDir(), "stdout")
	if err != nil {
		t.Fatal(err)
	}
	defer tmp.Close()
	saved := os.Stdout
	os.Stdout = tmp
	fn()
	os.Stdout = saved
	if _, err := tmp.Seek(0, io.SeekStart); err != nil {
		t.Fatal(err)
	}
	out, err := io.ReadAll(tmp)
	if err != nil {
		t.Fatal(err)
	}
	return string(out)
}

func planFor(t *testing.T, src, dst string) []job {
	t.Helper()
	jobs, err := planJobs(src, dst)
	if err != nil {
		t.Fatalf("planJobs(%s, %s): %v", src, dst, err)
	}
	return jobs
}

// TestDryRunLeavesBothTreesByteForByte is the whole promise of the flag. A move
// that has not been asked for must not have started: no copy, no temp file, no
// created folder, and above all no deleted original.
func TestDryRunLeavesBothTreesByteForByte(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "dst")

	write(t, filepath.Join(src, "notes.txt"), "one")
	write(t, filepath.Join(src, "deep", "inner", "photo.raw"), strings.Repeat("x", 4096))
	write(t, filepath.Join(src, "deep", "sheet.csv"), "a,b,c\n")
	// Something already at the destination, which must also survive untouched.
	write(t, filepath.Join(dst, "unrelated.txt"), "leave me alone")

	beforeSrc := snapshot(t, src)
	beforeDst := snapshot(t, dst)

	out := captureStdout(t, func() {
		dryRunMove(planFor(t, src, dst), 4, false)
	})

	diffSnapshots(t, "source tree", beforeSrc, snapshot(t, src))
	diffSnapshots(t, "destination tree", beforeDst, snapshot(t, dst))

	// The check file probeWritable creates must never outlive the call.
	for _, root := range []string{src, dst} {
		filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
			if err == nil && strings.Contains(fi.Name(), "moveguard") {
				t.Errorf("the dry run left %s behind", path)
			}
			return nil
		})
	}

	// And it has to actually have reported the move, or it proved nothing.
	for _, want := range []string{"notes.txt", "photo.raw", "sheet.csv", "would move"} {
		if !strings.Contains(out, want) {
			t.Errorf("the dry run report never mentions %q:\n%s", want, out)
		}
	}
	if !strings.Contains(out, "4.0 KiB") {
		t.Errorf("the dry run report gives no size for the 4096-byte file:\n%s", out)
	}
	if !strings.Contains(out, filepath.Join(dst, "notes.txt")) {
		t.Errorf("the dry run report never says where notes.txt would go:\n%s", out)
	}
}

// TestRealRunStillMoves guards the other direction: adding the flag must not
// have disturbed the thing the tool is for. Sources gone, destinations present
// and identical.
func TestRealRunStillMoves(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "dst")
	write(t, filepath.Join(src, "notes.txt"), "one")
	write(t, filepath.Join(src, "deep", "sheet.csv"), "a,b,c\n")

	// A dry run first, exactly as the window does it, to prove the real run
	// that follows is unaffected by having been previewed.
	captureStdout(t, func() { dryRunMove(planFor(t, src, dst), 4, false) })

	for _, j := range planFor(t, src, dst) {
		if r := moveVerify(j, false); r.status != "moved" {
			t.Fatalf("%s: status %q (%s), want moved", j.relPath, r.status, r.detail)
		}
	}

	for rel, want := range map[string]string{"notes.txt": "one", "deep/sheet.csv": "a,b,c\n"} {
		got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
		if err != nil {
			t.Errorf("%s never arrived: %v", rel, err)
			continue
		}
		if string(got) != want {
			t.Errorf("%s arrived as %q, want %q", rel, got, want)
		}
		if _, err := os.Stat(filepath.Join(src, filepath.FromSlash(rel))); !os.IsNotExist(err) {
			t.Errorf("%s is still at the source after a real move", rel)
		}
	}
}

// TestDryRunSpotsACollisionTheRealRunWouldHit is the test that makes the dry run
// worth having. A file already at the destination is overwritten by the real
// move without a word, so the dry run has to be the thing that says so — and
// the second half of this test confirms the warning was about something real.
func TestDryRunSpotsACollisionTheRealRunWouldHit(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "dst")
	write(t, filepath.Join(src, "report.txt"), "the new one")
	write(t, filepath.Join(dst, "report.txt"), "THE OLD ONE, WHICH MATTERS")

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, dst), 4, false); code != 2 {
			t.Errorf("exit code %d for a report containing a collision, want 2", code)
		}
	})

	if !strings.Contains(out, "written over") {
		t.Errorf("the dry run did not warn that report.txt would be written over:\n%s", out)
	}
	if !strings.Contains(out, "report.txt") {
		t.Errorf("the dry run did not name the colliding file:\n%s", out)
	}

	// The dry run must not have acted on what it found, either.
	if got, _ := os.ReadFile(filepath.Join(dst, "report.txt")); string(got) != "THE OLD ONE, WHICH MATTERS" {
		t.Errorf("the dry run itself overwrote the destination: %q", got)
	}

	// Now prove the collision was real: the move does overwrite it.
	for _, j := range planFor(t, src, dst) {
		if r := moveVerify(j, false); r.status != "moved" {
			t.Fatalf("real move: status %q (%s)", r.status, r.detail)
		}
	}
	if got, _ := os.ReadFile(filepath.Join(dst, "report.txt")); string(got) != "the new one" {
		t.Errorf("after the real move the destination holds %q; the dry run's warning "+
			"described something that does not happen", got)
	}
}

// TestDryRunReportsAFolderInTheWay covers the collision that makes the real run
// fail outright rather than overwrite: a directory sitting on the path a file
// needs.
func TestDryRunReportsAFolderInTheWay(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "dst")
	write(t, filepath.Join(src, "report.txt"), "hello")
	if err := os.MkdirAll(filepath.Join(dst, "report.txt"), 0o755); err != nil {
		t.Fatal(err)
	}

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, dst), 1, false); code != 2 {
			t.Errorf("exit code %d, want 2", code)
		}
	})
	if !strings.Contains(out, "cannot move") || !strings.Contains(out, "folder is already sitting") {
		t.Errorf("the dry run did not report the folder in the way:\n%s", out)
	}

	// And the real run does indeed fail on it, source preserved.
	for _, j := range planFor(t, src, dst) {
		if r := moveVerify(j, false); r.status == "moved" {
			t.Error("the real move claims to have moved a file onto a folder")
		}
	}
	if _, err := os.Stat(filepath.Join(src, "report.txt")); err != nil {
		t.Errorf("the source was lost to a failed move: %v", err)
	}
}

// TestDryRunUnderResumeRecognisesWhatIsAlreadyThere checks --resume keeps its
// meaning in a dry run: an identical copy at the destination is not something in
// the way, it is work already done.
func TestDryRunUnderResumeRecognisesWhatIsAlreadyThere(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "dst")
	write(t, filepath.Join(src, "half-done.txt"), "same bytes")
	write(t, filepath.Join(dst, "half-done.txt"), "same bytes")

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, dst), 2, true); code != 0 {
			t.Errorf("exit code %d for a resumable move with nothing wrong, want 0", code)
		}
	})
	if !strings.Contains(out, "already there") {
		t.Errorf("a resume dry run did not recognise the finished copy:\n%s", out)
	}
	if strings.Contains(out, "written over") {
		t.Errorf("a resume dry run called a finished copy a collision:\n%s", out)
	}

	// Without --resume the same pair is a collision, since the real run would
	// re-copy over it.
	out = captureStdout(t, func() { dryRunMove(planFor(t, src, dst), 2, false) })
	if !strings.Contains(out, "written over") {
		t.Errorf("without resume, an existing destination file should be reported:\n%s", out)
	}
}

// TestDryRunReportsAnUnwritableDestination is the check that cannot be made by
// looking: a folder that will refuse the write.
func TestDryRunReportsAnUnwritableDestination(t *testing.T) {
	if os.Geteuid() == 0 {
		t.Skip("running as root, which is allowed to write into a read-only folder")
	}
	base := t.TempDir()
	src := filepath.Join(base, "src")
	dst := filepath.Join(base, "locked")
	write(t, filepath.Join(src, "a.txt"), "hello")
	if err := os.Mkdir(dst, 0o555); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { os.Chmod(dst, 0o755) })

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, dst), 1, false); code != 2 {
			t.Errorf("exit code %d for an unwritable destination, want 2", code)
		}
	})
	if !strings.Contains(out, "cannot write into") {
		t.Errorf("the dry run said nothing about the unwritable destination:\n%s", out)
	}
}

// TestDryRunReportsAFileWhereTheFolderMustGo covers the other structural
// failure: something on the destination path that is not a folder, which stops
// the real run from being able to create the tree at all.
func TestDryRunReportsAFileWhereTheFolderMustGo(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	write(t, filepath.Join(src, "a.txt"), "hello")
	blocker := filepath.Join(base, "blocker")
	write(t, blocker, "I am a file, not a folder")

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, filepath.Join(blocker, "inside")), 1, false); code != 2 {
			t.Errorf("exit code %d, want 2", code)
		}
	})
	if !strings.Contains(out, "is a file, not a folder") {
		t.Errorf("the dry run did not report the file blocking the destination folder:\n%s", out)
	}
}

// TestDryRunOnACleanMoveExitsZero pins the ordinary case, so the flag can be
// used in a script to ask "would this work?".
func TestDryRunOnACleanMoveExitsZero(t *testing.T) {
	base := t.TempDir()
	src := filepath.Join(base, "src")
	write(t, filepath.Join(src, "a.txt"), "hello")

	out := captureStdout(t, func() {
		if code := dryRunMove(planFor(t, src, filepath.Join(base, "dst")), 4, false); code != 0 {
			t.Errorf("exit code %d for a clean move, want 0", code)
		}
	})
	if !strings.Contains(out, "Everything checks out") {
		t.Errorf("a clean dry run does not say so:\n%s", out)
	}
	// The destination does not exist yet and must not have been created.
	if _, err := os.Stat(filepath.Join(base, "dst")); !os.IsNotExist(err) {
		t.Error("the dry run created the destination folder")
	}
}

// TestDryRunIsInTheHelp keeps the flag documented. A dry run nobody is told
// about is a debug switch, and this one is meant to be part of the tool.
func TestDryRunIsInTheHelp(t *testing.T) {
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatal(err)
	}
	saved := os.Stderr
	os.Stderr = w
	usage()
	os.Stderr = saved
	w.Close()
	out, err := io.ReadAll(r)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(out), "--dry-run") {
		t.Errorf("--help does not mention --dry-run:\n%s", out)
	}
}

// TestDryRunFlagSurvivesFlagReordering checks the flag works where a customer
// would actually type it, after the two paths.
func TestDryRunFlagSurvivesFlagReordering(t *testing.T) {
	got := reorderFlags([]string{"/src", "/dst", "--dry-run"}, map[string]bool{"workers": true})
	want := []string{"--dry-run", "/src", "/dst"}
	if strings.Join(got, " ") != strings.Join(want, " ") {
		t.Errorf("reorderFlags gave %v, want %v", got, want)
	}
	got = reorderFlags([]string{"/src", "/dst", "--dry-run", "--workers", "8"}, map[string]bool{"workers": true})
	want = []string{"--dry-run", "--workers", "8", "/src", "/dst"}
	if strings.Join(got, " ") != strings.Join(want, " ") {
		t.Errorf("reorderFlags gave %v, want %v", got, want)
	}
}
