package automation

import (
	"context"
	"sync"
	"testing"
	"time"

	"github.com/nexuscrm/shared/pkg/api"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// TestListDuePausedInterviews_FiltersAndSortsByPausedUntil verifies the
// query the scheduler depends on: (InterviewStatus='Paused' AND
// PausedUntil<=NOW()) ordered by PausedUntil ASC. The composite index
// idx_flow_interview_paused_scan exists in system_tables.json to back it.
func TestListDuePausedInterviews_FiltersAndSortsByPausedUntil(t *testing.T) {
	mockQ := new(MockInterviewQuery)
	svc := NewFlowInterviewService(nil, mockQ)

	now := time.Now().UTC()
	mockQ.On("Query", mock.Anything, mock.MatchedBy(func(req api.QueryRequest) bool {
		if req.ObjectAPIName != constants.TableFlowInterview {
			return false
		}
		// Must filter on both status=Paused and PausedUntil<=now.
		haveStatus := false
		havePausedUntil := false
		for _, c := range req.Criteria {
			if c.Field == constants.FieldFlowInterview_InterviewStatus && c.Op == "=" &&
				c.Val == models.FlowInterviewStatusPaused {
				haveStatus = true
			}
			if c.Field == constants.FieldFlowInterview_PausedUntil && c.Op == "<=" {
				havePausedUntil = true
			}
		}
		// Must sort ascending on PausedUntil so oldest due first.
		return haveStatus && havePausedUntil &&
			req.SortField == constants.FieldFlowInterview_PausedUntil &&
			req.SortDirection == "ASC"
	}), mock.Anything).Return(&models.QueryResult{
		TotalSize: 2,
		Records: []models.SObject{
			{constants.FieldID: "int-a"},
			{constants.FieldID: "int-b"},
		},
	}, nil).Once()

	// Second query: stale-claim recovery (Running + ClaimedAt old)
	mockQ.On("Query", mock.Anything, mock.MatchedBy(func(req api.QueryRequest) bool {
		return req.ObjectAPIName == constants.TableFlowInterview &&
			len(req.Criteria) >= 2 &&
			req.Criteria[0].Val == models.FlowInterviewStatusRunning
	}), mock.Anything).Return(&models.QueryResult{
		TotalSize: 0,
		Records:   []models.SObject{},
	}, nil).Once()

	ids, err := svc.ListDuePausedInterviews(context.Background(), now, 100, nil)
	assert.NoError(t, err)
	assert.Equal(t, []string{"int-a", "int-b"}, ids)
	mockQ.AssertExpectations(t)
}

// TestResumeRunning_ClearsPausedUntil verifies the atomic flip back to
// Running. The scheduler query no longer matches after this update so
// the same interview won't be picked up twice on the next tick.
func TestResumeRunning_ClearsPausedUntil(t *testing.T) {
	mockP := new(MockInterviewPersistence)
	svc := NewFlowInterviewService(mockP, nil)

	pastTime := time.Now().Add(-time.Hour)
	interview := &models.FlowInterview{
		Id:              "int-resume",
		InterviewStatus: models.FlowInterviewStatusPaused,
		PausedUntil:     &pastTime,
	}

	mockP.On("Update", mock.Anything, constants.TableFlowInterview, "int-resume",
		mock.MatchedBy(func(u models.SObject) bool {
			val, hasPU := u[constants.FieldFlowInterview_PausedUntil]
			return u[constants.FieldFlowInterview_InterviewStatus] == models.FlowInterviewStatusRunning &&
				hasPU && val == nil
		}), mock.Anything).Return(nil).Once()

	err := svc.ResumeRunning(context.Background(), interview, nil)
	assert.NoError(t, err)
	assert.Equal(t, models.FlowInterviewStatusRunning, interview.InterviewStatus)
	assert.Nil(t, interview.PausedUntil)
	mockP.AssertExpectations(t)
}

// TestRecordToInterview_ParsesPausedUntil confirms the scheduler can
// deserialize paused_until out of a JSON-round-tripped SObject row.
func TestRecordToInterview_ParsesPausedUntil(t *testing.T) {
	want, _ := time.Parse(time.RFC3339, "2026-05-01T12:00:00Z")
	rec := models.SObject{
		constants.FieldID: "int-x",
		"FlowVersionId":   "ver-1",
		constants.FieldFlowInterview_InterviewStatus: models.FlowInterviewStatusPaused,
		constants.FieldFlowInterview_PausedUntil:     "2026-05-01T12:00:00Z",
	}
	got := recordToInterview(rec)
	assert.NotNil(t, got)
	assert.NotNil(t, got.PausedUntil)
	assert.True(t, got.PausedUntil.Equal(want))
}

// TestRecordToInterview_AcceptsTimeValue mirrors the above for the
// native-driver path where the row comes back as time.Time rather than
// a string literal.
func TestRecordToInterview_AcceptsTimeValue(t *testing.T) {
	want := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
	rec := models.SObject{
		constants.FieldID: "int-y",
		"FlowVersionId":   "ver-1",
		constants.FieldFlowInterview_InterviewStatus: models.FlowInterviewStatusPaused,
		constants.FieldFlowInterview_PausedUntil:     want,
	}
	got := recordToInterview(rec)
	assert.NotNil(t, got)
	assert.NotNil(t, got.PausedUntil)
	assert.True(t, got.PausedUntil.Equal(want))
}

// TestScheduler_ResumePausedInterviews_DispatchesEachDueId confirms that
// on each tick the scheduler lists due paused rows and calls
// ResumeInterview once per Id. An error from one resume must not block
// the others.
func TestScheduler_ResumePausedInterviews_DispatchesEachDueId(t *testing.T) {
	var resumedMu sync.Mutex
	var resumed []string
	svc := &SchedulerService{
		listDuePausedInterviewsFn: func(ctx context.Context, now time.Time, limit int, user *models.UserSession) ([]string, error) {
			return []string{"int-1", "int-2", "int-3"}, nil
		},
		resumeInterviewFn: func(ctx context.Context, id string, user *models.UserSession) error {
			resumedMu.Lock()
			defer resumedMu.Unlock()
			resumed = append(resumed, id)
			if id == "int-2" {
				// Simulate an error — must not block sibling resumes.
				return assert.AnError
			}
			return nil
		},
	}

	svc.resumePausedInterviews()

	resumedMu.Lock()
	defer resumedMu.Unlock()
	assert.ElementsMatch(t, []string{"int-1", "int-2", "int-3"}, resumed)
}

// TestScheduler_ResumePausedInterviews_PanicDoesNotBlockOthers confirms
// the per-goroutine recover: a panicking resume logs and the rest
// continue.
func TestScheduler_ResumePausedInterviews_PanicDoesNotBlockOthers(t *testing.T) {
	var doneMu sync.Mutex
	done := map[string]bool{}
	svc := &SchedulerService{
		listDuePausedInterviewsFn: func(ctx context.Context, now time.Time, limit int, user *models.UserSession) ([]string, error) {
			return []string{"good-a", "panicker", "good-b"}, nil
		},
		resumeInterviewFn: func(ctx context.Context, id string, user *models.UserSession) error {
			if id == "panicker" {
				panic("boom")
			}
			doneMu.Lock()
			defer doneMu.Unlock()
			done[id] = true
			return nil
		},
	}

	assert.NotPanics(t, func() { svc.resumePausedInterviews() })
	doneMu.Lock()
	defer doneMu.Unlock()
	assert.True(t, done["good-a"], "good-a must have been resumed")
	assert.True(t, done["good-b"], "good-b must have been resumed despite sibling panic")
}

// TestScheduler_ResumePausedInterviews_EmptyListIsNoop confirms the
// fast-path: no due rows → no calls to resumeInterviewFn.
func TestScheduler_ResumePausedInterviews_EmptyListIsNoop(t *testing.T) {
	called := 0
	svc := &SchedulerService{
		listDuePausedInterviewsFn: func(ctx context.Context, now time.Time, limit int, user *models.UserSession) ([]string, error) {
			return nil, nil
		},
		resumeInterviewFn: func(ctx context.Context, id string, user *models.UserSession) error {
			called++
			return nil
		},
	}
	svc.resumePausedInterviews()
	assert.Equal(t, 0, called)
}
