package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"
)

// ---------------------------------------------------------------- helpers ---

// fakeProgram writes a deterministic pseudo-binary for a slug into a source
// directory laid out the way a real download folder is.
func fakeProgram(t *testing.T, dir, slug string, seed byte, size int) string {
	t.Helper()
	body := make([]byte, size)
	for i := range body {
		body[i] = seed + byte(i%251)
	}
	ext := ""
	if runtime.GOOS == "windows" {
		ext = ".exe"
	}
	sub := filepath.Join(dir, slug)
	if err := os.MkdirAll(sub, 0o755); err != nil {
		t.Fatal(err)
	}
	p := filepath.Join(sub, fmt.Sprintf("%s-%s-%s%s", slug, runtime.GOOS, runtime.GOARCH, ext))
	if err := os.WriteFile(p, body, 0o755); err != nil {
		t.Fatal(err)
	}
	return p
}

func newSourceDir(t *testing.T, slugs ...string) string {
	t.Helper()
	dir := t.TempDir()
	for i, s := range slugs {
		fakeProgram(t, dir, s, byte(i*7+1), 4096+i*13)
	}
	return dir
}

func newRoot(t *testing.T) *Root {
	t.Helper()
	r, err := confirmRoot(filepath.Join(t.TempDir(), "techlosoft"))
	if err != nil {
		t.Fatal(err)
	}
	return r
}

func mustSource(t *testing.T, dir string) *Source {
	t.Helper()
	s, err := openSource(dir)
	if err != nil {
		t.Fatal(err)
	}
	return s
}

func sha256Of(t *testing.T, path string) string {
	t.Helper()
	h, err := hashFile(path)
	if err != nil {
		t.Fatal(err)
	}
	return h
}

// -------------------------------------------------------- the .tsb format ---

// A bundle must give back exactly the bytes that went in. Not "equivalent",
// not "same length" -- identical, because these are executables.
func TestBundleRoundTripsByteIdentically(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse", "dupepilot", "grabflow")
	src := mustSource(t, srcDir)

	var items []SourceFile
	originals := map[string][]byte{}
	for _, slug := range []string{"drivepulse", "dupepilot", "grabflow"} {
		sf, err := src.find(slug)
		if err != nil {
			t.Fatal(err)
		}
		items = append(items, sf)
		b, err := os.ReadFile(sf.Path)
		if err != nil {
			t.Fatal(err)
		}
		originals[slug] = b
	}

	out := filepath.Join(t.TempDir(), "suite.tsb")
	if err := writeBundle(out, items, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatal(err)
	}

	b, err := openBundle(out)
	if err != nil {
		t.Fatalf("openBundle: %v", err)
	}
	defer b.Close()

	if got := len(b.TOC.Entries); got != 3 {
		t.Fatalf("table of contents has %d entries, want 3", got)
	}
	for _, e := range b.TOC.Entries {
		buf := new(bytes.Buffer)
		if _, err := buf.ReadFrom(b.reader(e)); err != nil {
			t.Fatal(err)
		}
		if !bytes.Equal(buf.Bytes(), originals[e.Slug]) {
			t.Errorf("%s: %d bytes out of the bundle differ from the %d bytes that went in",
				e.Slug, buf.Len(), len(originals[e.Slug]))
		}
		sum := sha256.Sum256(buf.Bytes())
		if hex.EncodeToString(sum[:]) != e.SHA256 {
			t.Errorf("%s: payload hash does not match the table of contents", e.Slug)
		}
		if e.Size != int64(len(originals[e.Slug])) {
			t.Errorf("%s: table of contents says %d bytes, original is %d", e.Slug, e.Size, len(originals[e.Slug]))
		}
	}

	// And installing out of the bundle must reproduce the files on disk.
	root := newRoot(t)
	db, _ := root.loadInstalled()
	for _, e := range b.TOC.Entries {
		res := root.installOne(db, payloadFromBundle(b, e, out), false)
		if res.Action != "installed" {
			t.Fatalf("%s: %s %s", e.Slug, res.Action, res.Error)
		}
		got, err := os.ReadFile(root.programPath(e.Slug))
		if err != nil {
			t.Fatal(err)
		}
		if !bytes.Equal(got, originals[e.Slug]) {
			t.Errorf("%s: installed file differs from the original", e.Slug)
		}
	}
}

// The header is checked before anything is trusted.
func TestBundleRejectsGarbageAndBadMagic(t *testing.T) {
	dir := t.TempDir()
	bad := filepath.Join(dir, "not-a-bundle.tsb")
	if err := os.WriteFile(bad, []byte("this is a text file, honestly"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := openBundle(bad); !errors.Is(err, errBadMagic) {
		t.Fatalf("expected a bad-magic refusal, got %v", err)
	}

	empty := filepath.Join(dir, "empty.tsb")
	if err := os.WriteFile(empty, nil, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := openBundle(empty); err == nil {
		t.Fatal("an empty file was accepted as a bundle")
	}
}

// A single flipped bit in a payload must stop the install dead.
func TestCorruptedPayloadIsRefusedByHash(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, err := src.find("drivepulse")
	if err != nil {
		t.Fatal(err)
	}
	out := filepath.Join(t.TempDir(), "suite.tsb")
	if err := writeBundle(out, []SourceFile{sf}, "now"); err != nil {
		t.Fatal(err)
	}

	raw, err := os.ReadFile(out)
	if err != nil {
		t.Fatal(err)
	}
	// Flip one bit deep inside the payload area, leaving the header and the
	// table of contents perfectly valid.
	flip := len(raw) - 100
	raw[flip] ^= 0x01
	if err := os.WriteFile(out, raw, 0o644); err != nil {
		t.Fatal(err)
	}

	b, err := openBundle(out)
	if err != nil {
		t.Fatalf("the header and TOC are untouched, openBundle should still work: %v", err)
	}
	defer b.Close()
	e, ok := b.entry("drivepulse")
	if !ok {
		t.Fatal("entry missing")
	}

	root := newRoot(t)
	db, _ := root.loadInstalled()
	res := root.installOne(db, payloadFromBundle(b, e, out), false)
	if res.Action != "failed" {
		t.Fatalf("a corrupted payload was installed anyway: action=%q", res.Action)
	}
	if !strings.Contains(res.Error, "hash mismatch") {
		t.Errorf("expected a hash mismatch, got %q", res.Error)
	}
	if _, err := os.Stat(root.programPath("drivepulse")); !os.IsNotExist(err) {
		t.Error("a corrupted program was left in bin/")
	}
	if _, err := os.Stat(root.programPath("drivepulse") + partSuffix); !os.IsNotExist(err) {
		t.Error("the .part file was left behind after a failed install")
	}
	if _, ok := db.Programs["drivepulse"]; ok {
		t.Error("a failed install was recorded as installed")
	}
}

// The table of contents protects itself too.
func TestBundleRejectsTamperedTOC(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	out := filepath.Join(t.TempDir(), "suite.tsb")
	if err := writeBundle(out, []SourceFile{sf}, "now"); err != nil {
		t.Fatal(err)
	}
	raw, err := os.ReadFile(out)
	if err != nil {
		t.Fatal(err)
	}
	// The TOC starts right after the fixed header.
	raw[bundleHeaderLen+5] ^= 0x20
	if err := os.WriteFile(out, raw, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := openBundle(out); err == nil {
		t.Fatal("a tampered table of contents was accepted")
	}
}

// --------------------------------------------------------- install safety ---

// A failure after the bytes are written but before the rename must leave the
// install root exactly as it was.
func TestInstallIsAtomicOnMidwayFailure(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()

	before := listTree(t, root.Path)

	testHookAfterWrite = func(slug, part string) error {
		// The bytes really are on disk at this point -- prove it, so this
		// test is testing the cleanup and not a no-op.
		if _, err := os.Stat(part); err != nil {
			t.Errorf("expected %s to exist at the moment of failure: %v", part, err)
		}
		return errors.New("simulated failure right before the rename")
	}
	defer func() { testHookAfterWrite = nil }()

	res := root.installOne(db, payloadFromSource(sf), false)
	if res.Action != "failed" {
		t.Fatalf("expected the install to fail, got %q", res.Action)
	}
	if _, err := os.Stat(root.programPath("drivepulse")); !os.IsNotExist(err) {
		t.Error("the program was installed despite the failure")
	}
	if _, err := os.Stat(root.programPath("drivepulse") + partSuffix); !os.IsNotExist(err) {
		t.Error(".part file left behind")
	}
	if _, ok := db.Programs["drivepulse"]; ok {
		t.Error("the failed install was recorded")
	}

	after := listTree(t, root.Path)
	// The ledger gains a failure record, which is the point of the ledger.
	delete(before, filepath.Join(root.Path, ledgerName))
	delete(after, filepath.Join(root.Path, ledgerName))
	if len(before) != len(after) {
		t.Errorf("the install root changed: %d files before, %d after\nbefore=%v\nafter=%v",
			len(before), len(after), before, after)
	}
}

// A mid-way failure while UPDATING must leave the working old version in place.
func TestFailedUpdateLeavesTheOldVersionAlone(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()

	if res := root.installOne(db, payloadFromSource(sf), false); res.Action != "installed" {
		t.Fatalf("setup install failed: %v", res)
	}
	good, err := os.ReadFile(root.programPath("drivepulse"))
	if err != nil {
		t.Fatal(err)
	}

	// A "newer" build that will fail on the way in.
	newDir := t.TempDir()
	fakeProgram(t, newDir, "drivepulse", 99, 5000)
	if err := os.WriteFile(filepath.Join(newDir, "versions.txt"), []byte("drivepulse 2.0.0\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	newSrc := mustSource(t, newDir)
	newSF, _ := newSrc.find("drivepulse")

	testHookAfterWrite = func(slug, part string) error { return errors.New("simulated failure") }
	defer func() { testHookAfterWrite = nil }()

	if res := root.installOne(db, payloadFromSource(newSF), false); res.Action != "failed" {
		t.Fatalf("expected failure, got %q", res.Action)
	}
	still, err := os.ReadFile(root.programPath("drivepulse"))
	if err != nil {
		t.Fatalf("the old version is gone: %v", err)
	}
	if !bytes.Equal(still, good) {
		t.Error("the old version was damaged by a failed update")
	}
	if db.Programs["drivepulse"].Version != "1.0.0" {
		t.Errorf("the record says %q after a failed update, want 1.0.0", db.Programs["drivepulse"].Version)
	}
}

// Installing the same thing twice must be a no-op the second time.
func TestReinstallIsIdempotent(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()

	first := root.installOne(db, payloadFromSource(sf), false)
	if first.Action != "installed" {
		t.Fatalf("first install: %v", first)
	}
	info1, err := os.Stat(root.programPath("drivepulse"))
	if err != nil {
		t.Fatal(err)
	}

	second := root.installOne(db, payloadFromSource(sf), false)
	if second.Action != "unchanged" {
		t.Fatalf("second install reported %q, want unchanged", second.Action)
	}
	info2, err := os.Stat(root.programPath("drivepulse"))
	if err != nil {
		t.Fatal(err)
	}
	if !info1.ModTime().Equal(info2.ModTime()) {
		t.Error("an idempotent re-install rewrote the file")
	}
	if len(db.Programs) != 1 {
		t.Errorf("%d programs recorded after two installs of one program", len(db.Programs))
	}

	// --force is the documented way to rewrite it anyway.
	third := root.installOne(db, payloadFromSource(sf), true)
	if third.Action != "updated" && third.Action != "installed" {
		t.Errorf("--force reported %q, expected it to rewrite", third.Action)
	}
}

func listTree(t *testing.T, root string) map[string]int64 {
	t.Helper()
	out := map[string]int64{}
	err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.Mode().IsRegular() {
			out[p] = info.Size()
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return out
}

// ---------------------------------------------------------------- removal ---

// Removing must MOVE. The bytes have to still be there afterwards.
func TestRemoveMovesToTrashRatherThanDeleting(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()

	if res := root.installOne(db, payloadFromSource(sf), false); res.Action != "installed" {
		t.Fatalf("setup: %v", res)
	}
	installed := root.programPath("drivepulse")
	originalBytes, err := os.ReadFile(installed)
	if err != nil {
		t.Fatal(err)
	}
	originalHash := sha256Of(t, installed)

	res := root.removeOne(db, "drivepulse")
	if res.Action != "moved-to-trash" {
		t.Fatalf("remove reported %q, want moved-to-trash", res.Action)
	}
	if _, err := os.Stat(installed); !os.IsNotExist(err) {
		t.Error("the program is still in bin/ after removal")
	}
	if _, ok := db.Programs["drivepulse"]; ok {
		t.Error("still recorded as installed after removal")
	}

	// The whole point: the file exists in the trash, byte for byte.
	info, err := os.Stat(res.Trash)
	if err != nil {
		t.Fatalf("the removed file is not in the trash: %v", err)
	}
	if !info.Mode().IsRegular() {
		t.Fatalf("%s is not a regular file", res.Trash)
	}
	trashBytes, err := os.ReadFile(res.Trash)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(trashBytes, originalBytes) {
		t.Error("the file in the trash is not the file that was removed")
	}
	if got := sha256Of(t, res.Trash); got != originalHash {
		t.Errorf("trashed file hash %s, original %s", got, originalHash)
	}
	if !strings.HasPrefix(res.Trash, root.trash()) {
		t.Errorf("the trashed file landed at %s, outside %s", res.Trash, root.trash())
	}

	items, err := root.listTrash()
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 {
		t.Fatalf("expected 1 item in the trash, found %d", len(items))
	}

	// And it can be put back, which is the reason for moving rather than
	// deleting in the first place.
	if err := os.Rename(res.Trash, installed); err != nil {
		t.Fatal(err)
	}
	if got := sha256Of(t, installed); got != originalHash {
		t.Error("the restored file does not match")
	}
}

// Removing, re-installing and removing again must keep both copies.
func TestTrashDoesNotOverwriteItself(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()

	for i := 0; i < 2; i++ {
		root.installOne(db, payloadFromSource(sf), true)
		if res := root.removeOne(db, "drivepulse"); res.Action != "moved-to-trash" {
			t.Fatalf("round %d: %v", i, res)
		}
	}
	items, err := root.listTrash()
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 2 {
		t.Fatalf("expected 2 separate items in the trash, found %d", len(items))
	}
}

// --purge empties the trash and touches nothing else.
func TestPurgeEmptiesOnlyTheTrash(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse", "dupepilot")
	src := mustSource(t, srcDir)
	root := newRoot(t)
	db, _ := root.loadInstalled()
	for _, slug := range []string{"drivepulse", "dupepilot"} {
		sf, _ := src.find(slug)
		root.installOne(db, payloadFromSource(sf), false)
	}
	root.removeOne(db, "drivepulse")
	if err := root.saveInstalled(db); err != nil {
		t.Fatal(err)
	}

	files, bytesFreed, err := root.purgeTrash()
	if err != nil {
		t.Fatal(err)
	}
	if files == 0 || bytesFreed == 0 {
		t.Errorf("purge reported %d files / %d bytes", files, bytesFreed)
	}
	items, _ := root.listTrash()
	if len(items) != 0 {
		t.Errorf("%d items left in the trash after a purge", len(items))
	}
	// Everything else survived.
	if _, err := os.Stat(root.programPath("dupepilot")); err != nil {
		t.Errorf("purge removed an installed program: %v", err)
	}
	if _, err := os.Stat(root.installedPath()); err != nil {
		t.Errorf("purge removed the state file: %v", err)
	}
	if _, err := os.Stat(root.ledgerPath()); err != nil {
		t.Errorf("purge removed the ledger: %v", err)
	}
	if _, err := os.Stat(filepath.Join(root.Path, markerName)); err != nil {
		t.Errorf("purge removed the root marker: %v", err)
	}
	// And the trash directory itself is still there, ready for the next one.
	if info, err := os.Stat(root.trash()); err != nil || !info.IsDir() {
		t.Errorf("the trash folder itself was removed: %v", err)
	}
}

// ----------------------------------------------------------------- ledger ---

// Append-only means every byte written earlier is still exactly where it was.
func TestLedgerIsAppendOnly(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse", "dupepilot", "grabflow")
	src := mustSource(t, srcDir)
	root := newRoot(t)
	db, _ := root.loadInstalled()

	sf, _ := src.find("drivepulse")
	root.installOne(db, payloadFromSource(sf), false)

	snapshot, err := os.ReadFile(root.ledgerPath())
	if err != nil {
		t.Fatal(err)
	}
	if len(snapshot) == 0 {
		t.Fatal("the ledger is empty after an install")
	}
	recordsBefore, err := root.readLedger()
	if err != nil {
		t.Fatal(err)
	}

	// Do more work of every kind.
	for _, slug := range []string{"dupepilot", "grabflow"} {
		s2, _ := src.find(slug)
		root.installOne(db, payloadFromSource(s2), false)
	}
	root.removeOne(db, "dupepilot")
	if _, _, err := root.purgeTrash(); err != nil {
		t.Fatal(err)
	}

	after, err := os.ReadFile(root.ledgerPath())
	if err != nil {
		t.Fatal(err)
	}
	if len(after) <= len(snapshot) {
		t.Fatalf("the ledger did not grow: %d bytes then, %d now", len(snapshot), len(after))
	}
	if !bytes.Equal(after[:len(snapshot)], snapshot) {
		t.Fatal("the first bytes of the ledger changed -- it is not append-only")
	}

	recordsAfter, err := root.readLedger()
	if err != nil {
		t.Fatal(err)
	}
	if len(recordsAfter) <= len(recordsBefore) {
		t.Fatalf("record count went from %d to %d", len(recordsBefore), len(recordsAfter))
	}
	for i := range recordsBefore {
		if recordsAfter[i] != recordsBefore[i] {
			t.Errorf("record %d was rewritten:\n old %+v\n new %+v", i, recordsBefore[i], recordsAfter[i])
		}
	}
	// Every line must be complete JSON, one record per line.
	for _, line := range bytes.Split(bytes.TrimRight(after, "\n"), []byte("\n")) {
		if len(line) == 0 || line[0] != '{' || line[len(line)-1] != '}' {
			t.Errorf("ledger line is not a complete JSON object: %q", line)
		}
	}
	// And each action really was recorded.
	seen := map[string]bool{}
	for _, r := range recordsAfter {
		seen[r.Action] = true
	}
	for _, want := range []string{"installed", "removed", "purged-trash"} {
		if !seen[want] {
			t.Errorf("no %q record in the ledger", want)
		}
	}
}

func TestLedgerRecordsHashesAndTimestamps(t *testing.T) {
	srcDir := newSourceDir(t, "drivepulse")
	src := mustSource(t, srcDir)
	sf, _ := src.find("drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()
	root.installOne(db, payloadFromSource(sf), false)

	recs, err := root.readLedger()
	if err != nil {
		t.Fatal(err)
	}
	if len(recs) != 1 {
		t.Fatalf("expected 1 record, got %d", len(recs))
	}
	r := recs[0]
	if r.SHA256 != sf.SHA256 {
		t.Errorf("ledger hash %q, file hash %q", r.SHA256, sf.SHA256)
	}
	if _, err := time.Parse(time.RFC3339Nano, r.TS); err != nil {
		t.Errorf("ledger timestamp %q is not RFC 3339: %v", r.TS, err)
	}
	if r.Result != "ok" || r.Action != "installed" || r.Slug != "drivepulse" {
		t.Errorf("unexpected record: %+v", r)
	}
}

// ------------------------------------------------------- install-root safety ---

func TestInstallRootRefusesSystemDirectories(t *testing.T) {
	refuse := []string{
		"/", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin", "/etc", "/bin",
		"/var", "/var/log", "/System", "/Library", "/Applications", "/boot",
		"/sbin", "/lib", "/proc", "/sys", "/dev", "/root", "/home", "/tmp",
		"/private/etc", "/Users", "/Volumes",
	}
	for _, p := range refuse {
		abs, err := normalizeRoot(p)
		if err != nil {
			t.Fatalf("%s: %v", p, err)
		}
		if err := checkRootSafety(abs); err == nil {
			t.Errorf("checkRootSafety accepted the system directory %s", p)
		}
		if _, err := confirmRoot(p); err == nil {
			t.Errorf("confirmRoot created an install root at the system directory %s", p)
		}
	}

	// Trailing slashes and dot segments must not slip past the check.
	for _, p := range []string{"/usr/", "/usr/local/", "/usr/local/bin/", "/etc/./", "/var/log/../log"} {
		abs, err := normalizeRoot(p)
		if err != nil {
			t.Fatal(err)
		}
		if err := checkRootSafety(abs); err == nil {
			t.Errorf("checkRootSafety accepted %q (normalised to %s)", p, abs)
		}
	}

	// The home folder itself is refused; a folder inside it is fine.
	if home, err := os.UserHomeDir(); err == nil {
		if err := checkRootSafety(filepath.Clean(home)); err == nil {
			t.Error("checkRootSafety accepted the home folder itself")
		}
		if err := checkRootSafety(filepath.Join(home, "Techlosoft")); err != nil {
			t.Errorf("checkRootSafety refused a perfectly ordinary folder: %v", err)
		}
	}

	// An empty root is a bad invocation, not a silent default.
	if _, err := normalizeRoot("   "); err == nil {
		t.Error("an empty install root was accepted")
	}
}

// A root that has never been confirmed must not be used, and must not be
// created as a side effect of asking.
func TestUnconfirmedRootIsRefusedAndCreatesNothing(t *testing.T) {
	dir := filepath.Join(t.TempDir(), "not-confirmed")
	if _, err := openRoot(dir); err != ErrUnconfirmedRoot {
		t.Fatalf("expected ErrUnconfirmedRoot, got %v", err)
	}
	if _, err := os.Stat(dir); !os.IsNotExist(err) {
		t.Error("merely opening an unconfirmed root created it")
	}
	r, err := confirmRoot(dir)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(filepath.Join(r.Path, markerName)); err != nil {
		t.Errorf("no marker after confirmation: %v", err)
	}
	if _, err := openRoot(dir); err != nil {
		t.Errorf("a confirmed root was still refused: %v", err)
	}
}

// Nothing may be written outside the root, however odd the slug.
func TestRootContainsRejectsEscapes(t *testing.T) {
	root := newRoot(t)
	outside := []string{
		filepath.Join(root.Path, "..", "elsewhere"),
		filepath.Join(root.Path, "..", ".."),
		filepath.Dir(root.Path),
		string(filepath.Separator) + "etc",
	}
	for _, p := range outside {
		if root.contains(p) {
			t.Errorf("contains() accepted %s, which is outside %s", p, root.Path)
		}
	}
	for _, p := range []string{root.Path, root.bin(), root.trash(), root.programPath("drivepulse")} {
		if !root.contains(p) {
			t.Errorf("contains() rejected %s, which is inside %s", p, root.Path)
		}
	}
}

// ------------------------------------------------------- selection parsing ---

func TestParseSelection(t *testing.T) {
	options := catalog[:10] // ten known programs, numbered 1..10

	cases := []struct {
		in      string
		want    []string
		wantErr bool
	}{
		{"1", []string{options[0].Slug}, false},
		{"1-5", slugsOf(options[0:5]), false},
		{"5-1", slugsOf(options[0:5]), false},
		{"2,4", []string{options[1].Slug, options[3].Slug}, false},
		{"2, 4", []string{options[1].Slug, options[3].Slug}, false},
		{"2 4", []string{options[1].Slug, options[3].Slug}, false},
		{"1-3,7", []string{options[0].Slug, options[1].Slug, options[2].Slug, options[6].Slug}, false},
		{"all", slugsOf(options), false},
		{"ALL", slugsOf(options), false},
		{"drivepulse", []string{"drivepulse"}, false},
		{"DrivePulse", []string{"drivepulse"}, false},
		{"drivepulse, 1", []string{options[0].Slug, "drivepulse"}, false},
		{"", nil, false},
		{"   ", nil, false},
		{"none", nil, false},
		{"all,none", nil, false},
		// Order of the output follows the list, not the typing.
		{"4,2", []string{options[1].Slug, options[3].Slug}, false},
		// Duplicates collapse.
		{"2,2,2", []string{options[1].Slug}, false},
		// Nonsense.
		{"banana", nil, true},
		{"0", nil, true},
		{"11", nil, true},
		{"-3", nil, true},
		{"1-99", nil, true},
		{"1--3", nil, true},
		{"3.5", nil, true},
		{"1,banana", nil, true},
		{"notaprogram", nil, true},
	}
	for _, c := range cases {
		got, err := ParseSelection(c.in, options)
		if c.wantErr {
			if err == nil {
				t.Errorf("ParseSelection(%q) = %v, expected an error", c.in, got)
			}
			continue
		}
		if err != nil {
			t.Errorf("ParseSelection(%q): %v", c.in, err)
			continue
		}
		if strings.Join(got, ",") != strings.Join(c.want, ",") {
			t.Errorf("ParseSelection(%q) = %v, want %v", c.in, got, c.want)
		}
	}
}

// A program that exists but is not on the screen must not be selectable by
// name: the numbers and the names have to agree about what is on offer.
func TestParseSelectionRejectsNamesOutsideTheList(t *testing.T) {
	options := catalog[:3]
	elsewhere := catalog[50].Slug
	if _, err := ParseSelection(elsewhere, options); err == nil {
		t.Errorf("%q was accepted from a list that does not contain it", elsewhere)
	}
}

func slugsOf(ps []Program) []string {
	var out []string
	for _, p := range ps {
		out = append(out, p.Slug)
	}
	return out
}

func TestGroupLookup(t *testing.T) {
	ps, err := programsInGroup("Storage Health Center")
	if err != nil || len(ps) == 0 {
		t.Fatalf("exact group name: %d programs, %v", len(ps), err)
	}
	if _, err := programsInGroup("storage health center"); err != nil {
		t.Errorf("group names should be case-insensitive: %v", err)
	}
	if _, err := programsInGroup("Storage Health"); err != nil {
		t.Errorf("a unique prefix should work: %v", err)
	}
	if _, err := programsInGroup("no such group at all"); err == nil {
		t.Error("a nonexistent group was accepted")
	}
	if _, err := programsInGroup(""); err == nil {
		t.Error("an empty group name was accepted")
	}
	total := 0
	for _, g := range groups() {
		ps, err := programsInGroup(g)
		if err != nil {
			t.Fatalf("%s: %v", g, err)
		}
		total += len(ps)
	}
	if total != len(catalog) {
		t.Errorf("the groups hold %d programs between them, the catalogue has %d", total, len(catalog))
	}
}

func TestCatalogIsComplete(t *testing.T) {
	if len(catalog) != 100 {
		t.Errorf("the catalogue has %d programs, expected 100", len(catalog))
	}
	seen := map[string]bool{}
	for _, p := range catalog {
		if p.Slug == "" || p.Name == "" || p.Group == "" || p.Plain == "" {
			t.Errorf("incomplete catalogue entry: %+v", p)
		}
		if seen[p.Slug] {
			t.Errorf("duplicate slug %q", p.Slug)
		}
		seen[p.Slug] = true
	}
	if !seen["drivepulse"] || !seen["dupepilot"] || !seen["diskwatch"] {
		t.Error("known programs are missing from the catalogue")
	}
}

func TestSearchMatching(t *testing.T) {
	var hits int
	for _, p := range catalog {
		if matchSearch(p, []string{"duplicate"}) {
			hits++
		}
	}
	if hits == 0 {
		t.Error("searching for 'duplicate' found nothing")
	}
	// Every word must match, so adding a word can only narrow.
	var narrowed int
	for _, p := range catalog {
		if matchSearch(p, []string{"duplicate", "zzzznotaword"}) {
			narrowed++
		}
	}
	if narrowed != 0 {
		t.Errorf("adding an impossible word still matched %d programs", narrowed)
	}
}

// --------------------------------------------------------- update detection ---

func TestUpdateDetectionByVersion(t *testing.T) {
	inst := InstalledEntry{Slug: "drivepulse", Version: "1.0.0", SHA256: strings.Repeat("a", 64)}

	newer := SourceFile{Slug: "drivepulse", Version: "1.1.0", SHA256: strings.Repeat("b", 64)}
	if u := needsUpdate(inst, newer); u.Reason != "newer-version" {
		t.Errorf("1.0.0 -> 1.1.0 reported %q", u.Reason)
	}
	if u := needsUpdate(inst, SourceFile{Version: "2.0.0", SHA256: strings.Repeat("a", 64)}); u.Reason != "newer-version" {
		t.Errorf("a newer version with identical bytes reported %q", u.Reason)
	}
	// Older is never offered.
	if u := needsUpdate(inst, SourceFile{Version: "0.9.0", SHA256: strings.Repeat("b", 64)}); u.Reason != "up-to-date" {
		t.Errorf("a downgrade was offered as %q", u.Reason)
	}
	// Same version, same bytes: nothing to do.
	if u := needsUpdate(inst, SourceFile{Version: "1.0.0", SHA256: strings.Repeat("a", 64)}); u.Reason != "up-to-date" {
		t.Errorf("an identical build reported %q", u.Reason)
	}
}

func TestUpdateDetectionByHash(t *testing.T) {
	inst := InstalledEntry{Slug: "drivepulse", Version: "1.0.0", SHA256: strings.Repeat("a", 64)}
	rebuilt := SourceFile{Slug: "drivepulse", Version: "1.0.0", SHA256: strings.Repeat("c", 64)}
	u := needsUpdate(inst, rebuilt)
	if u.Reason != "rebuilt" {
		t.Errorf("same version with different bytes reported %q, want rebuilt", u.Reason)
	}
	if u.InstalledSH != inst.SHA256 || u.SourceSHA != rebuilt.SHA256 {
		t.Error("the update report does not carry both hashes")
	}
	// Case must not matter in a hex hash comparison.
	upper := SourceFile{Version: "1.0.0", SHA256: strings.ToUpper(strings.Repeat("a", 64))}
	if u := needsUpdate(inst, upper); u.Reason != "up-to-date" {
		t.Errorf("an upper-case identical hash reported %q", u.Reason)
	}
}

func TestCompareVersions(t *testing.T) {
	cases := []struct {
		a, b string
		want int
	}{
		{"1.0.0", "1.0.0", 0},
		{"1.0", "1.0.0", 0},
		{"1.0.1", "1.0.0", 1},
		{"1.0.0", "1.0.1", -1},
		{"1.10.0", "1.9.0", 1},
		{"2.0.0", "1.99.99", 1},
		{"v1.2.0", "1.2.0", 0},
		{"1.2.0", "1.2", 0},
		{"10.0", "9.0", 1},
	}
	for _, c := range cases {
		if got := compareVersions(c.a, c.b); got != c.want {
			t.Errorf("compareVersions(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
		}
	}
}

// End to end: install, then point at a newer source, and the update lands.
func TestUpdateEndToEnd(t *testing.T) {
	oldDir := newSourceDir(t, "drivepulse")
	root := newRoot(t)
	db, _ := root.loadInstalled()
	oldSrc := mustSource(t, oldDir)
	sf, _ := oldSrc.find("drivepulse")
	root.installOne(db, payloadFromSource(sf), false)

	newDir := t.TempDir()
	fakeProgram(t, newDir, "drivepulse", 200, 7777)
	if err := os.WriteFile(filepath.Join(newDir, "versions.txt"), []byte("# a newer drop\ndrivepulse 1.4.2\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	newSrc := mustSource(t, newDir)
	newSF, err := newSrc.find("drivepulse")
	if err != nil {
		t.Fatal(err)
	}
	if newSF.Version != "1.4.2" {
		t.Fatalf("versions.txt was not read: got %q", newSF.Version)
	}

	u := needsUpdate(db.Programs["drivepulse"], newSF)
	if u.Reason != "newer-version" {
		t.Fatalf("expected an update, got %q", u.Reason)
	}
	res := root.installOne(db, payloadFromSource(newSF), false)
	if res.Action != "updated" {
		t.Fatalf("expected 'updated', got %q (%s)", res.Action, res.Error)
	}
	if db.Programs["drivepulse"].Version != "1.4.2" {
		t.Errorf("recorded version is %q", db.Programs["drivepulse"].Version)
	}
	onDisk := sha256Of(t, root.programPath("drivepulse"))
	if onDisk != newSF.SHA256 {
		t.Error("the file on disk is not the new build")
	}
	// The ledger recorded the version it came from.
	recs, _ := root.readLedger()
	last := recs[len(recs)-1]
	if last.Action != "updated" || last.From != "1.0.0" || last.Version != "1.4.2" {
		t.Errorf("the update was not recorded properly: %+v", last)
	}
}

// ----------------------------------------------------------------- source ---

func TestSourceLayoutsAreAllFound(t *testing.T) {
	ext := ""
	if runtime.GOOS == "windows" {
		ext = ".exe"
	}
	plat := fmt.Sprintf("-%s-%s%s", runtime.GOOS, runtime.GOARCH, ext)

	layouts := map[string]string{
		"downloads":  filepath.Join("drivepulse", "downloads", "drivepulse"+plat),
		"per-slug":   filepath.Join("drivepulse", "drivepulse"+plat),
		"flat":       "drivepulse" + plat,
		"plain-name": filepath.Join("drivepulse", "drivepulse"+ext),
		"bare":       "drivepulse" + ext,
	}
	for name, rel := range layouts {
		dir := t.TempDir()
		full := filepath.Join(dir, rel)
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(full, []byte("body"), 0o755); err != nil {
			t.Fatal(err)
		}
		s := mustSource(t, dir)
		if _, err := s.find("drivepulse"); err != nil {
			t.Errorf("layout %q (%s) was not found: %v", name, rel, err)
		}
		if !s.available()["drivepulse"] {
			t.Errorf("layout %q not reported as available", name)
		}
	}
}

func TestSourceRefusesUnknownSlug(t *testing.T) {
	s := mustSource(t, newSourceDir(t, "drivepulse"))
	if _, err := s.find("definitely-not-a-techlosoft-program"); err == nil {
		t.Error("an unknown slug was accepted")
	}
	if _, err := s.find("dupepilot"); !errors.Is(err, errNotInSource) {
		t.Errorf("expected errNotInSource, got %v", err)
	}
}

// ------------------------------------------------- the double-click check ---
//
// console.go is copied verbatim into every Techlosoft program, so these tests
// pin down the same behaviour here as everywhere else. Getting this wrong in
// the "true" 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()

	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},
	} {
		if got := isCharDevice(tc.file); got != tc.want {
			t.Errorf("isCharDevice(%s) = %v, want %v", tc.name, got, tc.want)
		}
	}
}

func TestInteractiveConsoleNeedsBothEnds(t *testing.T) {
	if interactiveConsole() {
		t.Error("interactiveConsole() = true under `go test`, where output is captured; " +
			"the wizard would hang any non-interactive run")
	}
}

// The wizard's one-keypress default for the install root must be a path it
// would actually accept, or pressing Enter on the very first question is an
// error message.
func TestDefaultRootIsAcceptable(t *testing.T) {
	abs, err := normalizeRoot(defaultRoot())
	if err != nil {
		t.Fatalf("defaultRoot() = %q, which will not normalise: %v", defaultRoot(), err)
	}
	if err := checkRootSafety(abs); err != nil {
		t.Errorf("defaultRoot() = %q, which the safety check refuses: %v", abs, err)
	}
}

// guessSource must offer either nothing or a folder that really exists.
func TestGuessSourceIsRealOrEmpty(t *testing.T) {
	got := guessSource()
	if got == "" {
		return // nothing found; the wizard asks instead, which is fine
	}
	info, err := os.Stat(got)
	if err != nil {
		t.Fatalf("guessSource() = %q, which does not exist: %v", got, err)
	}
	if !info.IsDir() {
		t.Errorf("guessSource() = %q, which is not a directory", got)
	}
}
