package secrets

import (
	"crypto/rand"
	"encoding/json"
	"errors"
	"io"
	"os"
	"path/filepath"
	"testing"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
)

func freshKey(t *testing.T) []byte {
	t.Helper()
	k := make([]byte, crypto.KeyLen)
	if _, err := io.ReadFull(rand.Reader, k); err != nil {
		t.Fatalf("rand: %v", err)
	}
	return k
}

func TestRoundTrip(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}

	// Empty file → zero-value Repo, no error.
	got, err := st.Load()
	if err != nil {
		t.Fatalf("load empty: %v", err)
	}
	if !got.Empty() {
		t.Errorf("first load should be empty, got %+v", got)
	}

	want := Repo{URL: "rest:https://r/host", Username: "user", Password: "pw"}
	if err := st.Save(want); err != nil {
		t.Fatalf("save: %v", err)
	}
	got, err = st.Load()
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if got != want {
		t.Errorf("round-trip mismatch: got %+v want %+v", got, want)
	}

	// File mode must be 0600.
	info, _ := os.Stat(path)
	if info.Mode().Perm() != 0o600 {
		t.Errorf("file mode = %o, want 0600", info.Mode().Perm())
	}
}

func TestRejectsWrongKey(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	good, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new good: %v", err)
	}
	if err := good.Save(Repo{URL: "x", Password: "y"}); err != nil {
		t.Fatalf("save: %v", err)
	}
	bad, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new bad: %v", err)
	}
	if _, err := bad.Load(); err == nil {
		t.Error("load with wrong key must fail; got nil")
	}
}

func TestSaveIsAtomic(t *testing.T) {
	t.Parallel()
	// Sanity check: after Save(), there are no leftover .tmp files.
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	if err := st.Save(Repo{URL: "x", Password: "y"}); err != nil {
		t.Fatalf("save: %v", err)
	}
	entries, _ := os.ReadDir(dir)
	if len(entries) != 1 {
		var names []string
		for _, e := range entries {
			names = append(names, e.Name())
		}
		t.Errorf("dir should hold one file post-save, got %v", names)
	}
}

func TestSecretsLoadAdminEmpty(t *testing.T) {
	t.Parallel()
	// No file yet: LoadAdmin must return ErrNoAdmin, not a hard error.
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	_, err = st.LoadAdmin()
	if !errors.Is(err, ErrNoAdmin) {
		t.Errorf("expected ErrNoAdmin, got %v", err)
	}
}

func TestSecretsAdminSlotIndependent(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}

	repo := Repo{URL: "rest:https://repo/host", Username: "user", Password: "pw"}
	admin := Repo{URL: "rest:https://repo/host", Username: "admin", Password: "adminpw"}

	if err := st.Save(repo); err != nil {
		t.Fatalf("save repo: %v", err)
	}
	if err := st.SaveAdmin(admin); err != nil {
		t.Fatalf("save admin: %v", err)
	}

	// Load returns the repo slot unchanged.
	gotRepo, err := st.Load()
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if gotRepo != repo {
		t.Errorf("repo slot mismatch: got %+v want %+v", gotRepo, repo)
	}

	// LoadAdmin returns the admin slot.
	gotAdmin, err := st.LoadAdmin()
	if err != nil {
		t.Fatalf("load admin: %v", err)
	}
	if gotAdmin != admin {
		t.Errorf("admin slot mismatch: got %+v want %+v", gotAdmin, admin)
	}

	// SaveAdmin a second time replaces admin only; repo unchanged.
	admin2 := Repo{URL: "rest:https://repo/host", Username: "admin2", Password: "pw2"}
	if err := st.SaveAdmin(admin2); err != nil {
		t.Fatalf("save admin2: %v", err)
	}
	gotRepo2, err := st.Load()
	if err != nil {
		t.Fatalf("load after admin2 save: %v", err)
	}
	if gotRepo2 != repo {
		t.Errorf("repo slot changed unexpectedly: got %+v want %+v", gotRepo2, repo)
	}
	gotAdmin2, err := st.LoadAdmin()
	if err != nil {
		t.Fatalf("load admin2: %v", err)
	}
	if gotAdmin2 != admin2 {
		t.Errorf("admin2 slot mismatch: got %+v want %+v", gotAdmin2, admin2)
	}
}

func TestSecretsSaveRefusesCorruptFile(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}

	// Lay down a valid file first.
	if err := st.Save(Repo{URL: "rest:https://r/host", Password: "pw"}); err != nil {
		t.Fatalf("initial save: %v", err)
	}

	// Corrupt the file.
	garbage := []byte("not encrypted")
	if err := os.WriteFile(path, garbage, 0o600); err != nil {
		t.Fatalf("write garbage: %v", err)
	}

	// Save must refuse to overwrite: decrypt will fail.
	saveErr := st.Save(Repo{URL: "rest:https://r/host", Password: "new"})
	if saveErr == nil {
		t.Fatal("Save over corrupt file must return an error; got nil")
	}

	// File must NOT have been replaced — still contains the garbage bytes.
	got, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("re-read: %v", err)
	}
	if string(got) != string(garbage) {
		t.Errorf("corrupt file was overwritten; file size now %d (was %d)", len(got), len(garbage))
	}
}

func TestSecretsSaveAdminRefusesCorruptFile(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	st, err := New(path, freshKey(t))
	if err != nil {
		t.Fatalf("new: %v", err)
	}

	// Lay down a valid file first.
	if err := st.SaveAdmin(Repo{URL: "rest:https://r/host", Password: "adminpw"}); err != nil {
		t.Fatalf("initial save admin: %v", err)
	}

	// Corrupt the file.
	garbage := []byte("not encrypted admin")
	if err := os.WriteFile(path, garbage, 0o600); err != nil {
		t.Fatalf("write garbage: %v", err)
	}

	// SaveAdmin must refuse to overwrite: decrypt will fail.
	saveErr := st.SaveAdmin(Repo{URL: "rest:https://r/host", Password: "new"})
	if saveErr == nil {
		t.Fatal("SaveAdmin over corrupt file must return an error; got nil")
	}

	// File must NOT have been replaced.
	got, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("re-read: %v", err)
	}
	if string(got) != string(garbage) {
		t.Errorf("corrupt file was overwritten; file size now %d (was %d)", len(got), len(garbage))
	}
}

func TestSecretsLegacyFlatBlobMigrates(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	path := filepath.Join(dir, "secrets.enc")
	key := freshKey(t)

	// Write a legacy flat Repo blob directly — bypassing bundle wrapping.
	legacy := Repo{URL: "rest:https://legacy/host", Username: "legacyuser", Password: "legacypw"}
	plain, err := json.Marshal(legacy)
	if err != nil {
		t.Fatalf("marshal legacy: %v", err)
	}
	a, err := crypto.NewAEAD(key)
	if err != nil {
		t.Fatalf("aead: %v", err)
	}
	ct, err := a.Encrypt(plain, []byte(additionalData))
	if err != nil {
		t.Fatalf("encrypt legacy: %v", err)
	}
	if err := os.WriteFile(path, []byte(ct), 0o600); err != nil {
		t.Fatalf("write legacy file: %v", err)
	}

	// Open via secrets.New + Load — must return the legacy Repo.
	st, err := New(path, key)
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	got, err := st.Load()
	if err != nil {
		t.Fatalf("load legacy: %v", err)
	}
	if got != legacy {
		t.Errorf("legacy decode mismatch: got %+v want %+v", got, legacy)
	}

	// SaveAdmin should write both slots; re-opening must have both.
	admin := Repo{URL: "rest:https://legacy/host", Username: "admin", Password: "adminpw"}
	if err := st.SaveAdmin(admin); err != nil {
		t.Fatalf("save admin after legacy: %v", err)
	}

	st2, err := New(path, key)
	if err != nil {
		t.Fatalf("reopen: %v", err)
	}
	gotRepo, err := st2.Load()
	if err != nil {
		t.Fatalf("load repo after migration: %v", err)
	}
	if gotRepo != legacy {
		t.Errorf("repo after migration: got %+v want %+v", gotRepo, legacy)
	}
	gotAdmin, err := st2.LoadAdmin()
	if err != nil {
		t.Fatalf("load admin after migration: %v", err)
	}
	if gotAdmin != admin {
		t.Errorf("admin after migration: got %+v want %+v", gotAdmin, admin)
	}
}
