package restic

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.casaderoll.de/michael/urbm/internal/model"
)

type fakeSecrets map[string]string

func (s fakeSecrets) Get(id string) (string, error) { return s[id], nil }

func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
	dir := t.TempDir()
	argsPath := filepath.Join(dir, "args")
	passwordPath := filepath.Join(dir, "password")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.5,\"total_files\":12,\"files_done\":6,\"total_bytes\":1024,\"bytes_done\":512,\"seconds_remaining\":4,\"current_files\":[\"/source path/file.txt\"]}'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"}
	job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}}
	var progress Progress
	summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}, func(value Progress) { progress = value })
	if err != nil {
		t.Fatal(err)
	}
	if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 {
		t.Fatalf("unexpected summary: %+v", summary)
	}
	if progress.PercentDone != 0.5 || progress.FilesDone != 6 || progress.BytesDone != 512 || len(progress.CurrentFiles) != 1 {
		t.Fatalf("unexpected progress: %+v", progress)
	}
	args, _ := os.ReadFile(argsPath)
	for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} {
		if !strings.Contains(string(args), expected) {
			t.Fatalf("arguments missing %q: %s", expected, args)
		}
	}
	password, _ := os.ReadFile(passwordPath)
	if string(password) != "top-secret" {
		t.Fatal("password file was not provided")
	}
}

func TestBackupLimitsCPUForThisJob(t *testing.T) {
	dir := t.TempDir()
	envPath := filepath.Join(dir, "gomaxprocs")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$GOMAXPROCS\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"cpu123\"}'\n", envPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	job := model.Job{ID: "job", Compression: "auto", CPUCores: 2}
	if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil {
		t.Fatal(err)
	}
	value, err := os.ReadFile(envPath)
	if err != nil {
		t.Fatal(err)
	}
	if string(value) != "2" {
		t.Fatalf("GOMAXPROCS = %q", value)
	}
}

func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
	dir := t.TempDir()
	argsPath := filepath.Join(dir, "args")
	imagePath := filepath.Join(dir, "image")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"image123\",\"data_added\":4,\"total_bytes_processed\":4,\"total_files_processed\":1}'\n", argsPath, imagePath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	job := model.Job{ID: "flash", Compression: "auto"}
	summary, err := runner.BackupImage(context.Background(), repo, job, strings.NewReader("disk"), nil)
	if err != nil {
		t.Fatal(err)
	}
	if summary.SnapshotID != "image123" || summary.TotalBytesProcessed != 4 {
		t.Fatalf("unexpected summary: %+v", summary)
	}
	args, _ := os.ReadFile(argsPath)
	for _, expected := range []string{"--stdin", "--stdin-filename", "urbm-usb-flash.img", "usb-image"} {
		if !strings.Contains(string(args), expected) {
			t.Fatalf("arguments missing %q: %s", expected, args)
		}
	}
	image, _ := os.ReadFile(imagePath)
	if string(image) != "disk" {
		t.Fatalf("streamed image = %q", image)
	}
}

func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) {
	dir := t.TempDir()
	argsPath := filepath.Join(dir, "args")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"total_size\":4096,\"total_file_count\":12,\"total_blob_count\":8}'\n", argsPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	stats, err := runner.Stats(context.Background(), repo, "raw-data")
	if err != nil {
		t.Fatal(err)
	}
	if stats.TotalSize != 4096 || stats.TotalFileCount != 12 || stats.TotalBlobCount != 8 {
		t.Fatalf("unexpected stats: %+v", stats)
	}
	args, _ := os.ReadFile(argsPath)
	for _, expected := range []string{"stats", "--json", "--mode", "raw-data"} {
		if !strings.Contains(string(args), expected) {
			t.Fatalf("arguments missing %q: %s", expected, args)
		}
	}
}

func TestStatsIgnoresProgressBeforePrettyPrintedJSON(t *testing.T) {
	dir := t.TempDir()
	script := filepath.Join(dir, "restic")
	body := `#!/bin/sh
printf '%s\n' '[0:00] 100.00%  12 / 12 index files loaded'
printf '%s\n' '{'
printf '%s\n' '  "total_size": 8192,'
printf '%s\n' '  "total_file_count": 24,'
printf '%s\n' '  "total_blob_count": 16'
printf '%s\n' '}'
`
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	stats, err := runner.Stats(context.Background(), repo, "raw-data")
	if err != nil {
		t.Fatal(err)
	}
	if stats.TotalSize != 8192 || stats.TotalFileCount != 24 || stats.TotalBlobCount != 16 {
		t.Fatalf("unexpected stats: %+v", stats)
	}
}

func TestStatsUnlocksStaleRepositoryLockAndRetries(t *testing.T) {
	dir := t.TempDir()
	logPath := filepath.Join(dir, "commands")
	countPath := filepath.Join(dir, "stats-count")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> '%s'
case " $* " in
  *" stats "*)
    count=0
    [ -f '%s' ] && count=$(cat '%s')
    count=$((count + 1))
    printf '%%s' "$count" > '%s'
    if [ "$count" -eq 1 ]; then
      printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2
      exit 11
    fi
    printf '%%s\n' '{"total_size":4096,"total_file_count":12}'
    ;;
esac
`, logPath, countPath, countPath, countPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	stats, err := runner.Stats(context.Background(), repo, "raw-data")
	if err != nil {
		t.Fatal(err)
	}
	if stats.TotalSize != 4096 {
		t.Fatalf("unexpected stats: %+v", stats)
	}
	commands, err := os.ReadFile(logPath)
	if err != nil {
		t.Fatal(err)
	}
	lines := strings.Split(strings.TrimSpace(string(commands)), "\n")
	if len(lines) != 3 || !strings.Contains(lines[0], "stats") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "stats") {
		t.Fatalf("expected stats, unlock, stats; got %q", lines)
	}
}

func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
	dir := t.TempDir()
	argsPath := filepath.Join(dir, "args")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30, Weekly: 4, Monthly: 12}}
	if err := runner.Forget(context.Background(), repo, job); err != nil {
		t.Fatal(err)
	}
	args, _ := os.ReadFile(argsPath)
	for _, expected := range []string{"--keep-within", "30d", "--keep-weekly", "4", "--keep-monthly", "12"} {
		if !strings.Contains(string(args), expected) {
			t.Fatalf("retention arguments missing %q: %s", expected, args)
		}
	}
	if strings.Contains(string(args), "--keep-daily") {
		t.Fatalf("disabled daily retention was included: %s", args)
	}
}

func TestForgetUnlocksStaleRepositoryLockAndRetries(t *testing.T) {
	dir := t.TempDir()
	logPath := filepath.Join(dir, "commands")
	countPath := filepath.Join(dir, "forget-count")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> '%s'
case " $* " in
  *" forget "*)
    count=0
    [ -f '%s' ] && count=$(cat '%s')
    count=$((count + 1))
    printf '%%s' "$count" > '%s'
    if [ "$count" -eq 1 ]; then
      printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2
      exit 11
    fi
    ;;
esac
`, logPath, countPath, countPath, countPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30}}
	if err := runner.Forget(context.Background(), repo, job); err != nil {
		t.Fatal(err)
	}
	commands, err := os.ReadFile(logPath)
	if err != nil {
		t.Fatal(err)
	}
	lines := strings.Split(strings.TrimSpace(string(commands)), "\n")
	if len(lines) != 3 || !strings.Contains(lines[0], "forget") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "forget") {
		t.Fatalf("expected forget, unlock, forget; got %q", lines)
	}
}

func TestUnlockUsesSafeResticCommand(t *testing.T) {
	dir := t.TempDir()
	argsPath := filepath.Join(dir, "args")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	if err := runner.Unlock(context.Background(), repo); err != nil {
		t.Fatal(err)
	}
	args, _ := os.ReadFile(argsPath)
	if !strings.Contains(string(args), "unlock") {
		t.Fatalf("unlock command missing: %s", args)
	}
	if strings.Contains(string(args), "--remove-all") {
		t.Fatalf("unsafe remove-all option used: %s", args)
	}
}

func TestResticErrorExplainsExistingRepository(t *testing.T) {
	err := resticError("Fatal: create repository failed: config file already exists", fmt.Errorf("exit status 1"))
	if !strings.Contains(err.Error(), "already initialized") || !strings.Contains(err.Error(), "select Test") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestResticErrorExplainsCiphertextVerification(t *testing.T) {
	err := resticError(`{"message_type":"exit_error","message":"Fatal: config or key abc is damaged: ciphertext verification failed"}`, fmt.Errorf("exit status 1"))
	if !strings.Contains(err.Error(), "password does not match") || strings.Contains(err.Error(), "abc") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestResticErrorExplainsMissingExistingRepositoryPath(t *testing.T) {
	err := resticError("Fatal: repository does not exist", fmt.Errorf("exit status 10"))
	if !strings.Contains(err.Error(), "exakten Repository-Ordner") || !strings.Contains(err.Error(), "wirklich neues") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestRunnerPausesResumesAndCancelsProcessGroup(t *testing.T) {
	dir := t.TempDir()
	ticksPath := filepath.Join(dir, "ticks")
	script := filepath.Join(dir, "restic")
	body := fmt.Sprintf("#!/bin/sh\nwhile :; do printf x >> '%s'; sleep 0.05; done\n", ticksPath)
	if err := os.WriteFile(script, []byte(body), 0700); err != nil {
		t.Fatal(err)
	}
	runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
	repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
	ctx, cancel := context.WithCancel(WithRunID(context.Background(), "run"))
	done := make(chan error, 1)
	go func() { done <- runner.run(ctx, repo, []string{"backup"}, nil, nil) }()

	waitForSize := func(minimum int64) int64 {
		deadline := time.Now().Add(2 * time.Second)
		for time.Now().Before(deadline) {
			if info, err := os.Stat(ticksPath); err == nil && info.Size() >= minimum {
				return info.Size()
			}
			time.Sleep(10 * time.Millisecond)
		}
		t.Fatalf("tick file did not reach %d bytes", minimum)
		return 0
	}
	waitForSize(2)
	if err := runner.Pause("run"); err != nil {
		t.Fatal(err)
	}
	time.Sleep(100 * time.Millisecond)
	pausedSize := waitForSize(1)
	time.Sleep(150 * time.Millisecond)
	info, err := os.Stat(ticksPath)
	if err != nil {
		t.Fatal(err)
	}
	if info.Size() != pausedSize {
		t.Fatalf("process continued while paused: size=%d, expected=%d", info.Size(), pausedSize)
	}
	if err := runner.Resume("run"); err != nil {
		t.Fatal(err)
	}
	waitForSize(pausedSize + 2)
	if err := runner.Pause("run"); err != nil {
		t.Fatal(err)
	}
	cancel()
	select {
	case err := <-done:
		if err == nil || !strings.Contains(err.Error(), "cancelled") {
			t.Fatalf("cancel result = %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("paused process group was not cancelled")
	}
}
