package dispatch

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"
	calendarapi "github.com/tidbcloud/aisupport/packages/sdk-go/contracts/api/core-server/calendar/v1"
	calendarent "github.com/tidbcloud/aisupport/packages/sdk-go/contracts/entity/core-server/calendar/v1"
	dispatchent "github.com/tidbcloud/aisupport/packages/sdk-go/contracts/entity/ticket-manager/dispatch"
	triageent "github.com/tidbcloud/aisupport/packages/sdk-go/contracts/entity/ticket-manager/triage"
	"github.com/tidbcloud/aisupport/packages/sdk-go/infra/apiserver"
	authconsts "github.com/tidbcloud/aisupport/packages/sdk-go/infra/auth/consts"
	"github.com/tidbcloud/aisupport/services/ticket-manager/config"
	"github.com/tidbcloud/aisupport/services/ticket-manager/internal/app"
	coreint "github.com/tidbcloud/aisupport/services/ticket-manager/internal/integrations/core"
	"github.com/tidbcloud/aisupport/services/ticket-manager/internal/integrations/oncall"
	dispatchsvcapi "github.com/tidbcloud/aisupport/services/ticket-manager/internal/services/dispatch/api"
	"go.uber.org/mock/gomock"
)

func TestHandlerListPoliciesBindsFilters(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListPolicies(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.ListPoliciesParam) ([]*dispatchent.Policy, int64, error) {
		require.Equal(t, "pol_0123456789AbCdEf", *param.FamilyIDIs)
		require.Equal(t, "tcid", *param.NameLike)
		require.Equal(t, dispatchent.PolicyStatusDraft, *param.StatusIs)
		require.Equal(t, []dispatchent.PolicyStatus{dispatchent.PolicyStatusDraft, dispatchent.PolicyStatusActive}, param.Statuses)
		require.Equal(t, dispatchsvcapi.PageParam{Page: 2, PageSize: 10}, param.Page)
		return []*dispatchent.Policy{{ID: 101, Name: "tcid-l1"}}, 1, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/policies?page=2&page_size=10&family_id=pol_0123456789AbCdEf&name_like=tcid&status=draft&statuses=draft&statuses=active", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	var body map[string]any
	require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
	require.Equal(t, float64(1), body["total"])
}

func TestHandlerGetInsightBindsDate(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().GetInsight(gomock.Any(), dispatchsvcapi.GetInsightParam{Date: "2026-07-29"}).Return(
		&dispatchent.Insight{Date: "2026-07-29", Summary: dispatchent.InsightSummary{TotalIssues: 2}}, nil,
	)
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/insights?date=2026-07-29", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"date":"2026-07-29"`)
	require.Contains(t, response.Body.String(), `"total_issues":2`)
}

func TestHandlerGetOptions(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	coreClient := coreint.NewMockClient(ctrl)
	coreClient.Calendar.(*coreint.MockCalendarServiceClient).EXPECT().ListCalendarConfigs(gomock.Any(), &calendarapi.ListCalendarConfigsRequest{}).Return(&calendarapi.ListCalendarConfigsResponse{
		Configs: []*calendarent.CalendarConfig{{Name: "Team Calendar"}, {Name: "APAC Holidays"}},
	}, nil)
	oncallClient := oncall.NewMockClient(ctrl)
	oncallClient.EXPECT().ListTeams(gomock.Any()).Return([]oncall.Team{
		{TeamID: 1, TeamKey: "support-l1", TeamName: "Support L1"},
		{TeamID: 2, TeamKey: "support-l2", TeamName: "Support L2"},
	}, nil)
	router := newTestRouterWithOptionsDependencies(t, dispatchsvcapi.NewMockService(ctrl), coreClient, oncallClient, config.DispatchServiceConfig{
		BotEmails: []string{"ai-support-bot@pingcap.com"},
	})
	request := httptest.NewRequest(http.MethodGet, "/dispatch/options", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	var rawBody struct {
		Options struct {
			RuleCondition map[string]json.RawMessage `json:"rule_condition"`
		} `json:"options"`
	}
	require.NoError(t, json.Unmarshal(response.Body.Bytes(), &rawBody))
	require.Contains(t, rawBody.Options.RuleCondition, "issue_fields")
	require.NotContains(t, rawBody.Options.RuleCondition, "fields")
	require.NotContains(t, rawBody.Options.RuleCondition, "triage_fields")

	var body struct {
		Options dispatchent.FieldOption `json:"options"`
	}
	require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
	require.Equal(t, []dispatchent.FieldOptionItem{
		{Name: dispatchent.FieldOptionNow, Description: "The current condition evaluation time."},
		{Name: dispatchent.FieldOptionIssueKey, Description: "The Jira issue key."},
		{Name: dispatchent.FieldOptionIssueProjectKey, Description: "The Jira project key."},
		{Name: dispatchent.FieldOptionIssuePriority, Description: "The Jira issue priority."},
		{Name: dispatchent.FieldOptionIssueCustomerDeploymentModel, Description: "The customer deployment model: cloud or on_premise."},
		{Name: dispatchent.FieldOptionIssueCustomerClusterID, Description: "The customer cluster ID. Only supported for projects ST, TCID, TCOC, TCAB, TCT, and O11Y."},
		{Name: dispatchent.FieldOptionIssueCustomerTenantID, Description: "The customer tenant or account ID."},
		{Name: dispatchent.FieldOptionIssueCustomerTenantRegion, Description: "The customer tenant region."},
		{Name: dispatchent.FieldOptionIssueCustomFieldsOnCallTier, Description: "The on-call support tier configured on the issue."},
		{Name: dispatchent.FieldOptionIssueAutoOncallStatus, Description: "The terminal Auto Oncall Status configured on the Jira issue."},
		{Name: dispatchent.FieldOptionIssueCustomFieldsSeverity, Description: "The severity configured on the Jira issue."},
		{Name: dispatchent.FieldOptionIssueAssigneeEmail, Description: "The email address of the Jira issue assignee."},
		{Name: dispatchent.FieldOptionIssueReporterEmail, Description: "The email address of the Jira issue reporter."},
		{Name: dispatchent.FieldOptionIssueCreatedAt, Description: "The time when the Jira issue was created."},
		{Name: dispatchent.FieldOptionIssueComputedResponseSLADueAt, Description: "The response SLA due time. Only supported for projects GTOC, TCOC, and TCT; 0 means unlimited."},
		{Name: dispatchent.FieldOptionIssueComponents, Description: "All Jira components configured on the issue."},
		{Name: dispatchent.FieldOptionIssuePrimaryComponent, Description: "The first Jira component configured on the issue."},
		{Name: dispatchent.FieldOptionIssueComputedEffectivePrimaryComponent, Description: "The effective primary component: the first component from the latest triage result when available, otherwise the first Jira component."},
		{Name: dispatchent.FieldOptionIssueSummary, Description: "The Jira issue summary."},
		{Name: dispatchent.FieldOptionTriageIssueType, Description: "The issue type from the latest triage result."},
		{Name: dispatchent.FieldOptionTriageComponents, Description: "All components from the latest triage result."},
		{Name: dispatchent.FieldOptionTriagePrimaryComponent, Description: "The first component from the latest triage result."},
		{Name: dispatchent.FieldOptionTriageSeverity, Description: "The severity from the latest triage result."},
		{Name: dispatchent.FieldOptionTriageProjectLevel, Description: "Whether the issue is project-level according to the latest triage result."},
		{Name: dispatchent.FieldOptionTriageLanguage, Description: "The language from the latest triage result."},
	}, body.Options.RuleCondition.IssueFields)
	require.Equal(t, []dispatchent.FieldOptionConditionOperator{
		{Name: dispatchent.ConditionOperatorEqual, Description: "Matches when the field equals the configured value."},
		{Name: dispatchent.ConditionOperatorNotEqual, Description: "Matches when the field does not equal the configured value."},
		{Name: dispatchent.ConditionOperatorRegex, Description: "Matches when any field value matches the configured regular expression."},
		{Name: dispatchent.ConditionOperatorContains, Description: "Matches when any field value contains the configured value."},
	}, body.Options.RuleCondition.SingleValueOperators)
	require.Equal(t, []dispatchent.FieldOptionConditionOperator{
		{Name: dispatchent.ConditionOperatorIn, Description: "Matches when any field value is included in the configured values."},
		{Name: dispatchent.ConditionOperatorNotIn, Description: "Matches when no field value is included in the configured values."},
	}, body.Options.RuleCondition.MultiValueOperators)
	require.Equal(t, []dispatchent.FieldOptionConditionOperator{
		{Name: dispatchent.ConditionOperatorExists, Description: "Matches when the field exists."},
		{Name: dispatchent.ConditionOperatorNotExists, Description: "Matches when the field does not exist."},
	}, body.Options.RuleCondition.NoValueOperators)
	require.Equal(t, []dispatchent.FieldOptionConditionOperator{
		{Name: dispatchent.ConditionOperatorWithinWeeklyTimeWindow, Description: "Matches when the field time is within the configured weekly time window."},
		{Name: dispatchent.ConditionOperatorOverlapsWeeklyTimeWindow, Description: "Matches when the interval from evaluation time to the field time overlaps the configured weekly time window."},
	}, body.Options.RuleCondition.WeeklyTimeWindowOperators)
	require.Equal(t, []dispatchent.FieldOptionConditionOperator{
		{Name: dispatchent.ConditionOperatorWithinCalendarEvent, Description: "Matches when the field time is within an event in the configured calendar."},
	}, body.Options.RuleCondition.CalendarEventOperators)
	require.Equal(t, []string{"APAC Holidays", "Team Calendar"}, body.Options.RuleCondition.CalendarNames)
	require.Equal(t, []dispatchent.FieldOptionItem{
		{Name: string(dispatchent.ActionTypeAssign), Description: "Select an eligible engineer from the configured receiver, set the Jira issue assignee, update the engineer's dispatch state, and remove auto-assign labels from the Jira issue."},
		{Name: string(dispatchent.ActionTypeAssignL2), Description: "Select an eligible engineer from the configured receiver, set the Jira issue L2 assignee field, update the engineer's dispatch state, and remove auto-assign labels from the Jira issue."},
		{Name: string(dispatchent.ActionTypeEscalate), Description: "Transition the Jira issue to ESCALATE TO L3 and remove auto-assign labels."},
		{Name: string(dispatchent.ActionTypeRetrigger), Description: "Schedule the issue to run through Dispatch again after the configured delay."},
		{Name: string(dispatchent.ActionTypeNoop), Description: "Exit the Execution early when used with the rule's on-success terminate transition, or simulate a successful or failed Action for testing. Performs no side effects."},
		{Name: string(dispatchent.ActionTypeTransitionIssue), Description: "Transition the Jira issue to the configured target status."},
		{Name: string(dispatchent.ActionTypeSendWebRequest), Description: "Send an HTTP request with a configurable method, URL, headers, and body."},
		{Name: string(dispatchent.ActionTypeTriage), Description: "Analyze the Jira issue and generate triage results for subsequent rules."},
		{Name: string(dispatchent.ActionTypeSendAssignmentNotification), Description: "Send the current assignment target a Feishu card and consume the role notification label."},
		{Name: string(dispatchent.ActionTypeMarkAssignmentMessageUrgent), Description: "Mark the Ticket Manager assignment card urgent in Feishu."},
		{Name: string(dispatchent.ActionTypePageAssignment), Description: "Page the assignment target once and wait for acknowledgement."},
		{Name: string(dispatchent.ActionTypeEscalateAssignmentToLeader), Description: "Escalate the assignment role to the target's direct leader at most once."},
	}, body.Options.Action.Types)
	expected := dispatchent.DefaultFieldOption()
	expected.RuleCondition.CalendarNames = []string{"APAC Holidays", "Team Calendar"}
	expected.Action.BotEmails = []string{"ai-support-bot@pingcap.com"}
	expected.Action.OnCallTeams = []dispatchent.OnCallTeamOption{
		{TeamKey: "support-l1", TeamName: "Support L1"},
		{TeamKey: "support-l2", TeamName: "Support L2"},
	}
	require.Equal(t, expected, body.Options)
}

func TestHandlerGetOptionsFailsWhenOncallIsUnavailable(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	coreClient := coreint.NewMockClient(ctrl)
	coreClient.Calendar.(*coreint.MockCalendarServiceClient).EXPECT().ListCalendarConfigs(gomock.Any(), &calendarapi.ListCalendarConfigsRequest{}).Return(&calendarapi.ListCalendarConfigsResponse{}, nil)
	oncallClient := oncall.NewMockClient(ctrl)
	oncallClient.EXPECT().ListTeams(gomock.Any()).Return(nil, errors.New("oncall unavailable"))
	router := newTestRouterWithOptionsDependencies(t, dispatchsvcapi.NewMockService(ctrl), coreClient, oncallClient, config.DispatchServiceConfig{})
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/options", nil))

	require.Equal(t, http.StatusInternalServerError, response.Code)
	require.Contains(t, response.Body.String(), "oncall unavailable")
}

func TestHandlerGetOptionsFailsWhenCalendarIsUnavailable(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	coreClient := coreint.NewMockClient(ctrl)
	coreClient.Calendar.(*coreint.MockCalendarServiceClient).EXPECT().ListCalendarConfigs(gomock.Any(), &calendarapi.ListCalendarConfigsRequest{}).Return(nil, errors.New("calendar unavailable"))
	router := newTestRouterWithOptionsDependencies(t, dispatchsvcapi.NewMockService(ctrl), coreClient, oncall.NewMockClient(ctrl), config.DispatchServiceConfig{})
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/options", nil))

	require.Equal(t, http.StatusInternalServerError, response.Code)
	require.Contains(t, response.Body.String(), "calendar unavailable")
}

func TestHandlerListsProjectComponents(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListProjectComponents(gomock.Any(), "TCID").Return([]triageent.ComponentOption{
		{ID: "1", IDs: []string{"1"}, Name: "TiKV", Description: "Storage engine"},
	}, nil)
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/projects/TCID/components", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"project_key":"TCID"`)
	require.Contains(t, response.Body.String(), `"name":"TiKV"`)
}

func TestHandlerGetsDispatchContext(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().GetDispatchContext(gomock.Any(), "TCID-101").Return(
		&dispatchent.DispatchContext{IssueKey: "TCID-101", Assignee: "alice@example.com"}, nil,
	)
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/contexts/TCID-101", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	var body struct {
		Context dispatchent.DispatchContext `json:"context"`
	}
	require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
	require.Equal(t, "TCID-101", body.Context.IssueKey)
	require.Equal(t, "alice@example.com", body.Context.Assignee)
}

func TestHandlerCreatePolicyUsesOperator(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().CreatePolicy(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.CreatePolicyParam) (*dispatchent.Policy, error) {
		require.Equal(t, "owner@example.com", param.CreatedBy)
		require.Equal(t, uint64(100), *param.ReplacesPolicyID)
		require.Equal(t, dispatchent.TransitionTypeTerminate, param.Rules[0].Transition.OnSuccess)
		require.Equal(t, dispatchent.PolicyTrigger{
			Enable: true, SourceIn: []string{"jira_assignment_assignee"}, IssueLabelAll: []string{"cloud", "customer-impact"},
			IssueStatusIn: []string{"waiting for support"}, ProjectKeyIn: []string{"TCID"},
		}, param.Trigger)
		return &dispatchent.Policy{ID: 101, Status: dispatchent.PolicyStatusDraft}, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodPost, "/dispatch/policies", strings.NewReader(`{"name":"tcid-l1","replaces_policy_id":100,"trigger":{"enable":true,"source_in":["jira_assignment_assignee"],"issue_label_all":["cloud","customer-impact"],"issue_status_in":["waiting for support"],"project_key_in":["TCID"]},"rules":[{"transition":{"on_success":"terminate"}}]}`))
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set(authconsts.HeaderOperator, "owner@example.com")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
}

func TestHandlerCreatePolicyRequiresOperator(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	router := newTestRouter(t, dispatchsvcapi.NewMockService(ctrl))
	request := httptest.NewRequest(http.MethodPost, "/dispatch/policies", strings.NewReader(`{"name":"tcid-l1"}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusBadRequest, response.Code)
	require.Contains(t, response.Body.String(), authconsts.HeaderOperator)
}

func TestHandlerGetsPolicyWithTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().GetPolicy(gomock.Any(), uint64(101)).Return(&dispatchent.Policy{
		ID: 101, Trigger: dispatchent.PolicyTrigger{
			PolicyID: 101, Enable: true, IssueLabelAll: []string{"cloud", "customer-impact"},
			IssueStatusIn: []string{"WAITING FOR SUPPORT"}, ProjectKeyIn: []string{"TCID"},
		},
	}, nil)
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/policies/101", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	var body struct {
		Policy *dispatchent.Policy `json:"policy"`
	}
	require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
	require.Equal(t, []string{"cloud", "customer-impact"}, body.Policy.Trigger.IssueLabelAll)
	require.Equal(t, []string{"TCID"}, body.Policy.Trigger.ProjectKeyIn)
	require.Equal(t, []string{"WAITING FOR SUPPORT"}, body.Policy.Trigger.IssueStatusIn)
}

func TestHandlerDoesNotRegisterPolicyTriggerGet(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/policies/101/trigger", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusNotFound, response.Code)
}

func TestHandlerUpdatesPolicyWithTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().UpdatePolicy(gomock.Any(), uint64(101), dispatchsvcapi.UpdatePolicyParam{
		Name: "tcid-l1",
		Trigger: dispatchent.PolicyTrigger{
			Enable: true, SourceIn: []string{"manual"}, IssueLabelAll: []string{"cloud"},
			IssueStatusIn: []string{"waiting for support"}, ProjectKeyIn: []string{"TCID"},
		},
	}).Return(&dispatchent.Policy{
		ID: 101, Name: "tcid-l1", Trigger: dispatchent.PolicyTrigger{PolicyID: 101, Enable: true, ProjectKeyIn: []string{"TCID"}},
	}, nil)
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodPut, "/dispatch/policies/101", strings.NewReader(`{"name":"tcid-l1","trigger":{"enable":true,"source_in":["manual"],"issue_label_all":["cloud"],"issue_status_in":["waiting for support"],"project_key_in":["TCID"]}}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"policy_id":101`)
}

func TestHandlerUpdatePolicyRequiresTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	router := newTestRouter(t, dispatchsvcapi.NewMockService(ctrl))
	request := httptest.NewRequest(http.MethodPut, "/dispatch/policies/101", strings.NewReader(`{"name":"tcid-l1"}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusBadRequest, response.Code)
}

func TestHandlerDoesNotRegisterPolicyTriggerUpdate(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	router := newTestRouter(t, dispatchsvcapi.NewMockService(ctrl))
	request := httptest.NewRequest(http.MethodPut, "/dispatch/policies/101/trigger", strings.NewReader(`{"enable":true}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusNotFound, response.Code)
}

func TestHandlerDoesNotRegisterPolicyTriggerDelete(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/dispatch/policies/101/trigger", nil))

	require.Equal(t, http.StatusNotFound, response.Code)
}

func TestHandlerPublishesPolicy(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().PublishPolicy(gomock.Any(), uint64(101), "owner@example.com").
		Return(&dispatchent.Policy{ID: 101, Status: dispatchent.PolicyStatusActive}, nil)
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodPost, "/dispatch/policies/101/publish", nil)
	request.Header.Set(authconsts.HeaderOperator, "owner@example.com")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
}

func TestHandlerRetiresPolicy(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().RetirePolicy(gomock.Any(), uint64(101)).
		Return(&dispatchent.Policy{ID: 101, Status: dispatchent.PolicyStatusRetired}, nil)
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/dispatch/policies/101/retire", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"status":"retired"`)
}

func TestHandlerCreatesDispatchTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().Dispatch(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.DispatchParam) (*dispatchent.DispatchTrigger, error) {
		require.Equal(t, "jira", param.Source)
		require.Equal(t, "TCID-101", param.Input.IssueKey)
		return &dispatchent.DispatchTrigger{ID: 9, Source: param.Source, Status: dispatchent.DispatchTriggerStatusPending, Input: param.Input}, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodPost, "/dispatch/triggers", strings.NewReader(`{"source":"jira","input":{"issue_key":"TCID-101"}}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"trigger":{"id":9`)
	require.Contains(t, response.Body.String(), `"status":"pending"`)
}

func TestHandlerGetsDispatchTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().GetDispatchTrigger(gomock.Any(), uint64(9)).Return(&dispatchent.DispatchTrigger{
		ID: 9, Status: dispatchent.DispatchTriggerStatusCompleted,
		Output: &dispatchent.DispatchTriggerOutput{ExecutionIDs: []uint64{101}},
	}, nil)
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/triggers/9", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"status":"completed"`)
	require.Contains(t, response.Body.String(), `"execution_ids":[101]`)
}

func TestHandlerListsDispatchTriggers(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListDispatchTriggers(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.ListDispatchTriggersParam) ([]*dispatchent.DispatchTrigger, int64, error) {
		require.Equal(t, "tcid", *param.IssueKeyLike)
		require.Equal(t, []dispatchent.DispatchTriggerStatus{dispatchent.DispatchTriggerStatusPending, dispatchent.DispatchTriggerStatusProcessing}, param.Statuses)
		require.Equal(t, "jira", *param.Source)
		require.Equal(t, dispatchsvcapi.PageParam{Page: 2, PageSize: 10}, param.Page)
		return []*dispatchent.DispatchTrigger{{ID: 9, Status: dispatchent.DispatchTriggerStatusProcessing}}, 1, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/triggers?issue_key_like=tcid&statuses=pending&statuses=processing&source=jira&page=2&page_size=10", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"total":1`)
	require.Contains(t, response.Body.String(), `"id":9`)
}

func TestHandlerListsDispatchTriggersWithoutFilters(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListDispatchTriggers(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.ListDispatchTriggersParam) ([]*dispatchent.DispatchTrigger, int64, error) {
		require.Nil(t, param.IssueKeyLike)
		require.Empty(t, param.Statuses)
		require.Nil(t, param.Source)
		require.Equal(t, dispatchsvcapi.PageParam{Page: 1, PageSize: 100}, param.Page)
		return nil, 0, nil
	})
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/triggers", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"total":0`)
}

func TestHandlerCreatesSpecifiedPolicyDryrunTrigger(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().Dispatch(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.DispatchParam) (*dispatchent.DispatchTrigger, error) {
		require.Equal(t, "web", param.Source)
		require.Equal(t, "TCID-101", param.Input.IssueKey)
		require.Equal(t, uint64(101), *param.PolicyID)
		require.NotNil(t, param.Dryrun)
		require.Equal(t, time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC), *param.Dryrun.OverwriteData.Now)
		return &dispatchent.DispatchTrigger{ID: 9, Source: param.Source, Status: dispatchent.DispatchTriggerStatusPending}, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodPost, "/dispatch/triggers", strings.NewReader(`{"source":"web","input":{"issue_key":"TCID-101"},"policy_id":101,"dryrun":{"overwrite_data":{"now":"2026-08-13T10:00:00Z"}}}`))
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"trigger":{"id":9`)

	response = httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/dispatch/policies/101/dispatch", nil))
	require.Equal(t, http.StatusNotFound, response.Code)
}

func TestHandlerListsExecutions(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListExecutions(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.ListExecutionsParam) ([]*dispatchent.Execution, int64, error) {
		require.Equal(t, "tcid", *param.IssueKeyLike)
		require.Equal(t, "pol_0123456789AbCdEf", *param.PolicyFamilyIDIs)
		require.Equal(t, dispatchent.ExecutionModeLive, *param.Mode)
		require.Equal(t, []dispatchent.ExecutionStatus{dispatchent.ExecutionStatusCreated, dispatchent.ExecutionStatusExecuting}, param.Statuses)
		require.Equal(t, dispatchsvcapi.PageParam{Page: 2, PageSize: 10}, param.Page)
		return []*dispatchent.Execution{{ID: 9, Status: dispatchent.ExecutionStatusExecuting}}, 1, nil
	})
	router := newTestRouter(t, service)
	request := httptest.NewRequest(http.MethodGet, "/dispatch/executions?issue_key_like=tcid&policy_family_id=pol_0123456789AbCdEf&mode=live&statuses=created&statuses=executing&page=2&page_size=10", nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"total":1`)
	require.Contains(t, response.Body.String(), `"id":9`)
}

func TestHandlerListsExecutionsWithoutFilters(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	service := dispatchsvcapi.NewMockService(ctrl)
	service.EXPECT().ListExecutions(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, param dispatchsvcapi.ListExecutionsParam) ([]*dispatchent.Execution, int64, error) {
		require.Nil(t, param.IssueKeyLike)
		require.Nil(t, param.PolicyFamilyIDIs)
		require.Nil(t, param.Mode)
		require.Empty(t, param.Statuses)
		require.Equal(t, dispatchsvcapi.PageParam{Page: 1, PageSize: 100}, param.Page)
		return nil, 0, nil
	})
	router := newTestRouter(t, service)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/dispatch/executions", nil))

	require.Equal(t, http.StatusOK, response.Code)
	require.Contains(t, response.Body.String(), `"total":0`)
}

func TestHandlerDoesNotRegisterDispatchContextMutationRoutes(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	router := newTestRouter(t, dispatchsvcapi.NewMockService(ctrl))
	for _, request := range []*http.Request{
		httptest.NewRequest(http.MethodPost, "/dispatch/contexts", nil),
		httptest.NewRequest(http.MethodPut, "/dispatch/contexts/TCID-101", nil),
		httptest.NewRequest(http.MethodDelete, "/dispatch/contexts/TCID-101", nil),
	} {
		response := httptest.NewRecorder()
		router.ServeHTTP(response, request)
		require.Equal(t, http.StatusNotFound, response.Code)
	}
}

func newTestRouter(t *testing.T, service dispatchsvcapi.Service) *gin.Engine {
	return newTestRouterWithOncall(t, service, nil)
}

func newTestRouterWithOncall(t *testing.T, service dispatchsvcapi.Service, oncallClient oncall.Client) *gin.Engine {
	return newTestRouterWithOncallAndConfig(t, service, oncallClient, config.DispatchServiceConfig{})
}

func newTestRouterWithOncallAndConfig(t *testing.T, service dispatchsvcapi.Service, oncallClient oncall.Client, dispatchConfig config.DispatchServiceConfig) *gin.Engine {
	return newTestRouterWithOptionsDependencies(t, service, nil, oncallClient, dispatchConfig)
}

func newTestRouterWithOptionsDependencies(t *testing.T, service dispatchsvcapi.Service, coreClient *coreint.Client, oncallClient oncall.Client, dispatchConfig config.DispatchServiceConfig) *gin.Engine {
	t.Helper()
	gin.SetMode(gin.TestMode)
	router := gin.New()
	require.NoError(t, (&Handler{Dispatch: service, Core: coreClient, Oncall: oncallClient, Config: dispatchConfig}).Register(map[apiserver.RoutePath]gin.IRouter{app.APIRoutePath: router}))
	return router
}
