package runner

import (
	"context"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"sync"
	"syscall"
	"testing"
	"time"

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

// fakeSender collects sent envelopes for assertions. Lock-protected
// because the runner's pumpStdout / pumpStderr goroutines call Send
// concurrently — without the mutex, -race in CI flags every test
// that exercises a Run* method with both pumps active.
type fakeSender struct {
	mu   sync.Mutex
	envs []api.Envelope
}

func (s *fakeSender) Send(e api.Envelope) error {
	s.mu.Lock()
	s.envs = append(s.envs, e)
	s.mu.Unlock()
	return nil
}

// snapshot returns a copy of the captured envelopes safe to read
// without holding the lock. Tests use this when iterating envs while
// other goroutines may still be writing — though in practice all
// runner Run* methods join their pumps before returning, so callers
// can also read .envs directly post-return.
func (s *fakeSender) snapshot() []api.Envelope {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]api.Envelope, len(s.envs))
	copy(out, s.envs)
	return out
}

// setupScript writes a shell script (without shebang) to a temp dir,
// names it "restic", makes it executable, and returns the path.
//
// Writes to "<path>.tmp" then renames into place. The rename is the
// usual guard against ETXTBSY: under -race + many t.Parallel tests,
// a fork-from-another-goroutine can inherit the writable fd from
// os.WriteFile before close completes, and exec'ing the file then
// returns ETXTBSY ("text file busy"). The renamed dirent points at
// an inode that has no writable fd open anywhere — exec is safe on
// a vanilla filesystem.
//
// On overlayfs (every job that runs inside a `container:` block on
// our Gitea runner), the rename can briefly leak ETXTBSY anyway —
// the upper layer's "writable inode" bookkeeping lags the userspace
// close. To make the helper deterministic across environments, we
// probe-exec the file with a benign argument until exec succeeds,
// then return. Each script body has a `case "$1" in ... esac` shape
// where unknown args fall through to a clean exit, so the probe is
// a no-op from the test's point of view.
func setupScript(t *testing.T, body string) string {
	t.Helper()
	dir := t.TempDir()
	final := filepath.Join(dir, "restic")
	tmp := final + ".tmp"
	if err := os.WriteFile(tmp, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
		t.Fatalf("setupScript: write tmp: %v", err)
	}
	if err := os.Rename(tmp, final); err != nil {
		t.Fatalf("setupScript: rename: %v", err)
	}

	deadline := time.Now().Add(3 * time.Second)
	for {
		err := exec.Command(final, "__rm_probe__").Run()
		if err == nil {
			return final
		}
		if !errors.Is(err, syscall.ETXTBSY) {
			t.Fatalf("setupScript: probe exec: %v", err)
		}
		if time.Now().After(deadline) {
			t.Fatalf("setupScript: %s still ETXTBSY after 3s", final)
		}
		time.Sleep(10 * time.Millisecond)
	}
}

// firstEnvOfType returns the first envelope with the given type, or
// fails the test if none is found.
func firstEnvOfType(t *testing.T, envs []api.Envelope, mt api.MessageType) api.Envelope {
	t.Helper()
	for _, e := range envs {
		if e.Type == mt {
			return e
		}
	}
	t.Fatalf("no envelope of type %q found in %d envelopes", mt, len(envs))
	return api.Envelope{}
}

// envelopeOrder returns the message types of all sent envelopes.
func envelopeOrder(envs []api.Envelope) []api.MessageType {
	out := make([]api.MessageType, len(envs))
	for i, e := range envs {
		out[i] = e.Type
	}
	return out
}

// TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake
// binary that prints "prune" on stdout (for the log.stream envelope)
// and emits valid stats JSON so reportStats can populate size fields.
// Expected sequence: job.started → log.stream → repo.stats → job.finished.
func TestRunPruneShipsExpectedEnvelopes(t *testing.T) {
	t.Parallel()

	// The fake "restic" handles both "prune" and "stats --json" calls.
	statsJSON := `{"total_size":1000,"total_uncompressed_size":2000,"snapshots_count":3,"total_file_count":10}`
	bin := setupScript(t, `
case "$1" in
  prune)  echo "prune" ;;
  stats)  echo '`+statsJSON+`' ;;
  *)      echo "unknown: $*" ;;
esac
`)

	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	if err := r.RunPrune(context.Background(), "job-1"); err != nil {
		t.Fatalf("RunPrune: %v", err)
	}

	order := envelopeOrder(tx.envs)
	// Confirm landmark envelope types appear in the required order.
	wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
	positions := map[api.MessageType]int{}
	for i, mt := range order {
		if _, seen := positions[mt]; !seen {
			positions[mt] = i
		}
	}
	for i := 0; i < len(wantTypes)-1; i++ {
		a, b := wantTypes[i], wantTypes[i+1]
		pa, aOK := positions[a]
		pb, bOK := positions[b]
		if !aOK {
			t.Errorf("envelope type %q not found in output %v", a, order)
			continue
		}
		if !bOK {
			t.Errorf("envelope type %q not found in output %v", b, order)
			continue
		}
		if pa >= pb {
			t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
		}
	}

	// The repo.stats payload must have LastPruneAt set.
	statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
	var statsPayload api.RepoStatsPayload
	if err := statsEnv.UnmarshalPayload(&statsPayload); err != nil {
		t.Fatalf("unmarshal repo.stats payload: %v", err)
	}
	if statsPayload.LastPruneAt == nil {
		t.Error("expected LastPruneAt to be set in repo.stats after prune")
	}

	// The job.finished payload must indicate success.
	finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished)
	var finPayload api.JobFinishedPayload
	if err := finEnv.UnmarshalPayload(&finPayload); err != nil {
		t.Fatalf("unmarshal job.finished payload: %v", err)
	}
	if finPayload.Status != api.JobSucceeded {
		t.Errorf("expected job.finished status=%q, got %q", api.JobSucceeded, finPayload.Status)
	}
}

// TestRunCheckShipsCheckStatus verifies that a check run which emits
// a stale-lock line on stderr (exit 0) reports LastCheckStatus="ok"
// and LockPresent=true.
func TestRunCheckShipsCheckStatus(t *testing.T) {
	t.Parallel()

	statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}`
	bin := setupScript(t, `
case "$1" in
  check) echo "Found stale lock" >&2; exit 0 ;;
  stats) echo '`+statsJSON+`' ;;
  *)     exit 0 ;;
esac
`)

	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	if err := r.RunCheck(context.Background(), "job-2", 0); err != nil {
		t.Fatalf("RunCheck: %v", err)
	}

	// Assert envelope ordering: job.started → log.stream → repo.stats → job.finished.
	order := envelopeOrder(tx.envs)
	wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
	positions := map[api.MessageType]int{}
	for i, mt := range order {
		if _, seen := positions[mt]; !seen {
			positions[mt] = i
		}
	}
	for i := 0; i < len(wantTypes)-1; i++ {
		a, b := wantTypes[i], wantTypes[i+1]
		pa, aOK := positions[a]
		pb, bOK := positions[b]
		if !aOK {
			t.Errorf("envelope type %q not found in output %v", a, order)
			continue
		}
		if !bOK {
			t.Errorf("envelope type %q not found in output %v", b, order)
			continue
		}
		if pa >= pb {
			t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
		}
	}

	statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
	var p api.RepoStatsPayload
	if err := statsEnv.UnmarshalPayload(&p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.LastCheckStatus != "ok" {
		t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "ok")
	}
	if p.LockPresent == nil || !*p.LockPresent {
		t.Errorf("expected LockPresent=true, got %v", p.LockPresent)
	}
	if p.LastCheckAt == nil {
		t.Error("expected LastCheckAt to be set")
	}
}

// TestRunCheckErrorsFoundShipsErrorsStatus verifies that a check run
// that exits 1 (errors found) reports LastCheckStatus="errors_found".
func TestRunCheckErrorsFoundShipsErrorsStatus(t *testing.T) {
	t.Parallel()

	statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}`
	bin := setupScript(t, `
case "$1" in
  check) exit 1 ;;
  stats) echo '`+statsJSON+`' ;;
  *)     exit 0 ;;
esac
`)

	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	// RunCheck returns nil for exit 1 (errors_found is not a wrapper failure).
	if err := r.RunCheck(context.Background(), "job-3", 0); err != nil {
		t.Fatalf("RunCheck: %v", err)
	}

	// Assert envelope ordering: job.started → repo.stats → job.finished.
	// (No log.stream expected because the fake script produces no
	// output before exit 1 — a real restic check would emit log lines
	// before exiting non-zero.)
	order := envelopeOrder(tx.envs)
	wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgRepoStats, api.MsgJobFinished}
	positions := map[api.MessageType]int{}
	for i, mt := range order {
		if _, seen := positions[mt]; !seen {
			positions[mt] = i
		}
	}
	for i := 0; i < len(wantTypes)-1; i++ {
		a, b := wantTypes[i], wantTypes[i+1]
		pa, aOK := positions[a]
		pb, bOK := positions[b]
		if !aOK {
			t.Errorf("envelope type %q not found in output %v", a, order)
			continue
		}
		if !bOK {
			t.Errorf("envelope type %q not found in output %v", b, order)
			continue
		}
		if pa >= pb {
			t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
		}
	}

	statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
	var p api.RepoStatsPayload
	if err := statsEnv.UnmarshalPayload(&p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.LastCheckStatus != "errors_found" {
		t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "errors_found")
	}
}

// TestRunUnlockClearsLock verifies that a successful unlock ships a
// repo.stats envelope with LockPresent=false.
func TestRunUnlockClearsLock(t *testing.T) {
	t.Parallel()

	statsJSON := `{"total_size":100,"total_uncompressed_size":150,"snapshots_count":2,"total_file_count":8}`
	bin := setupScript(t, `
case "$1" in
  unlock) echo "removed 1 locks" ;;
  stats)  echo '`+statsJSON+`' ;;
  *)      exit 0 ;;
esac
`)

	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	if err := r.RunUnlock(context.Background(), "job-4"); err != nil {
		t.Fatalf("RunUnlock: %v", err)
	}

	// Assert envelope ordering: job.started → log.stream → repo.stats → job.finished.
	order := envelopeOrder(tx.envs)
	wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
	positions := map[api.MessageType]int{}
	for i, mt := range order {
		if _, seen := positions[mt]; !seen {
			positions[mt] = i
		}
	}
	for i := 0; i < len(wantTypes)-1; i++ {
		a, b := wantTypes[i], wantTypes[i+1]
		pa, aOK := positions[a]
		pb, bOK := positions[b]
		if !aOK {
			t.Errorf("envelope type %q not found in output %v", a, order)
			continue
		}
		if !bOK {
			t.Errorf("envelope type %q not found in output %v", b, order)
			continue
		}
		if pa >= pb {
			t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
		}
	}

	statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
	var p api.RepoStatsPayload
	if err := statsEnv.UnmarshalPayload(&p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.LockPresent == nil {
		t.Fatal("expected LockPresent to be set (non-nil)")
	}
	if *p.LockPresent {
		t.Errorf("expected LockPresent=false after unlock, got true")
	}
}

// TestRunInitShipsStartedAndFinished confirms the refactored RunInit
// still produces job.started and job.finished envelopes.
func TestRunInitShipsStartedAndFinished(t *testing.T) {
	t.Parallel()
	bin := setupScript(t, `echo "initialised repository"`)
	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	if err := r.RunInit(context.Background(), "job-init"); err != nil {
		t.Fatalf("RunInit: %v", err)
	}
	_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
	_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished)
}

// TestRunForgetShipsStartedAndFinished confirms the refactored
// RunForget still produces job.started and job.finished envelopes.
func TestRunForgetShipsStartedAndFinished(t *testing.T) {
	t.Parallel()
	// Script handles both "forget --json ..." and "snapshots --json" calls.
	bin := setupScript(t, `
case "$1" in
  forget)    echo "[]" ;;
  snapshots) echo "[]" ;;
  *)         exit 0 ;;
esac
`)
	tx := &fakeSender{}
	r := New(Config{ResticBin: bin}, tx, 0)
	keepLast := 1
	groups := []restic.ForgetGroup{{
		Tag:    "documents",
		Policy: restic.ForgetPolicy{KeepLast: &keepLast},
	}}
	if err := r.RunForget(context.Background(), "job-forget", groups, false); err != nil {
		t.Fatalf("RunForget: %v", err)
	}
	_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
	_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished)
}
