package runners_test

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/yan1h/agent-coding-workflow/internal/audit"
	"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
	"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
)

type recordingRecorder struct {
	entries []audit.Entry
}

func (r *recordingRecorder) Record(_ context.Context, e audit.Entry) error {
	r.entries = append(r.entries, e)
	return nil
}

// fakeWorktreeRepo is a fresh fake distinct from fakeWorkspaceFetchRepo.
type fakeWorktreeRepo struct {
	warnList    []runners.WorktreeRow
	pruneList   []runners.WorktreeRow
	warnListErr error
	pruneListErr error

	markedWarning  []uuid.UUID
	markWarningErr error

	tryStartResult  runners.WorktreeRow
	tryStartErr     error
	tryStartCalled  map[uuid.UUID]bool

	finalized       map[uuid.UUID]string // "ok" | "rollback"
	finishSuccessErr error
	finishRollbackErr error
}

func (f *fakeWorktreeRepo) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
	return f.warnList, f.warnListErr
}

func (f *fakeWorktreeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
	return f.pruneList, f.pruneListErr
}

func (f *fakeWorktreeRepo) MarkPruneWarning(_ context.Context, id uuid.UUID) error {
	f.markedWarning = append(f.markedWarning, id)
	return f.markWarningErr
}

func (f *fakeWorktreeRepo) TryStartPrune(_ context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
	if f.tryStartCalled == nil {
		f.tryStartCalled = map[uuid.UUID]bool{}
	}
	f.tryStartCalled[id] = true
	if f.tryStartErr != nil {
		return runners.WorktreeRow{}, f.tryStartErr
	}
	return f.tryStartResult, nil
}

func (f *fakeWorktreeRepo) FinishPruneSuccess(_ context.Context, id uuid.UUID) error {
	if f.finalized == nil {
		f.finalized = map[uuid.UUID]string{}
	}
	f.finalized[id] = "ok"
	return f.finishSuccessErr
}

func (f *fakeWorktreeRepo) FinishPruneRollback(_ context.Context, id uuid.UUID) error {
	if f.finalized == nil {
		f.finalized = map[uuid.UUID]string{}
	}
	f.finalized[id] = "rollback"
	return f.finishRollbackErr
}

// fakePruner records WorktreeRemove calls.
type fakePruner struct {
	err   error
	calls int
}

func (f *fakePruner) WorktreeRemove(_ context.Context, _, _ string) error {
	f.calls++
	return f.err
}

func TestWorktreePrune_WarningPhase(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-x", MainPath: "/main", Path: "/main/.worktrees/feat-x"}

	repo := &fakeWorktreeRepo{warnList: []runners.WorktreeRow{row}}
	disp := &recordingDispatcher{}
	gitr := &fakePruner{}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
	r.Run(context.Background())

	require.Len(t, repo.markedWarning, 1)
	assert.Equal(t, id, repo.markedWarning[0])

	require.Len(t, disp.msgs, 1)
	msg := disp.msgs[0]
	assert.Equal(t, ownerID, msg.UserID)
	assert.Equal(t, "worktree.prune_pending", msg.Topic)
	assert.Equal(t, notify.SeverityWarning, msg.Severity)

	// execution phase: pruneList is empty, so no prune calls.
	assert.Zero(t, gitr.calls)
}

func TestWorktreePrune_ExecutionPhaseSuccess(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-y", MainPath: "/main", Path: "/main/.worktrees/feat-y"}

	repo := &fakeWorktreeRepo{
		pruneList:       []runners.WorktreeRow{row},
		tryStartResult:  row,
	}
	gitr := &fakePruner{}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
	r.Run(context.Background())

	assert.True(t, repo.tryStartCalled[id])
	assert.Equal(t, 1, gitr.calls)
	require.NotNil(t, repo.finalized)
	assert.Equal(t, "ok", repo.finalized[id])
}

func TestWorktreePrune_ExecutionFailureRollsBack(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-z", MainPath: "/main", Path: "/main/.worktrees/feat-z"}

	repo := &fakeWorktreeRepo{
		pruneList:      []runners.WorktreeRow{row},
		tryStartResult: row,
	}
	gitr := &fakePruner{err: errors.New("git worktree remove failed")}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
	r.Run(context.Background())

	assert.True(t, repo.tryStartCalled[id])
	assert.Equal(t, 1, gitr.calls)
	require.NotNil(t, repo.finalized)
	assert.Equal(t, "rollback", repo.finalized[id])
}

func TestWorktreePrune_NoRowsIsNoOp(t *testing.T) {
	t.Parallel()
	repo := &fakeWorktreeRepo{} // both lists nil
	gitr := &fakePruner{}
	disp := &recordingDispatcher{}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
	r.Run(context.Background())

	assert.Empty(t, repo.markedWarning)
	assert.Empty(t, repo.tryStartCalled)
	assert.Empty(t, repo.finalized)
	assert.Zero(t, gitr.calls)
	assert.Empty(t, disp.msgs)
}

func TestWorktreePrune_AuditMetadataContainsWorkspaceIDAndPath(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	workspaceID := uuid.New()
	ownerID := uuid.New()
	row := runners.WorktreeRow{
		ID: id, WorkspaceID: workspaceID, OwnerID: ownerID,
		Branch: "feat-audit", MainPath: "/main", Path: "/main/.worktrees/feat-audit",
	}

	repo := &fakeWorktreeRepo{
		pruneList:      []runners.WorktreeRow{row},
		tryStartResult: row,
	}
	gitr := &fakePruner{}
	rec := &recordingRecorder{}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), rec)
	r.Run(context.Background())

	require.Len(t, rec.entries, 1)
	e := rec.entries[0]
	assert.Equal(t, "worktree.pruned", e.Action)
	assert.Equal(t, "worktree", e.TargetType)
	assert.Equal(t, id.String(), e.TargetID)
	assert.Equal(t, workspaceID.String(), e.Metadata["workspace_id"])
	assert.Equal(t, "feat-audit", e.Metadata["branch"])
	assert.Equal(t, "/main/.worktrees/feat-audit", e.Metadata["path"])
}

func TestWorktreePrune_CASLostSkipsSilently(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-cas", MainPath: "/main", Path: "/main/.worktrees/feat-cas"}

	repo := &fakeWorktreeRepo{
		pruneList:   []runners.WorktreeRow{row},
		tryStartErr: errors.New("worktree no longer idle"),
	}
	gitr := &fakePruner{}
	cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}

	r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
	r.Run(context.Background())

	// TryStartPrune was called but failed.
	assert.True(t, repo.tryStartCalled[id])
	// No git call.
	assert.Zero(t, gitr.calls)
	// No finalize for that ID.
	assert.Empty(t, repo.finalized)
}
