package mcpserver

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

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/aisupport/apps/cli/operation-mcp/devops"
	"github.com/tidbcloud/aisupport/packages/sdk-go/infra/auth/consts"
)

const testInstanceID = 4242

func strPtr(s string) *string { return &s }

func testCatalog() *devops.Catalog {
	manual := devops.StartPolicyManual
	return devops.NewCatalog([]*devops.Operation{
		{
			Name:        "RestartChangefeed",
			Alias:       "Restart Changefeed",
			Target:      devops.TargetUndefined,
			Description: "restart a changefeed",
			Categories:  []string{"cdc-meta"},
			Actions:     []devops.OperationAction{{Name: "RestartChangefeed", PreferStartPolicy: &manual}},
			Params: map[string]devops.ParamDefine{
				"changefeed_id":   {Type: devops.ParamTypeString, Desc: "changefeed id"},
				"cluster_type":    {Type: devops.ParamTypeString, Enum: []string{"CHANGEFEED", "CHANGEFEED_NG"}},
				"oncall_category": {Type: devops.ParamTypeString, Const: strPtr("cdc")},
			},
			Approval: devops.ApprovalTemplate{
				Nodes: []*devops.ApproverNode{{
					Approver: devops.Approver{UserGroup: "cloud_ticdc_operation"},
					Method:   "ANY",
				}},
			},
		},
		{
			Name:       "PauseChangefeed",
			Target:     devops.TargetUndefined,
			Categories: []string{"cdc-meta"},
			Actions:    []devops.OperationAction{{Name: "PauseChangefeed"}},
			Params:     map[string]devops.ParamDefine{"changefeed_id": {Type: devops.ParamTypeString}},
		},
		{
			Name:     "ShutdownOperationForK8sUpgrade",
			Target:   devops.TargetCluster,
			Internal: true,
			Actions:  []devops.OperationAction{{Name: "Shutdown"}},
			Params:   map[string]devops.ParamDefine{"cluster_id": {Type: devops.ParamTypeString}},
		},
	})
}

type apiStub struct {
	created  []devops.CreateInstanceReq
	previewd []devops.CreateInstanceReq
	canceled []string
	phase    devops.InstancePhase
}

func newAPIStub(t *testing.T) (*apiStub, *devops.Client) {
	t.Helper()

	stub := &apiStub{phase: devops.PhaseApproving}
	mux := http.NewServeMux()

	mux.HandleFunc("POST /api/v1/devops/operation/instances", func(w http.ResponseWriter, r *http.Request) {
		var body devops.CreateInstanceReq
		require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
		stub.created = append(stub.created, body)
		writeJSON(t, w, map[string]any{"errcode": "OK", "instance_id": testInstanceID})
	})

	mux.HandleFunc("POST /api/v1/devops/operation/instances/preview", func(w http.ResponseWriter, r *http.Request) {
		var body devops.CreateInstanceReq
		require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
		stub.previewd = append(stub.previewd, body)
		writeJSON(t, w, map[string]any{
			"errcode":    "OK",
			"advisories": []map[string]string{{"level": "warning", "message": "changefeed is lagging"}},
		})
	})

	mux.HandleFunc("POST /api/v1/devops/operation/instances/{instance_id}/cancel", func(w http.ResponseWriter, r *http.Request) {
		stub.canceled = append(stub.canceled, r.PathValue("instance_id"))
		stub.phase = devops.PhaseCanceled
		writeJSON(t, w, map[string]any{"errcode": "OK"})
	})

	mux.HandleFunc("GET /api/v1/devops/operation/instances/{instance_id}", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(t, w, map[string]any{
			"errcode": "OK",
			"instance": map[string]any{
				"id":        testInstanceID,
				"operation": "RestartChangefeed",
				"phase":     stub.phase,
			},
		})
	})

	server := httptest.NewServer(mux)
	t.Cleanup(server.Close)

	t.Setenv(consts.EnvDevOpsAccessToken, "")
	t.Setenv(consts.EnvDevOpsAPIKey, "")
	t.Setenv(consts.EnvDevOpsAPIToken, "test-token")

	cli, err := devops.NewClient(devops.Config{
		BaseURL:     server.URL,
		FrontendURL: "https://ops.test",
		Timeout:     5 * time.Second,
	})
	require.NoError(t, err)

	return stub, cli
}

func writeJSON(t *testing.T, w http.ResponseWriter, body any) {
	t.Helper()

	w.Header().Set("Content-Type", "application/json")
	require.NoError(t, json.NewEncoder(w).Encode(body))
}

func connect(t *testing.T, opts Options) *mcp.ClientSession {
	t.Helper()

	if opts.Catalog == nil {
		opts.Catalog = testCatalog()
	}

	server, err := New(opts)
	require.NoError(t, err)

	serverTransport, clientTransport := mcp.NewInMemoryTransports()
	ctx := context.Background()

	serverSession, err := server.Connect(ctx, serverTransport, nil)
	require.NoError(t, err)
	t.Cleanup(func() { _ = serverSession.Close() })

	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "test"}, nil)
	clientSession, err := client.Connect(ctx, clientTransport, nil)
	require.NoError(t, err)
	t.Cleanup(func() { _ = clientSession.Close() })

	return clientSession
}

func toolNames(t *testing.T, session *mcp.ClientSession) []string {
	t.Helper()

	result, err := session.ListTools(context.Background(), nil)
	require.NoError(t, err)

	names := make([]string, 0, len(result.Tools))
	for _, tool := range result.Tools {
		names = append(names, tool.Name)
	}
	return names
}

func callTool(t *testing.T, session *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
	t.Helper()

	result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
	require.NoError(t, err)
	return result
}

func resultText(t *testing.T, result *mcp.CallToolResult) string {
	t.Helper()

	require.Len(t, result.Content, 1)
	text, ok := result.Content[0].(*mcp.TextContent)
	require.True(t, ok, "expected text content")
	return text.Text
}

func TestWriteToolsRegisteredForAllowlistedOperations(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	require.ElementsMatch(t, []string{
		"list_operations",
		"describe_operation",
		"get_operation_instance",
		"list_operation_instances",
		"preview_operation",
		"submit_operation",
		"cancel_operation_instance",
		"submit_restart_changefeed",
	}, toolNames(t, session))
}

func TestReadOnlyServerExposesNoSubmitTools(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}})

	names := toolNames(t, session)
	require.NotContains(t, names, "submit_operation")
	require.NotContains(t, names, "submit_restart_changefeed")
	require.NotContains(t, names, "cancel_operation_instance")
	require.Contains(t, names, "preview_operation")
}

func TestNewRejectsUnknownAllowlistEntry(t *testing.T) {
	_, cli := newAPIStub(t)

	_, err := New(Options{Client: cli, Catalog: testCatalog(), Allowed: []string{"RestartEverything"}})
	require.ErrorContains(t, err, "does not exist in the DevOps operation catalog")
}

func TestNewRejectsInternalOperation(t *testing.T) {
	_, cli := newAPIStub(t)

	_, err := New(Options{Client: cli, Catalog: testCatalog(), Allowed: []string{"ShutdownOperationForK8sUpgrade"}})
	require.ErrorContains(t, err, "internal")
}

func TestSubmitGeneratedToolCreatesInstanceForApproval(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "submit_restart_changefeed", map[string]any{
		"changefeed_id": "cf-1",
		"cluster_type":  "CHANGEFEED_NG",
		"reason":        "ALT-123: changefeed stuck",
	})
	require.False(t, result.IsError, resultText(t, result))

	require.Len(t, stub.created, 1)
	created := stub.created[0]
	require.Equal(t, "RestartChangefeed", created.Operation)
	require.Equal(t, devops.TargetUndefined, created.Type)
	require.Equal(t, "ALT-123: changefeed stuck", created.Reason)
	require.False(t, created.IsDraft)
	require.Equal(t, map[string]string{
		"changefeed_id":   "cf-1",
		"cluster_type":    "CHANGEFEED_NG",
		"oncall_category": "cdc",
	}, created.Params)
	require.Equal(t, []devops.CreateInstanceAction{
		{Name: "RestartChangefeed", StartPolicy: devops.StartPolicyImmediate},
	}, created.Actions)

	var out submitResult
	require.NoError(t, json.Unmarshal([]byte(resultText(t, result)), &out))
	require.Equal(t, uint64(testInstanceID), out.InstanceID)
	require.Equal(t, devops.PhaseApproving, out.Phase)
	require.Equal(t, "https://ops.test/operations/4242", out.OpsPortalURL)
	require.Equal(t, []string{"group:cloud_ticdc_operation"}, out.Approval[0].Approvers)
}

func TestSubmitGeneratedToolHonoursStartPolicy(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "submit_restart_changefeed", map[string]any{
		"changefeed_id": "cf-1",
		"cluster_type":  "CHANGEFEED",
		"reason":        "ALT-123",
		"start_policy":  "manual",
	})
	require.False(t, result.IsError, resultText(t, result))
	require.Equal(t, devops.StartPolicyManual, stub.created[0].Actions[0].StartPolicy)
}

func TestSubmitRequiresReason(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "submit_operation", map[string]any{
		"operation": "RestartChangefeed",
		"params":    map[string]any{"changefeed_id": "cf-1", "cluster_type": "CHANGEFEED"},
		"reason":    "   ",
	})
	require.True(t, result.IsError)
	require.Contains(t, resultText(t, result), "reason is required")
	require.Empty(t, stub.created)
}

func TestSubmitRejectsOperationOutsideAllowlist(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "submit_operation", map[string]any{
		"operation": "PauseChangefeed",
		"params":    map[string]any{"changefeed_id": "cf-1"},
		"reason":    "ALT-123",
	})
	require.True(t, result.IsError)
	require.Contains(t, resultText(t, result), "not allowlisted")
	require.Contains(t, resultText(t, result), "RestartChangefeed")
	require.Empty(t, stub.created)
}

func TestSubmitRejectsInvalidParams(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "submit_operation", map[string]any{
		"operation": "RestartChangefeed",
		"params":    map[string]any{"changefeed_id": "cf-1", "cluster_type": "NOPE"},
		"reason":    "ALT-123",
	})
	require.True(t, result.IsError)
	require.Contains(t, resultText(t, result), "must be one of")
	require.Empty(t, stub.created)
}

func TestPreviewReturnsAdvisoriesWithoutCreating(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}})

	result := callTool(t, session, "preview_operation", map[string]any{
		"operation": "RestartChangefeed",
		"params":    map[string]any{"changefeed_id": "cf-1", "cluster_type": "CHANGEFEED"},
	})
	require.False(t, result.IsError, resultText(t, result))
	require.Contains(t, resultText(t, result), "changefeed is lagging")
	require.Len(t, stub.previewd, 1)
	require.Empty(t, stub.created)
}

func TestDescribeOperationReportsApprovalChain(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "describe_operation", map[string]any{"operation": "RestartChangefeed"})
	require.False(t, result.IsError, resultText(t, result))

	var detail operationDetail
	require.NoError(t, json.Unmarshal([]byte(resultText(t, result)), &detail))
	require.True(t, detail.Submittable)
	require.Equal(t, "submit_restart_changefeed", detail.SubmitTool)
	require.Equal(t, []string{"group:cloud_ticdc_operation"}, detail.Approval[0].Approvers)
	require.Contains(t, detail.ApprovalNote, "until a human approves it")

	// Const params are server-fixed and must not be offered as inputs.
	for _, param := range detail.Params {
		require.NotEqual(t, "oncall_category", param.Name)
	}
}

func TestListOperationsMarksSubmittable(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "list_operations", map[string]any{})
	require.False(t, result.IsError, resultText(t, result))

	var out struct {
		Operations []operationSummary `json:"operations"`
	}
	require.NoError(t, json.Unmarshal([]byte(resultText(t, result)), &out))

	submittable := map[string]bool{}
	for _, op := range out.Operations {
		submittable[op.Name] = op.Submittable
	}
	require.Equal(t, map[string]bool{"PauseChangefeed": false, "RestartChangefeed": true}, submittable,
		"internal operations are hidden by default")
}

func TestGetInstanceExplainsPhase(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}})

	result := callTool(t, session, "get_operation_instance", map[string]any{"instance_id": testInstanceID})
	require.False(t, result.IsError, resultText(t, result))
	require.Contains(t, resultText(t, result), "Waiting for a human approver")
	require.Contains(t, resultText(t, result), "https://ops.test/operations/4242")
}

func TestCancelInstance(t *testing.T) {
	stub, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	result := callTool(t, session, "cancel_operation_instance", map[string]any{"instance_id": testInstanceID})
	require.False(t, result.IsError, resultText(t, result))
	require.Equal(t, []string{"4242"}, stub.canceled)
	require.Contains(t, resultText(t, result), string(devops.PhaseCanceled))
}

func TestServerNeverExposesApprovalTools(t *testing.T) {
	_, cli := newAPIStub(t)
	session := connect(t, Options{Client: cli, Allowed: []string{"RestartChangefeed"}, EnableWrite: true})

	for _, name := range toolNames(t, session) {
		require.NotContains(t, name, "approve")
		require.NotContains(t, name, "approval")
	}
}

func TestMain(m *testing.M) {
	// Keep an ambient developer credential from leaking into the stubbed API.
	_ = os.Unsetenv(consts.EnvDevOpsAccessToken)
	_ = os.Unsetenv(consts.EnvDevOpsAPIKey)
	os.Exit(m.Run())
}
