package system

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// TestAtomicReplaceMovesPreviousAside pins the basic shape: an existing
// dst is moved to dst+suffix, and newSrc is renamed into place.
// Critical for `banger update` — without the .previous backup the
// rollback path has nothing to restore.
func TestAtomicReplaceMovesPreviousAside(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	if err := os.WriteFile(dst, []byte("old"), 0o755); err != nil {
		t.Fatalf("write dst: %v", err)
	}
	src := filepath.Join(dir, "banger.new")
	if err := os.WriteFile(src, []byte("new"), 0o755); err != nil {
		t.Fatalf("write src: %v", err)
	}

	if err := AtomicReplace(src, dst, ".previous"); err != nil {
		t.Fatalf("AtomicReplace: %v", err)
	}

	got, _ := os.ReadFile(dst)
	if string(got) != "new" {
		t.Fatalf("dst content = %q, want %q", got, "new")
	}
	prev, _ := os.ReadFile(dst + ".previous")
	if string(prev) != "old" {
		t.Fatalf("backup content = %q, want %q", prev, "old")
	}
	// src must be gone (it was renamed, not copied).
	if _, err := os.Stat(src); !os.IsNotExist(err) {
		t.Fatalf("src should have been renamed away; got %v", err)
	}
}

// TestAtomicReplaceFreshInstall covers the case where dst doesn't
// exist yet (fresh install). Should still install newSrc; no backup
// is left behind.
func TestAtomicReplaceFreshInstall(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	src := filepath.Join(dir, "banger.new")
	if err := os.WriteFile(src, []byte("new"), 0o755); err != nil {
		t.Fatalf("write src: %v", err)
	}

	if err := AtomicReplace(src, dst, ".previous"); err != nil {
		t.Fatalf("AtomicReplace: %v", err)
	}

	got, _ := os.ReadFile(dst)
	if string(got) != "new" {
		t.Fatalf("dst content = %q, want %q", got, "new")
	}
	if _, err := os.Stat(dst + ".previous"); !os.IsNotExist(err) {
		t.Fatalf(".previous should not exist for a fresh install")
	}
}

// TestAtomicReplaceClearsStaleBackup: a leftover .previous from a
// half-finished prior update would otherwise block the rename.
// AtomicReplace must clear it.
func TestAtomicReplaceClearsStaleBackup(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	if err := os.WriteFile(dst, []byte("old"), 0o755); err != nil {
		t.Fatalf("write dst: %v", err)
	}
	if err := os.WriteFile(dst+".previous", []byte("ancient"), 0o755); err != nil {
		t.Fatalf("write stale previous: %v", err)
	}
	src := filepath.Join(dir, "banger.new")
	if err := os.WriteFile(src, []byte("new"), 0o755); err != nil {
		t.Fatalf("write src: %v", err)
	}

	if err := AtomicReplace(src, dst, ".previous"); err != nil {
		t.Fatalf("AtomicReplace: %v", err)
	}
	prev, _ := os.ReadFile(dst + ".previous")
	if string(prev) != "old" {
		t.Fatalf("backup content = %q, want %q (stale 'ancient' should have been overwritten with the just-replaced 'old')", prev, "old")
	}
}

// TestAtomicReplaceRefusesEmptySuffix is paranoia: an empty suffix
// would silently no-op the backup AND break rollback. Refuse rather
// than letting the caller paint themselves into a corner.
func TestAtomicReplaceRefusesEmptySuffix(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	src := filepath.Join(dir, "banger.new")
	_ = os.WriteFile(dst, []byte("old"), 0o755)
	_ = os.WriteFile(src, []byte("new"), 0o755)
	err := AtomicReplace(src, dst, "")
	if err == nil {
		t.Fatal("AtomicReplace with empty suffix succeeded; want error")
	}
	if !strings.Contains(err.Error(), "suffixPrevious") {
		t.Fatalf("err = %v, want suffix-related message", err)
	}
}

// TestAtomicReplaceRollbackRestoresPrevious pins the rollback story
// after a doctor failure: AtomicReplaceRollback restores the .previous
// backup back into place.
func TestAtomicReplaceRollbackRestoresPrevious(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	src := filepath.Join(dir, "banger.new")
	_ = os.WriteFile(dst, []byte("old"), 0o755)
	_ = os.WriteFile(src, []byte("new"), 0o755)
	if err := AtomicReplace(src, dst, ".previous"); err != nil {
		t.Fatalf("AtomicReplace: %v", err)
	}

	if err := AtomicReplaceRollback(dst, ".previous"); err != nil {
		t.Fatalf("Rollback: %v", err)
	}
	got, _ := os.ReadFile(dst)
	if string(got) != "old" {
		t.Fatalf("post-rollback dst = %q, want %q", got, "old")
	}
	if _, err := os.Stat(dst + ".previous"); !os.IsNotExist(err) {
		t.Fatalf(".previous should be gone after rollback; stat err = %v", err)
	}
}

// TestAtomicReplaceRollbackTolerantWhenNoBackup: rolling back when
// there's nothing to roll back (fresh-install case) must be a no-op,
// not an error. The updater calls Rollback unconditionally on
// failure paths and shouldn't have to track "was there a backup?"
// itself.
func TestAtomicReplaceRollbackTolerantWhenNoBackup(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "banger")
	if err := os.WriteFile(dst, []byte("current"), 0o755); err != nil {
		t.Fatalf("write dst: %v", err)
	}
	if err := AtomicReplaceRollback(dst, ".previous"); err != nil {
		t.Fatalf("Rollback should be a no-op when no backup exists; got %v", err)
	}
	got, _ := os.ReadFile(dst)
	if string(got) != "current" {
		t.Fatalf("dst was disturbed despite no backup: %q", got)
	}
}
