package restic

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

// setupScriptBin writes a small shell script to a temp directory,
// makes it executable, and returns its path. scriptBody is the
// complete script content (without the shebang line — that's added
// automatically).
// Writes to "<path>.tmp" then renames into place — see the matching
// helper in internal/agent/runner/runner_test.go for the ETXTBSY
// race rationale. Same fix applied here so this helper doesn't lose
// the race the next time CI gets unlucky.
func setupScriptBin(t *testing.T, scriptBody string) string {
	t.Helper()
	dir := t.TempDir()
	final := filepath.Join(dir, "restic")
	tmp := final + ".tmp"
	content := "#!/bin/sh\n" + scriptBody + "\n"
	if err := os.WriteFile(tmp, []byte(content), 0o755); err != nil {
		t.Fatalf("setupScriptBin: write tmp: %v", err)
	}
	if err := os.Rename(tmp, final); err != nil {
		t.Fatalf("setupScriptBin: rename: %v", err)
	}
	return final
}

// captureLines returns a LineHandler that appends "stream:line" into
// the returned slice pointer (safe for single-goroutine test use).
func captureLines() (*[]string, LineHandler) {
	var lines []string
	h := func(stream, line string, _ any) {
		lines = append(lines, fmt.Sprintf("%s:%s", stream, line))
	}
	return &lines, h
}

// --- B1: RunPrune + B2: RunCheck ---

func TestRunPruneInvokesPrune(t *testing.T) {
	// Shell script that echoes its args; "prune" should appear in output.
	bin := setupScriptBin(t, `echo "$@"`)
	env := Env{Bin: bin}
	lines, h := captureLines()
	if err := env.RunPrune(context.Background(), h); err != nil {
		t.Fatalf("RunPrune returned error: %v", err)
	}
	for _, l := range *lines {
		if strings.Contains(l, "prune") {
			return
		}
	}
	t.Fatalf("expected 'prune' in captured output; got: %v", *lines)
}

func TestRunForgetDryRunArgument(t *testing.T) {
	bin := setupScriptBin(t, `echo "$@"`)
	env := Env{Bin: bin}
	lines, h := captureLines()
	keepLast := 1
	groups := []ForgetGroup{{
		Tag:    "documents",
		Policy: ForgetPolicy{KeepLast: &keepLast},
	}}
	if err := env.RunForget(context.Background(), groups, true, h); err != nil {
		t.Fatalf("RunForget: %v", err)
	}
	for _, line := range *lines {
		if strings.Contains(line, "forget --json --tag documents --keep-last 1 --dry-run") {
			return
		}
	}
	t.Fatalf("expected forget invocation with --dry-run; got: %v", *lines)
}

// --- B2: RunCheck ---

func TestRunCheckLockSniff(t *testing.T) {
	cases := []struct {
		name       string
		stderrLine string
		wantLocked bool
	}{
		{"stale lock", "Found stale lock from PID 1234", true},
		{"already locked", "repository is already locked exclusively", true},
		{"benign mention", "subdir/locked-file ok", false},
		{"empty", "", false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			// Script emits the line on stderr, then exits 0.
			script := fmt.Sprintf(`printf '%%s\n' %q >&2`, c.stderrLine)
			bin := setupScriptBin(t, script)
			env := Env{Bin: bin}
			res, err := env.RunCheck(context.Background(), 0, nil)
			if err != nil {
				t.Fatalf("RunCheck returned unexpected error: %v", err)
			}
			if res.LockPresent != c.wantLocked {
				t.Fatalf("LockPresent: got %v, want %v (line: %q)", res.LockPresent, c.wantLocked, c.stderrLine)
			}
			if res.ErrorsFound {
				t.Fatal("expected ErrorsFound=false")
			}
		})
	}
}

func TestRunCheckErrorsFoundOnExit1(t *testing.T) {
	bin := setupScriptBin(t, `exit 1`)
	env := Env{Bin: bin}
	res, err := env.RunCheck(context.Background(), 0, nil)
	if err != nil {
		t.Fatalf("RunCheck returned unexpected error (should have absorbed exit 1): %v", err)
	}
	if !res.ErrorsFound {
		t.Fatal("expected ErrorsFound=true for exit 1")
	}
}

func TestRunCheckSubsetArg(t *testing.T) {
	bin := setupScriptBin(t, `echo "$@"`)
	env := Env{Bin: bin}
	lines, h := captureLines()
	if _, err := env.RunCheck(context.Background(), 25, h); err != nil {
		t.Fatalf("RunCheck: %v", err)
	}
	want := "--read-data-subset 25%"
	for _, l := range *lines {
		if strings.Contains(l, want) {
			return
		}
	}
	t.Fatalf("expected %q in captured output; got: %v", want, *lines)
}

// --- B3: RunUnlock + RunStats ---

func TestRunUnlockInvokesUnlock(t *testing.T) {
	bin := setupScriptBin(t, `echo "$@"`)
	env := Env{Bin: bin}
	lines, h := captureLines()
	if err := env.RunUnlock(context.Background(), h); err != nil {
		t.Fatalf("RunUnlock: %v", err)
	}
	for _, l := range *lines {
		if strings.Contains(l, "unlock") {
			return
		}
	}
	t.Fatalf("expected 'unlock' in captured output; got: %v", *lines)
}

func TestRunStatsParsesJSON(t *testing.T) {
	bin := setupScriptBin(t, `echo '{"total_size":1234,"total_uncompressed_size":5678,"snapshots_count":3,"total_file_count":100,"total_blob_count":50}'`)
	env := Env{Bin: bin}
	stats, err := env.RunStats(context.Background(), nil)
	if err != nil {
		t.Fatalf("RunStats: %v", err)
	}
	if stats.TotalSize != 1234 {
		t.Fatalf("TotalSize: got %d, want 1234", stats.TotalSize)
	}
	if stats.TotalUncompressed != 5678 {
		t.Fatalf("TotalUncompressed: got %d, want 5678", stats.TotalUncompressed)
	}
	if stats.SnapshotsCount != 3 {
		t.Fatalf("SnapshotsCount: got %d, want 3", stats.SnapshotsCount)
	}
	if stats.TotalFileCount != 100 {
		t.Fatalf("TotalFileCount: got %d, want 100", stats.TotalFileCount)
	}
	if stats.TotalBlobCount != 50 {
		t.Fatalf("TotalBlobCount: got %d, want 50", stats.TotalBlobCount)
	}
}

func TestRunStatsErrorsWithoutJSON(t *testing.T) {
	bin := setupScriptBin(t, `echo "no json here"`)
	env := Env{Bin: bin}
	_, err := env.RunStats(context.Background(), nil)
	if err == nil {
		t.Fatal("expected error when no JSON in output")
	}
	if !strings.Contains(err.Error(), "no JSON in output") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestBandwidthLimitFlagsInjected(t *testing.T) {
	// Script echoes its argv to stdout. Each variant should produce
	// the right --limit-* flags before the subcommand.
	cases := []struct {
		name string
		env  Env
		want []string
	}{
		{"both caps", Env{LimitUploadKBps: 1024, LimitDownloadKBps: 512}, []string{"--limit-upload 1024", "--limit-download 512"}},
		{"only upload", Env{LimitUploadKBps: 256}, []string{"--limit-upload 256"}},
		{"zero means omit", Env{LimitUploadKBps: 0, LimitDownloadKBps: 0}, nil},
		{"negative means omit", Env{LimitUploadKBps: -1}, nil},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			bin := setupScriptBin(t, `echo "$@"`)
			env := c.env
			env.Bin = bin
			lines, h := captureLines()
			if err := env.RunUnlock(context.Background(), h); err != nil {
				t.Fatalf("RunUnlock: %v", err)
			}
			joined := strings.Join(*lines, "\n")
			for _, want := range c.want {
				if !strings.Contains(joined, want) {
					t.Fatalf("want %q in argv; got: %s", want, joined)
				}
			}
			if len(c.want) == 0 {
				if strings.Contains(joined, "--limit-upload") || strings.Contains(joined, "--limit-download") {
					t.Fatalf("expected no limit flags; got: %s", joined)
				}
			}
		})
	}
}

func TestRunStatsZeroSnapshots(t *testing.T) {
	// Confirms RunStats succeeds and returns a valid *RepoStats when the
	// repo has no snapshots (snapshots_count=0). A regression that
	// re-added a "SnapshotsCount > 0" guard would return an error here.
	bin := setupScriptBin(t, `echo '{"total_size":0,"total_uncompressed_size":0,"snapshots_count":0,"total_file_count":0,"total_blob_count":0}'`)
	env := Env{Bin: bin}
	stats, err := env.RunStats(context.Background(), nil)
	if err != nil {
		t.Fatalf("RunStats with zero snapshots returned unexpected error: %v", err)
	}
	if stats == nil {
		t.Fatal("expected non-nil *RepoStats, got nil")
	}
	if stats.SnapshotsCount != 0 {
		t.Fatalf("SnapshotsCount: got %d, want 0", stats.SnapshotsCount)
	}
}
