package backup

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func keyA() []byte { return []byte("0123456789abcdef0123456789abcdef") }
func keyB() []byte { return []byte("abcdef0123456789abcdef0123456789") }

func writeFiles(t *testing.T, root string, files map[string]string) {
	t.Helper()
	for name, body := range files {
		p := filepath.Join(root, name)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
		}
		if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
			t.Fatalf("write %s: %v", p, err)
		}
	}
}

func runBackup(t *testing.T, src, out string, key []byte) {
	t.Helper()
	if err := Backup(BackupOptions{
		SourceDir:  src,
		OutputPath: out,
		MasterKey:  key,
	}); err != nil {
		t.Fatalf("Backup: %v", err)
	}
}

func TestBackupRestoreRoundTrip(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	target := t.TempDir()
	os.RemoveAll(target)

	writeFiles(t, src, map[string]string{
		"cluster/master.key":            "KEYMATERIAL",
		"_defaults/db/orca.db":          "SQLITE",
		"_defaults/.env":                "FOO=bar",
		"_defaults/jobs/job1.md":        "job body",
		"cluster/peers/host1/peer.json": "{}",
	})

	runBackup(t, src, out, keyA())

	if _, err := os.Stat(out + ".sig"); err != nil {
		t.Fatalf("sig file missing: %v", err)
	}

	if err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyA(),
	}); err != nil {
		t.Fatalf("Restore: %v", err)
	}

	for name, body := range map[string]string{
		"cluster/master.key":            "KEYMATERIAL",
		"_defaults/db/orca.db":          "SQLITE",
		"_defaults/.env":                "FOO=bar",
		"_defaults/jobs/job1.md":        "job body",
		"cluster/peers/host1/peer.json": "{}",
	} {
		got, err := os.ReadFile(filepath.Join(target, name))
		if err != nil {
			t.Errorf("restored file %s missing: %v", name, err)
			continue
		}
		if string(got) != body {
			t.Errorf("restored %s = %q, want %q", name, string(got), body)
		}
	}
}

func TestVerifySignatureSameKey(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())
	if err := VerifySignature(out, out+".sig", keyA()); err != nil {
		t.Fatalf("verify same key: %v", err)
	}
}

func TestVerifySignatureWrongKey(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())
	err := VerifySignature(out, out+".sig", keyB())
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("verify wrong key: got %v, want ErrSignatureMismatch", err)
	}
}

func TestVerifySignatureTampered(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())

	body, err := os.ReadFile(out)
	if err != nil {
		t.Fatalf("read tarball: %v", err)
	}
	body[0] ^= 0xff
	if err := os.WriteFile(out, body, 0o644); err != nil {
		t.Fatalf("rewrite tampered tarball: %v", err)
	}
	err = VerifySignature(out, out+".sig", keyA())
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("verify tampered: got %v, want ErrSignatureMismatch", err)
	}
}

func TestExclusionSocketsAndRunOrca(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	target := t.TempDir()
	os.RemoveAll(target)

	writeFiles(t, src, map[string]string{
		"keep.txt":           "keep me",
		"normal.db":          "main db",
		"sock-excluded.sock": "sock",
		"side.db-wal":        "wal",
		"side.db-shm":        "shm",
	})

	runOrcaDir := filepath.Join(src, "run", "orca")
	if err := os.MkdirAll(runOrcaDir, 0o755); err != nil {
		t.Fatalf("mkdir run/orca: %v", err)
	}
	if err := os.WriteFile(filepath.Join(runOrcaDir, "ephemeral.txt"), []byte("eph"), 0o644); err != nil {
		t.Fatalf("write ephemeral: %v", err)
	}

	runBackup(t, src, out, keyA())

	if err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyA(),
	}); err != nil {
		t.Fatalf("Restore: %v", err)
	}

	for _, excluded := range []string{
		"sock-excluded.sock",
		"side.db-wal",
		"side.db-shm",
		"run/orca/ephemeral.txt",
	} {
		if _, err := os.Stat(filepath.Join(target, excluded)); !os.IsNotExist(err) {
			t.Errorf("excluded file %s should not be in restore (err=%v)", excluded, err)
		}
	}
	for _, kept := range []string{"keep.txt", "normal.db"} {
		if _, err := os.Stat(filepath.Join(target, kept)); err != nil {
			t.Errorf("kept file %s missing from restore: %v", kept, err)
		}
	}
}

func TestRestoreForceFalseRefusesNonEmpty(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())

	target := t.TempDir()
	if err := os.WriteFile(filepath.Join(target, "existing.txt"), []byte("x"), 0o644); err != nil {
		t.Fatalf("seed target: %v", err)
	}
	err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyA(),
		Force:     false,
	})
	if !errors.Is(err, ErrTargetNotEmpty) {
		t.Fatalf("restore to non-empty: got %v, want ErrTargetNotEmpty", err)
	}
}

func TestRestoreForceTrueOverwritesNonEmpty(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "new"})
	runBackup(t, src, out, keyA())

	target := t.TempDir()
	if err := os.WriteFile(filepath.Join(target, "stale.txt"), []byte("old"), 0o644); err != nil {
		t.Fatalf("seed target: %v", err)
	}
	err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyA(),
		Force:     true,
	})
	if err != nil {
		t.Fatalf("restore force: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(target, "a.txt"))
	if err != nil {
		t.Fatalf("restored a.txt missing: %v", err)
	}
	if string(got) != "new" {
		t.Errorf("restored a.txt = %q, want %q", string(got), "new")
	}
}

func TestEmptyBackup(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	target := t.TempDir()
	os.RemoveAll(target)

	runBackup(t, src, out, keyA())

	if err := VerifySignature(out, out+".sig", keyA()); err != nil {
		t.Fatalf("verify empty backup: %v", err)
	}
	if err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyA(),
	}); err != nil {
		t.Fatalf("restore empty backup: %v", err)
	}
	entries, err := os.ReadDir(target)
	if err != nil {
		t.Fatalf("read target: %v", err)
	}
	if len(entries) != 0 {
		t.Errorf("empty backup restored %d entries, want 0", len(entries))
	}
}

func TestRestoreSignatureMismatchFailsBeforeExtract(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())

	target := t.TempDir()
	os.RemoveAll(target)
	err := Restore(RestoreOptions{
		InputPath: out,
		TargetDir: target,
		MasterKey: keyB(),
	})
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("restore wrong key: got %v, want ErrSignatureMismatch", err)
	}
	if _, err := os.Stat(target); err == nil {
		entries, _ := os.ReadDir(target)
		if len(entries) != 0 {
			t.Errorf("target should be empty after failed verify, got %d entries", len(entries))
		}
	}
}

func TestRestoreBadSignatureContent(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())

	if err := os.WriteFile(out+".sig", []byte("not-hex!!"), 0o644); err != nil {
		t.Fatalf("write bad sig: %v", err)
	}
	err := VerifySignature(out, out+".sig", keyA())
	if err == nil {
		t.Fatal("verify bad sig content: expected error, got nil")
	}
	if strings.Contains(err.Error(), "decode signature") {
		return
	}
	if errors.Is(err, ErrSignatureMismatch) {
		return
	}
	t.Errorf("verify bad sig content: got unexpected err %v", err)
}

func TestBackupSignatureFileContent(t *testing.T) {
	src := t.TempDir()
	out := filepath.Join(t.TempDir(), "b.tar.gz")
	writeFiles(t, src, map[string]string{"a.txt": "hello"})
	runBackup(t, src, out, keyA())
	sig, err := os.ReadFile(out + ".sig")
	if err != nil {
		t.Fatalf("read sig: %v", err)
	}
	if dec, err := hexDecode(string(bytes.TrimSpace(sig))); err != nil {
		t.Fatalf("sig not hex: %v", err)
	} else if len(dec) != 32 {
		t.Errorf("sig len = %d, want 32", len(dec))
	}
}

func hexDecode(s string) ([]byte, error) {
	return hex.DecodeString(s)
}

// --- REQ-127 / F7 backup symlink validation tests ---

// TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute
// symlink linkname is rejected.
func TestRestoreRejectsAbsoluteSymlink(t *testing.T) {
	dir := t.TempDir()
	// Create a crafted tarball with an absolute symlink.
	tarPath := filepath.Join(dir, "evil.tar.gz")
	sigPath := tarPath + ".sig"
	if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil {
		t.Fatalf("create tarball: %v", err)
	}
	// Create a valid signature (the signature verifies, but the symlink
	// validation should still reject the restore).
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i)
	}
	mac := hmac.New(sha256.New, key)
	data, _ := os.ReadFile(tarPath)
	mac.Write(data)
	if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
		t.Fatalf("write sig: %v", err)
	}
	target := filepath.Join(dir, "restore")
	os.MkdirAll(target, 0o755)
	err := Restore(RestoreOptions{
		InputPath: tarPath,
		TargetDir: target,
		MasterKey: key,
		Force:     true,
	})
	if err == nil {
		t.Fatal("Restore should reject absolute symlink (REQ-127)")
	}
	if !strings.Contains(err.Error(), "absolute") {
		t.Errorf("error should mention absolute: %v", err)
	}
}

// TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink
// that escapes the target is rejected.
func TestRestoreRejectsTraversalSymlink(t *testing.T) {
	dir := t.TempDir()
	tarPath := filepath.Join(dir, "evil2.tar.gz")
	sigPath := tarPath + ".sig"
	if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil {
		t.Fatalf("create tarball: %v", err)
	}
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	mac := hmac.New(sha256.New, key)
	data, _ := os.ReadFile(tarPath)
	mac.Write(data)
	if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
		t.Fatalf("write sig: %v", err)
	}
	target := filepath.Join(dir, "restore2")
	os.MkdirAll(target, 0o755)
	err := Restore(RestoreOptions{
		InputPath: tarPath,
		TargetDir: target,
		MasterKey: key,
		Force:     true,
	})
	if err == nil {
		t.Fatal("Restore should reject traversal symlink (REQ-127)")
	}
}

// createCraftedTarballWithFile creates a tar.gz containing a single
// regular file entry with the given (possibly malicious) name. Used to
// test the tar-slip path-traversal guard (F3).
func createCraftedTarballWithFile(path, name, body string) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	gz := gzip.NewWriter(f)
	defer gz.Close()
	tw := tar.NewWriter(gz)
	defer tw.Close()
	hdr := &tar.Header{
		Name:     name,
		Typeflag: tar.TypeReg,
		Mode:     0o644,
		Size:     int64(len(body)),
	}
	if err := tw.WriteHeader(hdr); err != nil {
		return err
	}
	if _, err := tw.Write([]byte(body)); err != nil {
		return err
	}
	return nil
}

// TestRestoreRejectsTarSlipRegularFile verifies a tarball with a regular
// file entry whose name contains an embedded ".." traversal (e.g.
// "a/../../etc/passwd") is rejected. The old prefix-only check missed
// this pattern; the F3 filepath.Rel containment check catches it.
func TestRestoreRejectsTarSlipRegularFile(t *testing.T) {
	dir := t.TempDir()
	tarPath := filepath.Join(dir, "slip.tar.gz")
	sigPath := tarPath + ".sig"
	if err := createCraftedTarballWithFile(tarPath, "a/../../etc/passwd", "pwned"); err != nil {
		t.Fatalf("create tarball: %v", err)
	}
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 9)
	}
	mac := hmac.New(sha256.New, key)
	data, _ := os.ReadFile(tarPath)
	mac.Write(data)
	if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
		t.Fatalf("write sig: %v", err)
	}
	target := filepath.Join(dir, "restore")
	os.MkdirAll(target, 0o755)
	err := Restore(RestoreOptions{
		InputPath: tarPath,
		TargetDir: target,
		MasterKey: key,
		Force:     true,
	})
	if err == nil {
		t.Fatal("Restore should reject tar-slip regular file (F3)")
	}
	if !strings.Contains(err.Error(), "unsafe path") {
		t.Errorf("error should mention unsafe path: %v", err)
	}
}

// createCraftedTarball creates a tar.gz containing a single symlink
// entry with the given linkname. Used to test symlink validation.
func createCraftedTarball(path, name, linkname string) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	gz := gzip.NewWriter(f)
	defer gz.Close()
	tw := tar.NewWriter(gz)
	defer tw.Close()
	hdr := &tar.Header{
		Name:     name,
		Typeflag: tar.TypeSymlink,
		Linkname: linkname,
		Mode:     0o644,
	}
	return tw.WriteHeader(hdr)
}
