package github

import (
	"context"
	"encoding/json"
	"net/http"
	"testing"

	"github.com/github/github-mcp-server/internal/githubv4mock"
	"github.com/github/github-mcp-server/pkg/translations"
	"github.com/shurcooL/githubv4"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// projectFieldsQueryMatcher is the GraphQL shape we use for fields(first:100) resolution.
// Keep this in sync with projectFieldsConnection in projects_resolver.go.
type projectFieldsTestQuery struct {
	Organization struct {
		ProjectV2 struct {
			Fields struct {
				Nodes []struct {
					ProjectV2Field struct {
						ID         githubv4.ID
						DatabaseID githubv4.Int `graphql:"databaseId"`
						Name       githubv4.String
						DataType   githubv4.String
					} `graphql:"... on ProjectV2Field"`
					ProjectV2IterationField struct {
						ID         githubv4.ID
						DatabaseID githubv4.Int `graphql:"databaseId"`
						Name       githubv4.String
						DataType   githubv4.String
					} `graphql:"... on ProjectV2IterationField"`
					ProjectV2SingleSelectField struct {
						ID         githubv4.ID
						DatabaseID githubv4.Int `graphql:"databaseId"`
						Name       githubv4.String
						DataType   githubv4.String
						Options    []struct {
							ID   githubv4.String
							Name githubv4.String
						}
					} `graphql:"... on ProjectV2SingleSelectField"`
				}
				PageInfo PageInfoFragment
			} `graphql:"fields(first: $first, after: $after)"`
		} `graphql:"projectV2(number: $projectNumber)"`
	} `graphql:"organization(login: $owner)"`
}

func fieldsQueryVars(owner string, projectNumber int) map[string]any {
	return map[string]any{
		"owner":         githubv4.String(owner),
		"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec
		"first":         githubv4.Int(resolverFieldsPageSize),
		"after":         (*githubv4.String)(nil),
	}
}

// statusFieldNode is a single-select field response node for use in mock data.
// `nodeID` is the global node ID (e.g. "PVTSSF_lADO...") and `databaseID` is
// the numeric database ID the REST API expects.
func statusFieldNode(nodeID string, databaseID int, name string, options []map[string]any) map[string]any {
	return map[string]any{
		"id":         nodeID,
		"databaseId": databaseID,
		"name":       name,
		"dataType":   "SINGLE_SELECT",
		"options":    options,
	}
}

func fieldsResponse(nodes []map[string]any) map[string]any {
	return map[string]any{
		"organization": map[string]any{
			"projectV2": map[string]any{
				"fields": map[string]any{
					"nodes": nodes,
					"pageInfo": map[string]any{
						"hasNextPage":     false,
						"hasPreviousPage": false,
						"startCursor":     "",
						"endCursor":       "",
					},
				},
			},
		},
	}
}

func Test_ResolveProjectFieldByName_Success(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 7),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{
					{"id": "OPT_a", "name": "Todo"},
					{"id": "OPT_b", "name": "In Progress"},
					{"id": "OPT_c", "name": "Done"},
				}),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT")
	require.NoError(t, err)
	require.NotNil(t, field)
	assert.Equal(t, "12345", field.ID)
	assert.Equal(t, "SINGLE_SELECT", field.DataType)
	assert.Len(t, field.Options, 3)

	optionID, err := resolveSingleSelectOptionByName(field, "In Progress")
	require.NoError(t, err)
	assert.Equal(t, "OPT_b", optionID)
}

func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 7),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	_, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "")
	require.Error(t, err)

	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
	assert.Equal(t, "field_not_found", msg["error"])
	assert.Equal(t, "Priority", msg["name"])
	assert.NotEmpty(t, msg["candidates"])
}

func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 7),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil),
				statusFieldNode("PVTSSF_lADOBBcDeFg678", 67890, "Status", nil),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	_, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "")
	require.Error(t, err)

	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
	assert.Equal(t, "field_ambiguous", msg["error"])
	candidates, _ := msg["candidates"].([]any)
	assert.Len(t, candidates, 2)
}

func Test_ResolveSingleSelectOptionByName_NotFound(t *testing.T) {
	field := &ResolvedField{
		ID:       "12345",
		Name:     "Status",
		DataType: "SINGLE_SELECT",
		Options: []ResolvedFieldOption{
			{ID: "OPT_a", Name: "Todo"},
			{ID: "OPT_b", Name: "Done"},
		},
	}

	_, err := resolveSingleSelectOptionByName(field, "Blocked")
	require.Error(t, err)

	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
	assert.Equal(t, "option_not_found", msg["error"])
	assert.Equal(t, "Blocked", msg["name"])
}

func Test_ResolveSingleSelectOptionByName_WrongFieldType(t *testing.T) {
	field := &ResolvedField{
		ID:       "12345",
		Name:     "Description",
		DataType: "TEXT",
	}

	_, err := resolveSingleSelectOptionByName(field, "anything")
	require.Error(t, err)

	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
	assert.Equal(t, "wrong_field_type", msg["error"])
}

// resolveItemByIssueQuery matches the GraphQL shape used by
// resolveProjectItemIDByIssueNumber for the issue.projectItems traversal.
type resolveItemByIssueQuery struct {
	Repository struct {
		Issue struct {
			ProjectItems struct {
				Nodes []struct {
					FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
					Project        struct {
						ID githubv4.ID
					}
				}
				PageInfo PageInfoFragment
			} `graphql:"projectItems(first: 50, includeArchived: true)"`
		} `graphql:"issue(number: $issueNumber)"`
	} `graphql:"repository(owner: $issueOwner, name: $issueRepo)"`
}

func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		// project node id lookup (org)
		githubv4mock.NewQueryMatcher(
			struct {
				Organization struct {
					ProjectV2 struct {
						ID githubv4.ID
					} `graphql:"projectV2(number: $projectNumber)"`
				} `graphql:"organization(login: $owner)"`
			}{},
			map[string]any{
				"owner":         githubv4.String("octo-org"),
				"projectNumber": githubv4.Int(1),
			},
			githubv4mock.DataResponse(map[string]any{
				"organization": map[string]any{
					"projectV2": map[string]any{
						"id": "PVT_project1",
					},
				},
			}),
		),
		// issue.projectItems lookup
		githubv4mock.NewQueryMatcher(
			resolveItemByIssueQuery{},
			map[string]any{
				"issueOwner":  githubv4.String("octo-issue-owner"),
				"issueRepo":   githubv4.String("repo"),
				"issueNumber": githubv4.Int(123),
			},
			githubv4mock.DataResponse(map[string]any{
				"repository": map[string]any{
					"issue": map[string]any{
						"projectItems": map[string]any{
							"nodes": []any{
								map[string]any{
									"fullDatabaseId": "9999",
									"project":        map[string]any{"id": "PVT_other"},
								},
								map[string]any{
									"fullDatabaseId": "4242",
									"project":        map[string]any{"id": "PVT_project1"},
								},
							},
							"pageInfo": map[string]any{
								"hasNextPage":     false,
								"hasPreviousPage": false,
								"startCursor":     "",
								"endCursor":       "",
							},
						},
					},
				},
			}),
		),
	)
	gql := githubv4.NewClient(mocked)

	itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
	require.NoError(t, err)
	assert.Equal(t, int64(4242), itemID)
}

func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			struct {
				Organization struct {
					ProjectV2 struct {
						ID githubv4.ID
					} `graphql:"projectV2(number: $projectNumber)"`
				} `graphql:"organization(login: $owner)"`
			}{},
			map[string]any{
				"owner":         githubv4.String("octo-org"),
				"projectNumber": githubv4.Int(1),
			},
			githubv4mock.DataResponse(map[string]any{
				"organization": map[string]any{
					"projectV2": map[string]any{
						"id": "PVT_project1",
					},
				},
			}),
		),
		githubv4mock.NewQueryMatcher(
			resolveItemByIssueQuery{},
			map[string]any{
				"issueOwner":  githubv4.String("octo-issue-owner"),
				"issueRepo":   githubv4.String("repo"),
				"issueNumber": githubv4.Int(123),
			},
			githubv4mock.DataResponse(map[string]any{
				"repository": map[string]any{
					"issue": map[string]any{
						"projectItems": map[string]any{
							"nodes": []any{
								map[string]any{
									"fullDatabaseId": "9999",
									"project":        map[string]any{"id": "PVT_other"},
								},
							},
							"pageInfo": map[string]any{
								"hasNextPage":     false,
								"hasPreviousPage": false,
								"startCursor":     "",
								"endCursor":       "",
							},
						},
					},
				},
			}),
		),
	)
	gql := githubv4.NewClient(mocked)

	_, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
	require.Error(t, err)

	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
	assert.Equal(t, "item_not_in_project", msg["error"])
}

func Test_ResolveFieldNamesToIDs_Success(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 1),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
				statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Status", "Priority"})
	require.NoError(t, err)
	assert.Equal(t, []int64{100, 200}, ids)
}

// Field and single-select option name matching is case-insensitive so agents passing lowercase 
// names like "status" or "in progress" resolve to "Status" and "In Progress" respectively.
func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 7),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{
					{"id": "OPT_a", "name": "Todo"},
					{"id": "OPT_b", "name": "In Progress"},
				}),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "status", "")
	require.NoError(t, err)
	require.NotNil(t, field)
	assert.Equal(t, "12345", field.ID)

	optionID, err := resolveSingleSelectOptionByName(field, "in progress")
	require.NoError(t, err)
	assert.Equal(t, "OPT_b", optionID)
}

// Test_ResolveFieldNamesToIDs_CaseInsensitive verifies bulk name resolution
// also matches case-insensitively.
func Test_ResolveFieldNamesToIDs_CaseInsensitive(t *testing.T) {
	mocked := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 1),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
				statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil),
			})),
		),
	)
	gql := githubv4.NewClient(mocked)

	ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"status", "PRIORITY"})
	require.NoError(t, err)
	assert.Equal(t, []int64{100, 200}, ids)
}

// Test_ProjectsWrite_UpdateProjectItem_ByName is the acceptance test for the
// write side: set Status = "In Progress" using only names plus an issue number.
func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) {
	toolDef := ProjectsWrite(translations.NullTranslationHelper)

	updatedItem := verbosePullRequestProjectItemFixture()

	mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
		PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem),
	})
	restClient := mustNewGHClient(t, mockedREST)

	mockedGQL := githubv4mock.NewMockedHTTPClient(
		// 1. project node id (used by resolveProjectItemIDByIssueNumber)
		githubv4mock.NewQueryMatcher(
			struct {
				Organization struct {
					ProjectV2 struct {
						ID githubv4.ID
					} `graphql:"projectV2(number: $projectNumber)"`
				} `graphql:"organization(login: $owner)"`
			}{},
			map[string]any{
				"owner":         githubv4.String("octo-org"),
				"projectNumber": githubv4.Int(1),
			},
			githubv4mock.DataResponse(map[string]any{
				"organization": map[string]any{
					"projectV2": map[string]any{"id": "PVT_project1"},
				},
			}),
		),
		// 2. issue -> projectItems lookup
		githubv4mock.NewQueryMatcher(
			resolveItemByIssueQuery{},
			map[string]any{
				"issueOwner":  githubv4.String("github"),
				"issueRepo":   githubv4.String("planning-tracking"),
				"issueNumber": githubv4.Int(123),
			},
			githubv4mock.DataResponse(map[string]any{
				"repository": map[string]any{
					"issue": map[string]any{
						"projectItems": map[string]any{
							"nodes": []any{
								map[string]any{
									"fullDatabaseId": "1001",
									"project":        map[string]any{"id": "PVT_project1"},
								},
							},
							"pageInfo": map[string]any{
								"hasNextPage": false, "hasPreviousPage": false,
								"startCursor": "", "endCursor": "",
							},
						},
					},
				},
			}),
		),
		// 3. fields(first:100) for name resolution
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 1),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{
					{"id": "OPT_in_progress", "name": "In Progress"},
				}),
			})),
		),
	)
	gqlClient := githubv4.NewClient(mockedGQL)

	deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
	handler := toolDef.Handler(deps)
	request := createMCPRequest(map[string]any{
		"method":         "update_project_item",
		"owner":          "octo-org",
		"owner_type":     "org",
		"project_number": float64(1),
		"item_owner":     "github",
		"item_repo":      "planning-tracking",
		"issue_number":   float64(123),
		"updated_field": map[string]any{
			"name":  "Status",
			"value": "In Progress",
		},
	})
	result, err := handler(ContextWithDeps(context.Background(), deps), &request)

	require.NoError(t, err)
	require.False(t, result.IsError, getTextResult(t, result).Text)
}

func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testing.T) {
	toolDef := ProjectsWrite(translations.NullTranslationHelper)

	mockedGQL := githubv4mock.NewMockedHTTPClient(
		githubv4mock.NewQueryMatcher(
			projectFieldsTestQuery{},
			fieldsQueryVars("octo-org", 1),
			githubv4mock.DataResponse(fieldsResponse([]map[string]any{
				statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", nil),
			})),
		),
	)
	gqlClient := githubv4.NewClient(mockedGQL)
	restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))

	deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
	handler := toolDef.Handler(deps)
	request := createMCPRequest(map[string]any{
		"method":         "update_project_item",
		"owner":          "octo-org",
		"owner_type":     "org",
		"project_number": float64(1),
		"item_id":        float64(1001),
		"updated_field": map[string]any{
			"name":  "Doesnt Exist",
			"value": "whatever",
		},
	})
	result, err := handler(ContextWithDeps(context.Background(), deps), &request)

	require.NoError(t, err)
	require.True(t, result.IsError)

	textContent := getTextResult(t, result)
	var msg map[string]any
	require.NoError(t, json.Unmarshal([]byte(textContent.Text), &msg))
	assert.Equal(t, "field_not_found", msg["error"])
	assert.Equal(t, "Doesnt Exist", msg["name"])
}
