package github

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"strings"
	"testing"

	"github.com/github/github-mcp-server/pkg/inventory"
	"github.com/google/jsonschema-go/jsonschema"
	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCSVOutputAppliedToDefaultListTools(t *testing.T) {
	listTool := testCSVOutputTool("list_things", `[{"number":1}]`)
	getTool := testCSVOutputTool("get_thing", `{"number":1}`)

	tools := withCSVOutput([]inventory.ServerTool{listTool, getTool})
	require.Len(t, tools, 2)

	// CSV mode does not introduce variants or change tool gating; both tools
	// remain visible regardless of feature flag state.
	for _, csvOutputEnabled := range []bool{false, true} {
		inv := buildCSVOutputInventory(t, tools, csvOutputEnabled)
		available := inv.AvailableTools(context.Background())
		require.Len(t, available, 2)

		listing := requireToolByName(t, available, "list_things")
		assert.Empty(t, listing.FeatureFlagEnable)
		assert.Empty(t, listing.FeatureFlagDisable)

		getting := requireToolByName(t, available, "get_thing")
		assert.Empty(t, getting.FeatureFlagEnable)
		assert.Empty(t, getting.FeatureFlagDisable)
	}
}

func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) {
	enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`)
	enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame
	disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`)
	disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame}

	tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly})
	require.Len(t, tools, 2)

	// Both flag-gated variants get the CSV wrapper; the per-request flag filter
	// decides which one actually registers, and the runtime csv_output check
	// decides whether the wrapper converts the response.
	deps := newCSVOutputTestDeps(true)
	for _, tool := range tools {
		result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
		require.NoError(t, err)
		assert.Contains(t, textResult(t, result), "number\n")
	}
}

func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) {
	nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions)

	tools := withCSVOutput([]inventory.ServerTool{nonDefaultListTool})
	require.Len(t, tools, 1)

	// Non-default toolset list tools are not wrapped: even with the flag on,
	// the response stays in JSON form.
	deps := newCSVOutputTestDeps(true)
	result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
	require.NoError(t, err)
	assert.JSONEq(t, `[{"number":1}]`, textResult(t, result))
}

func TestCSVOutputDoesNotExposeFormatParameter(t *testing.T) {
	tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", `[{"number":1}]`)})
	require.Len(t, tools, 1)

	schema, ok := tools[0].Tool.InputSchema.(*jsonschema.Schema)
	require.True(t, ok)
	assert.NotContains(t, schema.Properties, "output_format")
}

func TestCSVOutputConvertsJSONTextToCSVWhenFlagOn(t *testing.T) {
	tools := withCSVOutput([]inventory.ServerTool{
		testCSVOutputTool("list_things", `[
			{
				"number": 1,
				"body": "first line\n\tsecond line",
				"labels": ["bug", "help wanted"],
				"user": {"login": "octocat"}
			}
		]`),
	})
	require.Len(t, tools, 1)

	deps := newCSVOutputTestDeps(true)
	result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
	require.NoError(t, err)
	require.NotNil(t, result)
	require.False(t, result.IsError)

	assert.NotContains(t, textResult(t, result), "#")

	records := readCSVResult(t, result)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "first line second line", row["body"])
	assert.Equal(t, "bug;help wanted", row["labels"])
	assert.Equal(t, "1", row["number"])
	assert.Equal(t, "octocat", row["user.login"])
}

func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) {
	const jsonResponse = `[{"number":1,"user":{"login":"octocat"}}]`
	tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", jsonResponse)})
	require.Len(t, tools, 1)

	deps := newCSVOutputTestDeps(false)
	result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
	require.NoError(t, err)
	require.NotNil(t, result)

	require.Len(t, result.Content, 1)
	text, ok := result.Content[0].(*mcp.TextContent)
	require.True(t, ok)
	assert.JSONEq(t, jsonResponse, text.Text)
}

func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"issues": [
			{"number": 1, "title": "First issue"}
		],
		"pageInfo": {
			"endCursor": "cursor-1",
			"hasNextPage": true
		},
		"totalCount": 2
	}`)
	require.NoError(t, err)
	assert.Contains(t, csvText, "# pageInfo.endCursor: cursor-1\n")
	assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n")
	assert.Contains(t, csvText, "# totalCount: 2\n\n")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "1", row["number"])
	assert.Equal(t, "First issue", row["title"])
	assert.NotContains(t, row, "pageInfo.endCursor")
	assert.NotContains(t, row, "totalCount")
}

func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"discussions": [
			{
				"number": 5,
				"title": "Discussion tools testing",
				"category": {"name": "Q&A"},
				"user": {"login": "octocat"}
			}
		]
	}`)
	require.NoError(t, err)

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "Q&A", row["category.name"])
	assert.Equal(t, "5", row["number"])
	assert.Equal(t, "Discussion tools testing", row["title"])
	assert.Equal(t, "octocat", row["user.login"])
}

func TestJSONTextToCSVFindsPrimaryRowsOneLevelDeeper(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"issues": {
			"nodes": [
				{"number": 5, "title": "Nested issue"}
			],
			"pageInfo": {"hasNextPage": false},
			"totalCount": 1
		}
	}`)
	require.NoError(t, err)

	assert.Contains(t, csvText, "# issues.pageInfo.hasNextPage: false\n")
	assert.Contains(t, csvText, "# issues.totalCount: 1\n\n")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "5", row["number"])
	assert.Equal(t, "Nested issue", row["title"])
}

func TestJSONTextToCSVUsesSingleArrayAsPrimaryRows(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"results": [
			{"number": 1, "title": "Single array result"}
		],
		"pageInfo": {"hasNextPage": true}
	}`)
	require.NoError(t, err)

	assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n\n")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "1", row["number"])
	assert.Equal(t, "Single array result", row["title"])
	assert.NotContains(t, row, "pageInfo.hasNextPage")
}

func TestJSONTextToCSVFlattensRootObjectWithoutPrimaryRows(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"name": "summary",
		"pageInfo": {"hasNextPage": false},
		"totalCount": 2
	}`)
	require.NoError(t, err)
	assert.NotContains(t, csvText, "#")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "summary", row["name"])
	assert.Equal(t, "false", row["pageInfo.hasNextPage"])
	assert.Equal(t, "2", row["totalCount"])
}

func TestJSONTextToCSVConvertsScalarToValueRow(t *testing.T) {
	csvText, err := jsonTextToCSV(`"plain value"`)
	require.NoError(t, err)

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "plain value", row["value"])
}

func TestJSONTextToCSVReturnsEmptyForEmptyArray(t *testing.T) {
	csvText, err := jsonTextToCSV(`[]`)
	require.NoError(t, err)
	assert.Empty(t, csvText)
}

func TestJSONTextToCSVReturnsEmptyForEmptyObject(t *testing.T) {
	csvText, err := jsonTextToCSV(`{}`)
	require.NoError(t, err)
	assert.Empty(t, csvText)
}

func TestJSONTextToCSVReturnsEmptyForOnlyEmptyNestedObjects(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"repository": {
			"owner": {}
		}
	}`)
	require.NoError(t, err)
	assert.Empty(t, csvText)
}

func TestJSONTextToCSVReturnsMetadataOnlyWhenRowsHaveNoColumns(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"items": [
			{}
		],
		"totalCount": 1
	}`)
	require.NoError(t, err)
	assert.Equal(t, "# totalCount: 1\n\n", csvText)
}

func TestJSONTextToCSVFlattensAmbiguousArraysAsSingleRow(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"foo": ["a", "b"],
		"bar": ["c"]
	}`)
	require.NoError(t, err)
	assert.NotContains(t, csvText, "#")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "c", row["bar"])
	assert.Equal(t, "a;b", row["foo"])
}

func TestJSONTextToCSVUsesPreferredArrayWhenMultipleArraysExist(t *testing.T) {
	csvText, err := jsonTextToCSV(`{
		"items": [
			{"id": 1}
		],
		"other": [
			{"id": 2}
		],
		"totalCount": 1
	}`)
	require.NoError(t, err)

	assert.Contains(t, csvText, "# other: {\"id\":2}\n")
	assert.Contains(t, csvText, "# totalCount: 1\n\n")

	records := readCSVText(t, csvText)
	require.Len(t, records, 2)

	row := csvRow(t, records[0], records[1])
	assert.Equal(t, "1", row["id"])
}

func testCSVOutputTool(name string, response string) inventory.ServerTool {
	return testCSVOutputToolWithToolset(name, response, ToolsetMetadataRepos)
}

func testCSVOutputToolWithToolset(name string, response string, toolset inventory.ToolsetMetadata) inventory.ServerTool {
	return inventory.ServerTool{
		Tool: mcp.Tool{
			Name: name,
			Annotations: &mcp.ToolAnnotations{
				ReadOnlyHint: true,
			},
			InputSchema: &jsonschema.Schema{
				Type:       "object",
				Properties: map[string]*jsonschema.Schema{},
			},
		},
		Toolset: toolset,
		HandlerFunc: func(_ any) mcp.ToolHandler {
			return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
				return &mcp.CallToolResult{
					Content: []mcp.Content{
						&mcp.TextContent{Text: response},
					},
				}, nil
			}
		},
	}
}

func buildCSVOutputInventory(t *testing.T, tools []inventory.ServerTool, _ bool) *inventory.Inventory {
	t.Helper()

	inv, err := inventory.NewBuilder().
		SetTools(tools).
		Build()
	require.NoError(t, err)
	return inv
}

func newCSVOutputTestDeps(csvOutputEnabled bool) ToolDependencies {
	return csvOutputTestDeps{stubDeps: stubDeps{obsv: stubExporters()}, csvOn: csvOutputEnabled}
}

type csvOutputTestDeps struct {
	stubDeps
	csvOn bool
}

func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool {
	return flag == FeatureFlagCSVOutput && d.csvOn
}

func requireToolByName(t *testing.T, tools []inventory.ServerTool, name string) inventory.ServerTool {
	t.Helper()

	for _, tool := range tools {
		if tool.Tool.Name == name {
			return tool
		}
	}
	require.Failf(t, "tool not found", "tool %q not found", name)
	return inventory.ServerTool{}
}

func testCSVOutputRequest() *mcp.CallToolRequest {
	return &mcp.CallToolRequest{
		Params: &mcp.CallToolParamsRaw{
			Arguments: json.RawMessage(`{}`),
		},
	}
}

func readCSVResult(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)

	return readCSVText(t, text.Text)
}

func textResult(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)
	return text.Text
}

func readCSVText(t *testing.T, text string) [][]string {
	t.Helper()

	reader := csv.NewReader(strings.NewReader(text))
	reader.Comment = '#'
	records, err := reader.ReadAll()
	require.NoError(t, err)
	return records
}

func csvRow(t *testing.T, headers []string, record []string) map[string]string {
	t.Helper()
	require.Len(t, record, len(headers))

	row := make(map[string]string, len(headers))
	for i, header := range headers {
		row[header] = record[i]
	}
	return row
}
