package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
)

// TestIsCharDeviceRejectsNonConsoles pins down the half of the check that can
// be tested without a terminal: a pipe, a regular file and a closed handle
// must all read as "not a console", because each of them means something other
// than a person waiting at a keyboard.
//
// Getting this wrong in this direction is the dangerous one: it would drop a
// script or a build pipeline into an interactive prompt and hang it forever.
func TestIsCharDeviceRejectsNonConsoles(t *testing.T) {
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("pipe: %v", err)
	}
	defer r.Close()
	defer w.Close()

	tmp, err := os.CreateTemp(t.TempDir(), "out")
	if err != nil {
		t.Fatalf("temp file: %v", err)
	}
	defer tmp.Close()

	devNull, err := os.Open(os.DevNull)
	if err != nil {
		t.Fatalf("open %s: %v", os.DevNull, err)
	}
	defer devNull.Close()

	for _, tc := range []struct {
		name string
		file *os.File
		want bool
	}{
		{"read end of a pipe", r, false},
		{"write end of a pipe", w, false},
		{"regular file", tmp, false},
		// os.DevNull IS a character device on Unix, so this case documents a
		// real limitation rather than asserting a value that differs by
		// platform: `prog < /dev/null` on Unix looks interactive on stdin.
		// It is harmless, because stdout must ALSO be a console, and a script
		// redirecting stdin almost always redirects stdout too.
	} {
		if got := isCharDevice(tc.file); got != tc.want {
			t.Errorf("isCharDevice(%s) = %v, want %v", tc.name, got, tc.want)
		}
	}
	_ = devNull
}

// TestIsCharDeviceHandlesAClosedFile makes sure a handle we cannot stat is
// treated as "not a console" rather than panicking or defaulting to true.
func TestIsCharDeviceHandlesAClosedFile(t *testing.T) {
	f, err := os.CreateTemp(t.TempDir(), "closed")
	if err != nil {
		t.Fatalf("temp file: %v", err)
	}
	f.Close()
	if isCharDevice(f) {
		t.Error("isCharDevice(closed file) = true, want false")
	}
}

// TestInteractiveConsoleNeedsBothEnds asserts the AND: one console end is not
// enough. Under `go test` stdout is not a terminal, so this must be false.
func TestInteractiveConsoleNeedsBothEnds(t *testing.T) {
	if interactiveConsole() {
		t.Error("interactiveConsole() = true under `go test`, where output is captured; " +
			"the guided prompt would hang any non-interactive run")
	}
}

// TestSuggestedVaultIsRealOrEmpty guards the one-keypress default: whatever
// vault it offers must actually exist and be a file, or pressing Enter greets
// the reader with an error on their very first interaction.
func TestSuggestedVaultIsRealOrEmpty(t *testing.T) {
	got := suggestedVault()
	if got == "" {
		return // no vault lying around; the prompt asks for one instead
	}
	info, err := os.Stat(got)
	if err != nil {
		t.Fatalf("suggestedVault() = %q, which does not exist: %v", got, err)
	}
	if info.IsDir() {
		t.Errorf("suggestedVault() = %q, which is a directory, not a vault file", got)
	}
}

// TestVaultUnlocksRejectsRubbish pins the pre-flight password check that keeps
// a wrong answer from reaching the code that exits the process — which, in a
// double-clicked window, means the window disappearing mid-question.
func TestVaultUnlocksRejectsRubbish(t *testing.T) {
	dir := t.TempDir()
	notAVault := filepath.Join(dir, "notes.vz")
	if err := os.WriteFile(notAVault, []byte("this is not a vault at all"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	if err := vaultUnlocks(notAVault, "hunter2"); err == nil {
		t.Error("vaultUnlocks(non-vault) = nil, want an error")
	}
	if looksLikeVault(notAVault) {
		t.Error("looksLikeVault(non-vault) = true, want false")
	}
	if err := vaultUnlocks(filepath.Join(dir, "nothing-here.vz"), "hunter2"); err == nil {
		t.Error("vaultUnlocks(missing file) = nil, want an error")
	}
}

// captureStdout runs fn with stdout redirected and returns what it printed.
// Both pack and its dry run report through fmt.Printf, so the only way to test
// what they tell the reader is to read it.
func captureStdout(t *testing.T, fn func()) string {
	t.Helper()
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("pipe: %v", err)
	}
	saved := os.Stdout
	os.Stdout = w

	done := make(chan string, 1)
	go func() {
		var sb strings.Builder
		io.Copy(&sb, r)
		done <- sb.String()
	}()

	fn()

	os.Stdout = saved
	w.Close()
	out := <-done
	r.Close()
	return out
}

// tree lists every path under dir with its size, so a test can assert that a
// command left the place exactly as it found it.
func tree(t *testing.T, dir string) []string {
	t.Helper()
	var out []string
	err := filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(dir, path)
		if err != nil {
			return err
		}
		if fi.IsDir() {
			out = append(out, rel+"/")
			return nil
		}
		out = append(out, fmt.Sprintf("%s|%d", rel, fi.Size()))
		return nil
	})
	if err != nil {
		t.Fatalf("walk %s: %v", dir, err)
	}
	sort.Strings(out)
	return out
}

// packFixture builds a small folder to pack and returns the workspace root, the
// folder inside it, and the vault path that packing would write.
func packFixture(t *testing.T) (root, src, vault string) {
	t.Helper()
	root = t.TempDir()
	src = filepath.Join(root, "Records")
	if err := os.MkdirAll(filepath.Join(src, "invoices"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(src, "notes.txt"), []byte("0123456789"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	if err := os.WriteFile(filepath.Join(src, "invoices", "april.csv"), []byte("a,b\n1,2\n"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	return root, src, filepath.Join(root, "Records.vz")
}

// TestPackDryRunWritesNothing is the reason --dry-run exists: it must be able to
// say what packing would store without storing it. The assertion is deliberately
// the whole workspace rather than just the vault path, because a half-written
// vault under a temporary name, or a stray file beside the target, would be just
// as much of a broken promise as the vault itself appearing.
func TestPackDryRunWritesNothing(t *testing.T) {
	root, src, vault := packFixture(t)
	before := tree(t, root)

	out := captureStdout(t, func() {
		cmdPack([]string{src, "-o", vault, "--password", "hunter2", "--dry-run"})
	})

	if after := tree(t, root); !reflect.DeepEqual(before, after) {
		t.Errorf("the dry run changed the workspace\nbefore: %v\n after: %v", before, after)
	}
	if _, err := os.Stat(vault); !os.IsNotExist(err) {
		t.Errorf("the dry run left something at %s (stat error was %v, wanted 'does not exist')", vault, err)
	}

	// It has to list the files, and it has to give their sizes, or there is
	// nothing in the preview worth reading.
	for _, want := range []string{
		"Records/notes.txt", "Records/invoices/april.csv", "10 B", "8 B",
		"2 files", "nothing has been written",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("the dry run never mentioned %q; it said:\n%s", want, out)
		}
	}
	if strings.Contains(out, "Vault written") {
		t.Errorf("the dry run claimed to have written a vault:\n%s", out)
	}
}

// TestPackDryRunWarnsAboutReplacingAVault covers the risk that made packing a
// dangerous action in the first place: an existing vault of the same name goes
// without a word. The dry run has to be the word.
func TestPackDryRunWarnsAboutReplacingAVault(t *testing.T) {
	_, src, vault := packFixture(t)
	if err := os.WriteFile(vault, []byte("an older vault"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	sum := func() []byte {
		b, err := os.ReadFile(vault)
		if err != nil {
			t.Fatalf("read: %v", err)
		}
		return b
	}
	before := sum()

	out := captureStdout(t, func() {
		cmdPack([]string{src, "-o", vault, "--password", "hunter2", "--dry-run"})
	})

	if !bytes.Equal(before, sum()) {
		t.Error("the dry run overwrote the vault it was warning about")
	}
	if !strings.Contains(out, "would be replaced") {
		t.Errorf("no warning that the existing vault would be replaced:\n%s", out)
	}
}

// TestPackStillPacksAndVerifyAcceptsIt is the other half: adding a dry run must
// not have taken anything away. Without the flag, the same command must produce
// a real vault, and verify must accept it — which exercises the shared walk in
// its writing mode and proves the refactor did not corrupt what it stores.
func TestPackStillPacksAndVerifyAcceptsIt(t *testing.T) {
	_, src, vault := packFixture(t)

	packOut := captureStdout(t, func() {
		cmdPack([]string{src, "-o", vault, "--password", "hunter2"})
	})
	if !strings.Contains(packOut, "Vault written") {
		t.Fatalf("pack did not report writing a vault:\n%s", packOut)
	}
	info, err := os.Stat(vault)
	if err != nil {
		t.Fatalf("pack wrote no vault at %s: %v", vault, err)
	}
	if info.Size() == 0 {
		t.Fatal("pack wrote an empty vault")
	}

	verifyOut := captureStdout(t, func() {
		cmdUnpack([]string{vault, "--password", "hunter2"}, false)
	})
	if !strings.Contains(verifyOut, "integrity confirmed") {
		t.Errorf("verify would not accept the vault pack just wrote:\n%s", verifyOut)
	}
	for _, want := range []string{"Records/notes.txt", "Records/invoices/april.csv", "2 files"} {
		if !strings.Contains(verifyOut, want) {
			t.Errorf("verify never mentioned %q; it said:\n%s", want, verifyOut)
		}
	}
}

// TestPackDryRunAndRealPackListTheSameFiles pins the promise the shared walk
// exists to keep. If these two ever disagree, the preview is lying about what
// the run after it will do, and this test is what says so.
func TestPackDryRunAndRealPackListTheSameFiles(t *testing.T) {
	_, src, vault := packFixture(t)

	dry := captureStdout(t, func() {
		cmdPack([]string{src, "-o", vault, "--password", "hunter2", "--dry-run"})
	})
	real := captureStdout(t, func() {
		cmdPack([]string{src, "-o", vault, "--password", "hunter2"})
	})

	names := func(out, prefix string) []string {
		var got []string
		for _, line := range strings.Split(out, "\n") {
			line = strings.TrimSpace(line)
			if !strings.HasPrefix(line, prefix) {
				continue
			}
			rest := strings.TrimSpace(strings.TrimPrefix(line, prefix))
			if i := strings.LastIndex(rest, " ("); i >= 0 {
				rest = rest[:i]
			}
			got = append(got, rest)
		}
		sort.Strings(got)
		return got
	}

	want := names(real, "add ")
	got := names(dry, "would add ")
	if len(want) == 0 {
		t.Fatalf("could not read any file names out of the real pack:\n%s", real)
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("the dry run and the real pack disagree about what goes in\n dry run: %v\nreal run: %v", got, want)
	}
}
