package runners_test

import (
	"context"
	"errors"
	"testing"

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

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

// fakeWorkspaceFetchRepo: inline mock holding a few workspaces + recorded calls.
type fakeWorkspaceFetchRepo struct {
	listed   []runners.WorkspaceFetchTarget
	startCAS map[uuid.UUID]bool
	finished map[uuid.UUID]string

	// markSyncingErrFor: if an entry exists for ID, MarkSyncing returns (false, err).
	markSyncingErrFor map[uuid.UUID]error
	// markSyncingLost: if true (and no error matched), returns (false, nil) to simulate CAS lost.
	markSyncingLost bool
	// markFetchResultErr: if non-nil, MarkFetchResult returns it (after recording the call).
	markFetchResultErr error
}

func (f *fakeWorkspaceFetchRepo) ListFetchTargets(_ context.Context) ([]runners.WorkspaceFetchTarget, error) {
	return f.listed, nil
}

func (f *fakeWorkspaceFetchRepo) MarkSyncing(_ context.Context, id uuid.UUID) (bool, error) {
	if f.startCAS == nil {
		f.startCAS = map[uuid.UUID]bool{}
	}
	if err, ok := f.markSyncingErrFor[id]; ok {
		return false, err
	}
	if f.markSyncingLost {
		return false, nil
	}
	f.startCAS[id] = true
	return true, nil
}

func (f *fakeWorkspaceFetchRepo) MarkFetchResult(_ context.Context, id uuid.UUID, success bool, errMsg string) error {
	if f.finished == nil {
		f.finished = map[uuid.UUID]string{}
	}
	if success {
		f.finished[id] = "ok"
	} else {
		f.finished[id] = errMsg
	}
	return f.markFetchResultErr
}

type fakeFetcher struct {
	err    error
	calls  int
	fetched map[uuid.UUID]bool
}

func (f *fakeFetcher) Fetch(_ context.Context, _ string, id uuid.UUID) error {
	f.calls++
	if f.fetched == nil {
		f.fetched = map[uuid.UUID]bool{}
	}
	f.fetched[id] = true
	return f.err
}

type recordingDispatcher struct{ msgs []notify.Message }

func (r *recordingDispatcher) Dispatch(_ context.Context, m notify.Message) error {
	r.msgs = append(r.msgs, m)
	return nil
}

func TestWorkspaceFetch_SuccessUpdatesIdle(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	repo := &fakeWorkspaceFetchRepo{listed: []runners.WorkspaceFetchTarget{
		{ID: id, OwnerID: ownerID, Name: "ws", MainPath: "/x"},
	}}
	r := runners.NewWorkspaceFetch(repo, &fakeFetcher{}, &recordingDispatcher{}, discardLogger())
	r.Run(context.Background())
	assert.True(t, repo.startCAS[id])
	assert.Equal(t, "ok", repo.finished[id])
}

func TestWorkspaceFetch_FailureMarksErrorAndDispatches(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	ownerID := uuid.New()
	repo := &fakeWorkspaceFetchRepo{listed: []runners.WorkspaceFetchTarget{
		{ID: id, OwnerID: ownerID, Name: "ws", MainPath: "/x"},
	}}
	disp := &recordingDispatcher{}
	r := runners.NewWorkspaceFetch(repo, &fakeFetcher{err: errors.New("network")}, disp, discardLogger())
	r.Run(context.Background())
	assert.Contains(t, repo.finished[id], "network")
	require.Len(t, disp.msgs, 1)
	assert.Equal(t, "workspace.sync_failed", disp.msgs[0].Topic)
	assert.Equal(t, ownerID, disp.msgs[0].UserID)
	assert.Equal(t, notify.SeverityError, disp.msgs[0].Severity)
}

func TestWorkspaceFetch_NoTargetsNoOp(t *testing.T) {
	t.Parallel()
	repo := &fakeWorkspaceFetchRepo{}
	r := runners.NewWorkspaceFetch(repo, &fakeFetcher{}, &recordingDispatcher{}, discardLogger())
	r.Run(context.Background())
	assert.Empty(t, repo.startCAS)
}

func TestWorkspaceFetch_CASLostSkipsSilently(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	repo := &fakeWorkspaceFetchRepo{
		listed: []runners.WorkspaceFetchTarget{
			{ID: id, OwnerID: uuid.New(), Name: "ws", MainPath: "/x"},
		},
		markSyncingLost: true,
	}
	fetcher := &fakeFetcher{}
	disp := &recordingDispatcher{}
	r := runners.NewWorkspaceFetch(repo, fetcher, disp, discardLogger())
	r.Run(context.Background())
	assert.Zero(t, fetcher.calls, "Fetch must not be called when CAS lost")
	assert.NotContains(t, repo.finished, id, "MarkFetchResult must not be called when CAS lost")
	assert.Empty(t, disp.msgs, "no dispatch when CAS lost")
}

func TestWorkspaceFetch_MarkSyncingErrorContinuesLoop(t *testing.T) {
	t.Parallel()
	bad := uuid.New()
	good := uuid.New()
	repo := &fakeWorkspaceFetchRepo{
		listed: []runners.WorkspaceFetchTarget{
			{ID: bad, OwnerID: uuid.New(), Name: "bad", MainPath: "/b"},
			{ID: good, OwnerID: uuid.New(), Name: "good", MainPath: "/g"},
		},
		markSyncingErrFor: map[uuid.UUID]error{bad: errors.New("db down")},
	}
	fetcher := &fakeFetcher{}
	r := runners.NewWorkspaceFetch(repo, fetcher, &recordingDispatcher{}, discardLogger())
	r.Run(context.Background())
	assert.False(t, fetcher.fetched[bad], "Fetch must not run when MarkSyncing errors")
	assert.True(t, fetcher.fetched[good], "loop must continue to next target")
	assert.Equal(t, "ok", repo.finished[good])
}

func TestWorkspaceFetch_MarkFetchResultErrorOnSuccessDoesNotDispatch(t *testing.T) {
	t.Parallel()
	id := uuid.New()
	repo := &fakeWorkspaceFetchRepo{
		listed: []runners.WorkspaceFetchTarget{
			{ID: id, OwnerID: uuid.New(), Name: "ws", MainPath: "/x"},
		},
		markFetchResultErr: errors.New("db blip"),
	}
	fetcher := &fakeFetcher{}
	disp := &recordingDispatcher{}
	r := runners.NewWorkspaceFetch(repo, fetcher, disp, discardLogger())
	r.Run(context.Background())
	assert.Empty(t, disp.msgs, "success path must not dispatch even if MarkFetchResult errors")
}
