package invocation

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

	"github.com/stretchr/testify/require"
)

type fakeScheduleClock struct {
	now   time.Time
	calls int
}

func (clock *fakeScheduleClock) Now() time.Time {
	clock.calls++
	return clock.now
}

type fakeScheduleCandidate struct {
	sourceID     string
	workspaceID  string
	occurrenceAt time.Time
}

func (candidate fakeScheduleCandidate) SourceID() string        { return candidate.sourceID }
func (candidate fakeScheduleCandidate) WorkspaceID() string     { return candidate.workspaceID }
func (candidate fakeScheduleCandidate) OccurrenceAt() time.Time { return candidate.occurrenceAt }

type fakeScheduledMaterializer struct {
	candidates []ScheduleCandidate
	listErr    error

	listInputs []ListDueScheduleInput
	attempted  []ScheduleCandidate
	nows       []time.Time
	results    map[string]ScheduleMaterializeResult
	errors     map[string]error
}

func (materializer *fakeScheduledMaterializer) ListDueScheduleCandidates(_ context.Context, input ListDueScheduleInput) ([]ScheduleCandidate, error) {
	materializer.listInputs = append(materializer.listInputs, input)
	if materializer.listErr != nil {
		return nil, materializer.listErr
	}
	return materializer.candidates, nil
}

func (materializer *fakeScheduledMaterializer) MaterializeScheduled(_ context.Context, candidate ScheduleCandidate, now time.Time) (ScheduleMaterializeResult, error) {
	materializer.attempted = append(materializer.attempted, candidate)
	materializer.nows = append(materializer.nows, now)
	if err := materializer.errors[candidate.SourceID()]; err != nil {
		return ScheduleMaterializeResult{}, err
	}
	return materializer.results[candidate.SourceID()], nil
}

func TestScheduleDispatcherRunOnceClassifiesEveryDisposition(t *testing.T) {
	now := time.Date(2026, 7, 21, 14, 0, 0, 987654321, time.FixedZone("offset", 8*60*60))
	clock := &fakeScheduleClock{now: now}
	materializer := &fakeScheduledMaterializer{
		results: map[string]ScheduleMaterializeResult{
			"source-created":  {Disposition: ScheduleMaterializeCreated},
			"source-replayed": {Disposition: ScheduleMaterializeReplayed},
			"source-stale":    {Disposition: ScheduleMaterializeStale},
			"source-fenced":   {Disposition: ScheduleMaterializeFenced},
			"source-blocked":  {Disposition: ScheduleMaterializeBlocked},
		},
	}
	for sourceID := range materializer.results {
		materializer.candidates = append(materializer.candidates, fakeScheduleCandidate{
			sourceID: sourceID, workspaceID: "workspace-1", occurrenceAt: now,
		})
	}

	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{Clock: clock, BatchSize: 7})
	require.NoError(t, err)
	outcome, err := dispatcher.RunOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, ScheduleDispatchOutcome{
		DueListed: 5,
		Attempted: 5,
		Created:   1,
		Replayed:  1,
		Stale:     1,
		Fenced:    1,
		Blocked:   1,
	}, outcome)

	wantNow := now.UTC().Truncate(time.Microsecond)
	require.Equal(t, 1, clock.calls)
	require.Equal(t, []ListDueScheduleInput{{Now: wantNow, Limit: 7}}, materializer.listInputs)
	require.Len(t, materializer.nows, 5)
	for _, materializeNow := range materializer.nows {
		require.Equal(t, wantNow, materializeNow)
	}
}

func TestScheduleDispatcherRunOnceAggregatesCandidateErrorsAndContinues(t *testing.T) {
	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	materializer := &fakeScheduledMaterializer{
		candidates: []ScheduleCandidate{
			fakeScheduleCandidate{sourceID: "bad", workspaceID: "workspace-1", occurrenceAt: now},
			fakeScheduleCandidate{sourceID: "good", workspaceID: "workspace-1", occurrenceAt: now},
			fakeScheduleCandidate{sourceID: "unknown", workspaceID: "workspace-1", occurrenceAt: now},
		},
		results: map[string]ScheduleMaterializeResult{
			"good":    {Disposition: ScheduleMaterializeCreated},
			"unknown": {Disposition: ScheduleMaterializeDisposition("future-value")},
		},
		errors: map[string]error{"bad": errors.New("database unavailable")},
	}
	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{
		Clock: &fakeScheduleClock{now: now},
	})
	require.NoError(t, err)

	outcome, err := dispatcher.RunOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, 3, outcome.DueListed)
	require.Equal(t, 3, outcome.Attempted)
	require.Equal(t, 1, outcome.Created)
	require.Len(t, outcome.Errors, 2)
	require.ErrorContains(t, outcome.Errors[0], "workspace-1/bad")
	require.ErrorContains(t, outcome.Errors[0], "database unavailable")
	require.ErrorContains(t, outcome.Errors[1], "workspace-1/unknown")
	require.ErrorContains(t, outcome.Errors[1], "future-value")
	require.Equal(t, []string{"bad", "good", "unknown"}, attemptedSourceIDs(materializer.attempted))
}

func TestScheduleDispatcherRunOnceIsDefensivelyBounded(t *testing.T) {
	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	materializer := &fakeScheduledMaterializer{results: map[string]ScheduleMaterializeResult{}}
	for index := 0; index < 5; index++ {
		sourceID := fmt.Sprintf("source-%d", index)
		materializer.candidates = append(materializer.candidates, fakeScheduleCandidate{
			sourceID: sourceID, workspaceID: "workspace-1", occurrenceAt: now,
		})
		materializer.results[sourceID] = ScheduleMaterializeResult{Disposition: ScheduleMaterializeStale}
	}
	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{
		Clock:     &fakeScheduleClock{now: now},
		BatchSize: 2,
	})
	require.NoError(t, err)

	outcome, err := dispatcher.RunOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, 5, outcome.DueListed)
	require.Equal(t, 2, outcome.Attempted)
	require.Equal(t, 2, outcome.Stale)
	require.Len(t, outcome.Errors, 1)
	require.ErrorContains(t, outcome.Errors[0], "returned 5 candidates for limit 2")
	require.Equal(t, []string{"source-0", "source-1"}, attemptedSourceIDs(materializer.attempted))
}

func TestScheduleDispatcherRunOnceRejectsNilCandidateWithoutAbortingBatch(t *testing.T) {
	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	materializer := &fakeScheduledMaterializer{
		candidates: []ScheduleCandidate{
			nil,
			fakeScheduleCandidate{sourceID: "good", workspaceID: "workspace-1", occurrenceAt: now},
		},
		results: map[string]ScheduleMaterializeResult{
			"good": {Disposition: ScheduleMaterializeCreated},
		},
	}
	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{Clock: &fakeScheduleClock{now: now}})
	require.NoError(t, err)

	outcome, err := dispatcher.RunOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, 2, outcome.DueListed)
	require.Equal(t, 1, outcome.Attempted)
	require.Equal(t, 1, outcome.Created)
	require.Len(t, outcome.Errors, 1)
	require.ErrorContains(t, outcome.Errors[0], "candidate 0 is nil")
}

func TestScheduleDispatcherRunOnceReturnsListError(t *testing.T) {
	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	materializer := &fakeScheduledMaterializer{listErr: errors.New("read failed")}
	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{Clock: &fakeScheduleClock{now: now}})
	require.NoError(t, err)

	outcome, err := dispatcher.RunOnce(context.Background())
	require.ErrorContains(t, err, "list due schedule candidates: read failed")
	require.Equal(t, ScheduleDispatchOutcome{}, outcome)
	require.Empty(t, materializer.attempted)
}

func TestScheduleDispatcherDefaultsAndValidation(t *testing.T) {
	_, err := NewScheduleDispatcher(nil, ScheduleDispatcherOptions{})
	require.ErrorContains(t, err, "scheduled materializer is required")

	now := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
	materializer := &fakeScheduledMaterializer{}
	dispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{Clock: &fakeScheduleClock{now: now}})
	require.NoError(t, err)
	_, err = dispatcher.RunOnce(context.Background())
	require.NoError(t, err)
	require.Equal(t, defaultScheduleDispatchBatchSize, materializer.listInputs[0].Limit)

	zeroClockDispatcher, err := NewScheduleDispatcher(materializer, ScheduleDispatcherOptions{Clock: &fakeScheduleClock{}})
	require.NoError(t, err)
	_, err = zeroClockDispatcher.RunOnce(context.Background())
	require.ErrorContains(t, err, "clock returned a zero time")

	var nilDispatcher *ScheduleDispatcher
	_, err = nilDispatcher.RunOnce(context.Background())
	require.ErrorContains(t, err, "not initialized")
}

func attemptedSourceIDs(candidates []ScheduleCandidate) []string {
	ids := make([]string, 0, len(candidates))
	for _, candidate := range candidates {
		ids = append(ids, candidate.SourceID())
	}
	return ids
}
