package main

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

// This file is MoveGuard's dry run: the answer to "what would `move` do?",
// given without doing any of it.
//
// MoveGuard needs one more than most tools do. Its single command deletes the
// customer's source files once their copies verify, so there is no safe way to
// let somebody try it and see. Without a dry run the only two things that could
// be offered were a run that destroys data with no preview, or a "preview" that
// really performed the move — and a preview that does the thing is worse than
// none, because it is believed.
//
// The rule followed throughout: a dry run that says "fine" where the real run
// would stop is worse than no dry run at all. So every check below is the
// question the real run asks at the moment it would act, asked as cheaply as it
// can be asked, rather than a guess made from what a file looks like.

// dryRow is everything a dry run works out about one file.
type dryRow struct {
	relPath string
	dstPath string
	size    int64

	// problem is set when the real run would report an error for this file
	// and move nothing.
	problem string

	// overwrite is set when the move would succeed but would destroy
	// something already at the destination to do it. The real run does this
	// silently, which is exactly why the dry run says it out loud.
	overwrite string

	// resumeSkip marks a file a --resume run would not re-copy, because an
	// identical copy is already at the destination.
	resumeSkip bool
}

// dryRunMove reports what moving jobs would do, and returns the exit code.
//
// It creates no copies, makes no folders, renames nothing and deletes nothing.
// The single exception is deliberate and is described on probeWritable: to
// answer "can anything be written here at all?" truthfully it briefly creates
// and immediately removes one empty check file per destination folder. That is
// the only way to ask the question on Windows, where a folder's permissions
// cannot be read off it, and answering it wrongly is the failure this whole
// file exists to prevent.
//
// workers and resume mean the same things they mean for a real move: the files
// are inspected in parallel, and under resume a destination copy that already
// matches is recognised rather than reported as something in the way.
func dryRunMove(jobs []job, workers int, resume bool) int {
	if workers < 1 {
		workers = 1
	}
	checker := newDestChecker()

	jobCh := make(chan job)
	rowCh := make(chan dryRow)
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobCh {
				rowCh <- inspectJob(j, resume, checker)
			}
		}()
	}
	go func() {
		for _, j := range jobs {
			jobCh <- j
		}
		close(jobCh)
		wg.Wait()
		close(rowCh)
	}()

	rows := make([]dryRow, 0, len(jobs))
	for r := range rowCh {
		rows = append(rows, r)
	}
	// Workers finish in whatever order the disk gives them back, so sort for
	// a stable, readable report.
	sort.Slice(rows, func(i, j int) bool { return rows[i].relPath < rows[j].relPath })

	fmt.Println("Dry run — nothing will be copied, moved or deleted.")
	fmt.Println()

	var total int64
	var moves, problems, overwrites, skips int
	for _, r := range rows {
		switch {
		case r.problem != "":
			problems++
			fmt.Printf("  cannot move    %s -> %s — %s\n", r.relPath, r.dstPath, r.problem)
		case r.overwrite != "":
			overwrites++
			moves++
			total += r.size
			fmt.Printf("  warning        %s (%s) -> %s — %s\n",
				r.relPath, humanBytes(r.size), r.dstPath, r.overwrite)
		case r.resumeSkip:
			skips++
			fmt.Printf("  already there  %s (%s) -> %s — an identical copy is already there, so only the original here would be removed\n",
				r.relPath, humanBytes(r.size), r.dstPath)
		default:
			moves++
			total += r.size
			fmt.Printf("  would move     %s (%s) -> %s\n", r.relPath, humanBytes(r.size), r.dstPath)
		}
	}

	// The summary lines deliberately avoid the words "would move", which the
	// window reads as "this is a line describing the preview" and shows in the
	// preview's own colour. That is right for the per-file lines above, where it
	// is the whole point of the screen, and wrong for the closing verdict, which
	// needs to read as good news, a warning or a failure.
	fmt.Println()
	switch {
	case problems == 0 && overwrites == 0:
		fmt.Printf("Everything checks out: %d file(s) can be moved, %s in total",
			moves, humanBytes(total))
		if skips > 0 {
			fmt.Printf(", and %d already at the destination would just have the original removed", skips)
		}
		fmt.Println(".")
	case problems == 0:
		fmt.Printf("warning: %d file(s) can be moved, %s in total, but %d of them would be written over something already at the destination.\n",
			moves, humanBytes(total), overwrites)
	default:
		fmt.Printf("%d of %d file(s) would fail and would not be moved. %d file(s) can be moved",
			problems, len(rows), moves)
		if overwrites > 0 {
			fmt.Printf(", %d of them over something already at the destination", overwrites)
		}
		fmt.Println(".")
	}
	fmt.Println("Nothing on disk was changed. Run the same command again without --dry-run to carry the move out.")

	if problems > 0 || overwrites > 0 {
		return 2
	}
	return 0
}

// inspectJob asks, for one file, everything the real move would find out the
// hard way.
func inspectJob(j job, resume bool, checker *destChecker) dryRow {
	row := dryRow{relPath: j.relPath, dstPath: j.dstPath}

	info, err := os.Stat(j.srcPath)
	if err != nil {
		row.problem = "cannot be read: " + reason(err)
		return row
	}
	row.size = info.Size()

	// The real run reads the whole file through to hash it, so a source it
	// cannot open is a source it cannot move. Opening and closing it here asks
	// the same question without reading the file twice for nothing.
	f, err := os.Open(j.srcPath)
	if err != nil {
		row.problem = "cannot be opened for reading: " + reason(err)
		return row
	}
	f.Close()

	if dstInfo, err := os.Stat(j.dstPath); err == nil {
		switch {
		case dstInfo.IsDir():
			// The real run copies to a temp name and then renames onto this
			// path, which cannot replace a directory: it fails here.
			row.problem = "a folder is already sitting where this file would go, so the move would fail"
			return row
		case identicalFiles(j.srcPath, j.dstPath):
			// The same bytes are already there. Under --resume that is work a
			// previous run finished, and the move skips the copy. Without it the
			// move copies over the top again, which is harmless but is still
			// worth saying, because "already there" and "about to be replaced"
			// look identical from the outside.
			if resume {
				row.resumeSkip = true
			} else {
				row.overwrite = fmt.Sprintf("an identical copy (%s) is already there and would be written over again",
					humanBytes(dstInfo.Size()))
			}
		default:
			row.overwrite = fmt.Sprintf("a different file (%s) is already there and would be written over",
				humanBytes(dstInfo.Size()))
		}
	}

	if prob := checker.check(filepath.Dir(j.dstPath)); prob != "" && row.problem == "" {
		row.problem = prob
	}
	return row
}

// identicalFiles reports whether two files hold exactly the same bytes, by the
// same SHA-256 comparison the real move trusts before it deletes anything.
func identicalFiles(a, b string) bool {
	ha, err := hashFile(a)
	if err != nil {
		return false
	}
	hb, err := hashFile(b)
	if err != nil {
		return false
	}
	return ha == hb
}

// destChecker answers "could the real run put a file in this folder?" once per
// folder however many files are headed for it, so a ten-thousand-file move does
// a handful of checks rather than ten thousand.
type destChecker struct {
	mu   sync.Mutex
	seen map[string]string
}

func newDestChecker() *destChecker { return &destChecker{seen: make(map[string]string)} }

func (d *destChecker) check(dir string) string {
	d.mu.Lock()
	defer d.mu.Unlock()
	if prob, ok := d.seen[dir]; ok {
		return prob
	}
	prob := checkDestDir(dir)
	d.seen[dir] = prob
	return prob
}

// checkDestDir works out whether a file could be created at dir — without
// creating dir itself. The real run makes missing folders as it goes, so the
// real question is whether the nearest folder that DOES exist would accept
// something new inside it.
func checkDestDir(dir string) string {
	existing := dir
	for {
		info, err := os.Stat(existing)
		if err == nil {
			if !info.IsDir() {
				return "cannot make the folder " + dir + ": " + existing + " is a file, not a folder"
			}
			break
		}
		parent := filepath.Dir(existing)
		if parent == existing {
			return "cannot reach " + dir + ": none of it exists, and neither does the drive it would be on"
		}
		existing = parent
	}
	return probeWritable(existing, dir)
}

// probeWritable is the one place this file writes anything.
//
// Whether a folder will accept a new file is not something that can be read off
// it. On Windows the permissions that actually decide are access control lists,
// which a stat cannot see, and a folder can look perfectly writable and refuse
// the first write. The only truthful answer comes from trying — so this creates
// one empty file with an unmistakable name, closes it and removes it again
// straight away.
//
// It is exclusive-create, so it can never touch a file of the customer's that
// happens to be there, and if the removal ever fails that is reported as a
// problem rather than passed over, because leaving a stray file behind is the
// one thing a dry run must not do.
func probeWritable(existing, wanted string) string {
	probe := filepath.Join(existing, fmt.Sprintf(".moveguard-write-check-%d-%d", os.Getpid(), time.Now().UnixNano()))
	f, err := os.OpenFile(probe, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
	if err != nil {
		where := existing
		if wanted != existing {
			where = existing + ", which is where the move would have to start making " + wanted
		}
		return "cannot write into " + where + ": " + reason(err)
	}
	f.Close()
	if err := os.Remove(probe); err != nil {
		return "could not remove the empty check file it just wrote, so please delete " + probe + " by hand: " + reason(err)
	}
	return ""
}

// reason is the useful half of a file system error. The full text of one names
// the operation and the path that failed, which for the write check means it
// would quote the check file's own scratch name at somebody who never knew it
// existed — "cannot write into D:\out: open D:\out\.moveguard-write-check-...:
// permission denied". Only the last part answers the question.
func reason(err error) string {
	var pathErr *os.PathError
	if errors.As(err, &pathErr) {
		return pathErr.Err.Error()
	}
	return err.Error()
}
