package github

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/github/github-mcp-server/pkg/ifc"
	"github.com/github/github-mcp-server/pkg/inventory"
	"github.com/github/github-mcp-server/pkg/scopes"
	"github.com/github/github-mcp-server/pkg/translations"
	"github.com/github/github-mcp-server/pkg/utils"
	"github.com/go-viper/mapstructure/v2"
	"github.com/google/go-github/v89/github"
	"github.com/google/jsonschema-go/jsonschema"
	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/shurcooL/githubv4"
)

const DefaultGraphQLPageSize = 30

// Common interface for all discussion query types
type DiscussionQueryResult interface {
	GetDiscussionFragment() DiscussionFragment
}

// Implement the interface for all query types
func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment {
	return q.Repository.Discussions
}

func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment {
	return q.Repository.Discussions
}

func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment {
	return q.Repository.Discussions
}

func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment {
	return q.Repository.Discussions
}

type DiscussionFragment struct {
	Nodes      []NodeFragment
	PageInfo   PageInfoFragment
	TotalCount githubv4.Int
}

type NodeFragment struct {
	Number         githubv4.Int
	Title          githubv4.String
	CreatedAt      githubv4.DateTime
	UpdatedAt      githubv4.DateTime
	Closed         githubv4.Boolean
	IsAnswered     githubv4.Boolean
	AnswerChosenAt *githubv4.DateTime
	Author         struct {
		Login githubv4.String
	}
	Category struct {
		Name githubv4.String
	} `graphql:"category"`
	URL githubv4.String `graphql:"url"`
}

type PageInfoFragment struct {
	HasNextPage     bool
	HasPreviousPage bool
	StartCursor     githubv4.String
	EndCursor       githubv4.String
}

type BasicNoOrder struct {
	Repository struct {
		Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"`
	} `graphql:"repository(owner: $owner, name: $repo)"`
}

type BasicWithOrder struct {
	Repository struct {
		Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"`
	} `graphql:"repository(owner: $owner, name: $repo)"`
}

type WithCategoryAndOrder struct {
	Repository struct {
		Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"`
	} `graphql:"repository(owner: $owner, name: $repo)"`
}

type WithCategoryNoOrder struct {
	Repository struct {
		Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"`
	} `graphql:"repository(owner: $owner, name: $repo)"`
}

func fragmentToDiscussion(fragment NodeFragment) *github.Discussion {
	return &github.Discussion{
		Number:    github.Ptr(int(fragment.Number)),
		Title:     github.Ptr(string(fragment.Title)),
		HTMLURL:   github.Ptr(string(fragment.URL)),
		CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
		UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
		User: &github.User{
			Login: github.Ptr(string(fragment.Author.Login)),
		},
		DiscussionCategory: &github.DiscussionCategory{
			Name: github.Ptr(string(fragment.Category.Name)),
		},
	}
}

func getQueryType(useOrdering bool, categoryID *githubv4.ID) any {
	if categoryID != nil && useOrdering {
		return &WithCategoryAndOrder{}
	}
	if categoryID != nil && !useOrdering {
		return &WithCategoryNoOrder{}
	}
	if categoryID == nil && useOrdering {
		return &BasicWithOrder{}
	}
	return &BasicNoOrder{}
}

func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool {
	return NewTool(
		ToolsetMetadataDiscussions,
		mcp.Tool{
			Name:        "list_discussions",
			Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."),
			Annotations: &mcp.ToolAnnotations{
				Title:        t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
				ReadOnlyHint: true,
			},
			InputSchema: WithCursorPagination(&jsonschema.Schema{
				Type: "object",
				Properties: map[string]*jsonschema.Schema{
					"owner": {
						Type:        "string",
						Description: "Repository owner",
					},
					"repo": {
						Type:        "string",
						Description: "Repository name. If not provided, discussions will be queried at the organisation level.",
					},
					"category": {
						Type:        "string",
						Description: "Optional filter by discussion category ID. If provided, only discussions with this category are listed.",
					},
					"orderBy": {
						Type:        "string",
						Description: "Order discussions by field. If provided, the 'direction' also needs to be provided.",
						Enum:        []any{"CREATED_AT", "UPDATED_AT"},
					},
					"direction": {
						Type:        "string",
						Description: "Order direction.",
						Enum:        []any{"ASC", "DESC"},
					},
				},
				Required: []string{"owner"},
			}),
		},
		[]scopes.Scope{scopes.Repo},
		func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
			owner, err := RequiredParam[string](args, "owner")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			repo, err := OptionalParam[string](args, "repo")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			// when not provided, default to the .github repository
			// this will query discussions at the organisation level
			if repo == "" {
				repo = ".github"
			}

			category, err := OptionalParam[string](args, "category")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			orderBy, err := OptionalParam[string](args, "orderBy")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			direction, err := OptionalParam[string](args, "direction")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			// Get pagination parameters and convert to GraphQL format
			pagination, err := OptionalCursorPaginationParams(args)
			if err != nil {
				return nil, nil, err
			}
			paginationParams, err := pagination.ToGraphQLParams()
			if err != nil {
				return nil, nil, err
			}

			client, err := deps.GetGQLClient(ctx)
			if err != nil {
				return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
			}

			var categoryID *githubv4.ID
			if category != "" {
				id := githubv4.ID(category)
				categoryID = &id
			}

			vars := map[string]any{
				"owner": githubv4.String(owner),
				"repo":  githubv4.String(repo),
				"first": githubv4.Int(*paginationParams.First),
			}
			if paginationParams.After != nil {
				vars["after"] = githubv4.String(*paginationParams.After)
			} else {
				vars["after"] = (*githubv4.String)(nil)
			}

			// this is an extra check in case the tool description is misinterpreted, because
			// we shouldn't use ordering unless both a 'field' and 'direction' are provided
			useOrdering := orderBy != "" && direction != ""
			if useOrdering {
				vars["orderByField"] = githubv4.DiscussionOrderField(orderBy)
				vars["orderByDirection"] = githubv4.OrderDirection(direction)
			}

			if categoryID != nil {
				vars["categoryId"] = *categoryID
			}

			discussionQuery := getQueryType(useOrdering, categoryID)
			if err := client.Query(ctx, discussionQuery, vars); err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			// Extract and convert all discussion nodes using the common interface
			var discussions []*github.Discussion
			var pageInfo PageInfoFragment
			var totalCount githubv4.Int
			if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok {
				fragment := queryResult.GetDiscussionFragment()
				for _, node := range fragment.Nodes {
					discussions = append(discussions, fragmentToDiscussion(node))
				}
				pageInfo = fragment.PageInfo
				totalCount = fragment.TotalCount
			}

			// Create response with pagination info
			response := map[string]any{
				"discussions": discussions,
				"pageInfo": map[string]any{
					"hasNextPage":     pageInfo.HasNextPage,
					"hasPreviousPage": pageInfo.HasPreviousPage,
					"startCursor":     string(pageInfo.StartCursor),
					"endCursor":       string(pageInfo.EndCursor),
				},
				"totalCount": totalCount,
			}

			out, err := json.Marshal(response)
			if err != nil {
				return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err)
			}
			result := utils.NewToolResultText(string(out))
			// Discussion content is user-authored (untrusted); confidentiality
			// follows repo visibility.
			result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent)
			return result, nil, nil
		},
	)
}

func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
	return NewTool(
		ToolsetMetadataDiscussions,
		mcp.Tool{
			Name:        "get_discussion",
			Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"),
			Annotations: &mcp.ToolAnnotations{
				Title:        t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
				ReadOnlyHint: true,
			},
			InputSchema: &jsonschema.Schema{
				Type: "object",
				Properties: map[string]*jsonschema.Schema{
					"owner": {
						Type:        "string",
						Description: "Repository owner",
					},
					"repo": {
						Type:        "string",
						Description: "Repository name",
					},
					"discussionNumber": {
						Type:        "number",
						Description: "Discussion Number",
					},
				},
				Required: []string{"owner", "repo", "discussionNumber"},
			},
		},
		[]scopes.Scope{scopes.Repo},
		func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
			// Decode params
			var params struct {
				Owner            string
				Repo             string
				DiscussionNumber int32
			}
			if err := mapstructure.WeakDecode(args, &params); err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			client, err := deps.GetGQLClient(ctx)
			if err != nil {
				return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
			}

			var q struct {
				Repository struct {
					Discussion struct {
						Number         githubv4.Int
						Title          githubv4.String
						Body           githubv4.String
						CreatedAt      githubv4.DateTime
						Closed         githubv4.Boolean
						IsAnswered     githubv4.Boolean
						AnswerChosenAt *githubv4.DateTime
						URL            githubv4.String `graphql:"url"`
						Category       struct {
							Name githubv4.String
						} `graphql:"category"`
					} `graphql:"discussion(number: $discussionNumber)"`
				} `graphql:"repository(owner: $owner, name: $repo)"`
			}
			vars := map[string]any{
				"owner":            githubv4.String(params.Owner),
				"repo":             githubv4.String(params.Repo),
				"discussionNumber": githubv4.Int(params.DiscussionNumber),
			}
			if err := client.Query(ctx, &q, vars); err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			d := q.Repository.Discussion

			// Build response as map to include fields not present in go-github's Discussion struct.
			// The go-github library's Discussion type lacks isAnswered and answerChosenAt fields,
			// so we use map[string]interface{} for the response (consistent with other functions
			// like ListDiscussions and GetDiscussionComments).
			response := map[string]any{
				"number":     int(d.Number),
				"title":      string(d.Title),
				"body":       string(d.Body),
				"url":        string(d.URL),
				"closed":     bool(d.Closed),
				"isAnswered": bool(d.IsAnswered),
				"createdAt":  d.CreatedAt.Time,
				"category": map[string]any{
					"name": string(d.Category.Name),
				},
			}

			// Add optional timestamp fields if present
			if d.AnswerChosenAt != nil {
				response["answerChosenAt"] = d.AnswerChosenAt.Time
			}

			out, err := json.Marshal(response)
			if err != nil {
				return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
			}

			result := utils.NewToolResultText(string(out))
			// Discussion content is user-authored (untrusted); confidentiality
			// follows repo visibility.
			result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
			return result, nil, nil
		},
	)
}

func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool {
	return NewTool(
		ToolsetMetadataDiscussions,
		mcp.Tool{
			Name:        "get_discussion_comments",
			Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"),
			Annotations: &mcp.ToolAnnotations{
				Title:        t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
				ReadOnlyHint: true,
			},
			InputSchema: WithCursorPagination(&jsonschema.Schema{
				Type: "object",
				Properties: map[string]*jsonschema.Schema{
					"owner": {
						Type:        "string",
						Description: "Repository owner",
					},
					"repo": {
						Type:        "string",
						Description: "Repository name",
					},
					"discussionNumber": {
						Type:        "number",
						Description: "Discussion Number",
					},
					"includeReplies": {
						Type:        "boolean",
						Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.",
					},
				},
				Required: []string{"owner", "repo", "discussionNumber"},
			}),
		},
		[]scopes.Scope{scopes.Repo},
		func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
			// Decode params
			var params struct {
				Owner            string
				Repo             string
				DiscussionNumber int32
			}
			if err := mapstructure.WeakDecode(args, &params); err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			includeReplies, err := OptionalParam[bool](args, "includeReplies")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			// Get pagination parameters and convert to GraphQL format
			pagination, err := OptionalCursorPaginationParams(args)
			if err != nil {
				return nil, nil, err
			}

			// Check if pagination parameters were explicitly provided
			_, perPageProvided := args["perPage"]
			paginationExplicit := perPageProvided

			paginationParams, err := pagination.ToGraphQLParams()
			if err != nil {
				return nil, nil, err
			}

			// Use default of 30 if pagination was not explicitly provided
			if !paginationExplicit {
				defaultFirst := int32(DefaultGraphQLPageSize)
				paginationParams.First = &defaultFirst
			}

			client, err := deps.GetGQLClient(ctx)
			if err != nil {
				return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
			}

			vars := map[string]any{
				"owner":            githubv4.String(params.Owner),
				"repo":             githubv4.String(params.Repo),
				"discussionNumber": githubv4.Int(params.DiscussionNumber),
				"first":            githubv4.Int(*paginationParams.First),
			}
			if paginationParams.After != nil {
				vars["after"] = githubv4.String(*paginationParams.After)
			} else {
				vars["after"] = (*githubv4.String)(nil)
			}

			var comments []MinimalDiscussionComment
			var pageInfo struct {
				HasNextPage     githubv4.Boolean
				HasPreviousPage githubv4.Boolean
				StartCursor     githubv4.String
				EndCursor       githubv4.String
			}
			var totalCount int

			if includeReplies {
				var q struct {
					Repository struct {
						Discussion struct {
							Comments struct {
								Nodes []struct {
									ID       githubv4.ID
									Body     githubv4.String
									IsAnswer githubv4.Boolean
									Replies  struct {
										Nodes []struct {
											ID       githubv4.ID
											Body     githubv4.String
											IsAnswer githubv4.Boolean
										}
										TotalCount int
									} `graphql:"replies(first: 100)"`
								}
								PageInfo struct {
									HasNextPage     githubv4.Boolean
									HasPreviousPage githubv4.Boolean
									StartCursor     githubv4.String
									EndCursor       githubv4.String
								}
								TotalCount int
							} `graphql:"comments(first: $first, after: $after)"`
						} `graphql:"discussion(number: $discussionNumber)"`
					} `graphql:"repository(owner: $owner, name: $repo)"`
				}
				if err := client.Query(ctx, &q, vars); err != nil {
					return utils.NewToolResultError(err.Error()), nil, nil
				}
				for _, c := range q.Repository.Discussion.Comments.Nodes {
					comment := MinimalDiscussionComment{
						ID:              fmt.Sprintf("%v", c.ID),
						Body:            string(c.Body),
						IsAnswer:        bool(c.IsAnswer),
						ReplyTotalCount: c.Replies.TotalCount,
					}
					for _, r := range c.Replies.Nodes {
						comment.Replies = append(comment.Replies, MinimalDiscussionComment{
							ID:       fmt.Sprintf("%v", r.ID),
							Body:     string(r.Body),
							IsAnswer: bool(r.IsAnswer),
						})
					}
					comments = append(comments, comment)
				}
				pageInfo = q.Repository.Discussion.Comments.PageInfo
				totalCount = q.Repository.Discussion.Comments.TotalCount
			} else {
				var q struct {
					Repository struct {
						Discussion struct {
							Comments struct {
								Nodes []struct {
									ID       githubv4.ID
									Body     githubv4.String
									IsAnswer githubv4.Boolean
								}
								PageInfo struct {
									HasNextPage     githubv4.Boolean
									HasPreviousPage githubv4.Boolean
									StartCursor     githubv4.String
									EndCursor       githubv4.String
								}
								TotalCount int
							} `graphql:"comments(first: $first, after: $after)"`
						} `graphql:"discussion(number: $discussionNumber)"`
					} `graphql:"repository(owner: $owner, name: $repo)"`
				}
				if err := client.Query(ctx, &q, vars); err != nil {
					return utils.NewToolResultError(err.Error()), nil, nil
				}
				for _, c := range q.Repository.Discussion.Comments.Nodes {
					comments = append(comments, MinimalDiscussionComment{
						ID:       fmt.Sprintf("%v", c.ID),
						Body:     string(c.Body),
						IsAnswer: bool(c.IsAnswer),
					})
				}
				pageInfo = q.Repository.Discussion.Comments.PageInfo
				totalCount = q.Repository.Discussion.Comments.TotalCount
			}

			// Create response with pagination info
			response := map[string]any{
				"comments": comments,
				"pageInfo": map[string]any{
					"hasNextPage":     pageInfo.HasNextPage,
					"hasPreviousPage": pageInfo.HasPreviousPage,
					"startCursor":     string(pageInfo.StartCursor),
					"endCursor":       string(pageInfo.EndCursor),
				},
				"totalCount": totalCount,
			}

			out, err := json.Marshal(response)
			if err != nil {
				return nil, nil, fmt.Errorf("failed to marshal comments: %w", err)
			}

			result := utils.NewToolResultText(string(out))
			// Discussion comments are user-authored (untrusted); confidentiality
			// follows repo visibility.
			result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
			return result, nil, nil
		},
	)
}

func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
	return NewTool(
		ToolsetMetadataDiscussions,
		mcp.Tool{
			Name: "discussion_comment_write",
			Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments.
Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`),
			Annotations: &mcp.ToolAnnotations{
				Title:           t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"),
				ReadOnlyHint:    false,
				DestructiveHint: jsonschema.Ptr(true),
			},
			InputSchema: &jsonschema.Schema{
				Type: "object",
				Properties: map[string]*jsonschema.Schema{
					"method": {
						Type: "string",
						Description: `Write operation to perform on a discussion comment.
Options are:
- 'add' - adds a new top-level comment to a discussion.
- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting).
- 'update' - updates an existing discussion comment.
- 'delete' - deletes a discussion comment.
- 'mark_answer' - marks a discussion comment as the answer (Q&A only).
- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only).
`,
						Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"},
					},
					"owner": {
						Type:        "string",
						Description: "Repository owner (required for 'add' and 'reply' methods)",
					},
					"repo": {
						Type:        "string",
						Description: "Repository name (required for 'add' and 'reply' methods)",
					},
					"discussionNumber": {
						Type:        "number",
						Description: "Discussion number (required for 'add' and 'reply' methods)",
					},
					"body": {
						Type:        "string",
						Description: "Comment content (required for 'add', 'reply', and 'update' methods)",
					},
					"commentNodeID": {
						Type:        "string",
						Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.",
					},
				},
				Required: []string{"method"},
			},
		},
		[]scopes.Scope{scopes.Repo},
		func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
			method, err := RequiredParam[string](args, "method")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			client, err := deps.GetGQLClient(ctx)
			if err != nil {
				return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
			}

			switch method {
			case "add":
				return addDiscussionComment(ctx, client, args)
			case "reply":
				return replyToDiscussionComment(ctx, client, args)
			case "update":
				return updateDiscussionComment(ctx, client, args)
			case "delete":
				return deleteDiscussionComment(ctx, client, args)
			case "mark_answer":
				return markDiscussionCommentAsAnswer(ctx, client, args)
			case "unmark_answer":
				return unmarkDiscussionCommentAsAnswer(ctx, client, args)
			default:
				return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil
			}
		})
}

func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	owner, err := RequiredParam[string](args, "owner")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	repo, err := RequiredParam[string](args, "repo")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	discussionNumber, err := RequiredInt(args, "discussionNumber")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	body, err := RequiredParam[string](args, "body")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	// Get the discussion's node ID using its number
	var q struct {
		Repository struct {
			Discussion struct {
				ID githubv4.ID
			} `graphql:"discussion(number: $discussionNumber)"`
		} `graphql:"repository(owner: $owner, name: $repo)"`
	}
	vars := map[string]any{
		"owner":            githubv4.String(owner),
		"repo":             githubv4.String(repo),
		"discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers
	}
	if err := client.Query(ctx, &q, vars); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	input := githubv4.AddDiscussionCommentInput{
		DiscussionID: q.Repository.Discussion.ID,
		Body:         githubv4.String(body),
	}

	var mutation struct {
		AddDiscussionComment struct {
			Comment struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"addDiscussionComment(input: $input)"`
	}

	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	comment := mutation.AddDiscussionComment.Comment
	out, err := json.Marshal(MinimalResponse{
		ID:  fmt.Sprintf("%v", comment.ID),
		URL: string(comment.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func requiredCommentNodeID(args map[string]any) (string, error) {
	commentNodeID, err := RequiredParam[string](args, "commentNodeID")
	if err != nil {
		return "", err
	}
	if strings.TrimSpace(commentNodeID) == "" {
		return "", fmt.Errorf("commentNodeID cannot be blank")
	}
	return commentNodeID, nil
}

func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	commentNodeID, err := requiredCommentNodeID(args)
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	owner, err := RequiredParam[string](args, "owner")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	repo, err := RequiredParam[string](args, "repo")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	discussionNumber, err := RequiredInt(args, "discussionNumber")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	body, err := RequiredParam[string](args, "body")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	// The GitHub API silently ignores an invalid ReplyToID and creates a top-level
	// comment instead of returning an error, so we validate upfront that the node
	// exists and is a DiscussionComment to give callers a clear failure.
	var nodeQuery struct {
		Node struct {
			DiscussionComment struct {
				ID         *githubv4.ID
				Discussion struct {
					ID githubv4.ID
				} `graphql:"discussion"`
			} `graphql:"... on DiscussionComment"`
		} `graphql:"node(id: $replyToID)"`
	}
	if err := client.Query(ctx, &nodeQuery, map[string]any{
		"replyToID": githubv4.ID(commentNodeID),
	}); err != nil {
		return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil
	}
	if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" {
		return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil
	}

	// Get the discussion's node ID using its number
	var q struct {
		Repository struct {
			Discussion struct {
				ID githubv4.ID
			} `graphql:"discussion(number: $discussionNumber)"`
		} `graphql:"repository(owner: $owner, name: $repo)"`
	}
	vars := map[string]any{
		"owner":            githubv4.String(owner),
		"repo":             githubv4.String(repo),
		"discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers
	}
	if err := client.Query(ctx, &q, vars); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID {
		return utils.NewToolResultError(
			fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo),
		), nil, nil
	}

	replyToID := githubv4.ID(commentNodeID)
	input := githubv4.AddDiscussionCommentInput{
		DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID,
		Body:         githubv4.String(body),
		ReplyToID:    &replyToID,
	}

	var mutation struct {
		AddDiscussionComment struct {
			Comment struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"addDiscussionComment(input: $input)"`
	}

	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	comment := mutation.AddDiscussionComment.Comment
	out, err := json.Marshal(MinimalResponse{
		ID:  fmt.Sprintf("%v", comment.ID),
		URL: string(comment.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	commentNodeID, err := requiredCommentNodeID(args)
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}
	body, err := RequiredParam[string](args, "body")
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	input := githubv4.UpdateDiscussionCommentInput{
		CommentID: githubv4.ID(commentNodeID),
		Body:      githubv4.String(body),
	}

	var mutation struct {
		UpdateDiscussionComment struct {
			Comment struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"updateDiscussionComment(input: $input)"`
	}

	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	comment := mutation.UpdateDiscussionComment.Comment
	out, err := json.Marshal(MinimalResponse{
		ID:  fmt.Sprintf("%v", comment.ID),
		URL: string(comment.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	commentNodeID, err := requiredCommentNodeID(args)
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	input := githubv4.DeleteDiscussionCommentInput{
		ID: githubv4.ID(commentNodeID),
	}

	var mutation struct {
		DeleteDiscussionComment struct {
			Comment struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"deleteDiscussionComment(input: $input)"`
	}

	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	comment := mutation.DeleteDiscussionComment.Comment
	out, err := json.Marshal(MinimalResponse{
		ID:  fmt.Sprintf("%v", comment.ID),
		URL: string(comment.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	commentNodeID, err := requiredCommentNodeID(args)
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	input := githubv4.MarkDiscussionCommentAsAnswerInput{
		ID: githubv4.ID(commentNodeID),
	}
	var mutation struct {
		MarkDiscussionCommentAsAnswer struct {
			Discussion struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"markDiscussionCommentAsAnswer(input: $input)"`
	}
	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	out, err := json.Marshal(struct {
		DiscussionID  string `json:"discussionID"`
		DiscussionURL string `json:"discussionURL"`
	}{
		DiscussionID:  fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID),
		DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
	commentNodeID, err := requiredCommentNodeID(args)
	if err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	input := githubv4.UnmarkDiscussionCommentAsAnswerInput{
		ID: githubv4.ID(commentNodeID),
	}
	var mutation struct {
		UnmarkDiscussionCommentAsAnswer struct {
			Discussion struct {
				ID  githubv4.ID
				URL githubv4.String `graphql:"url"`
			}
		} `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"`
	}
	if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
		return utils.NewToolResultError(err.Error()), nil, nil
	}

	out, err := json.Marshal(struct {
		DiscussionID  string `json:"discussionID"`
		DiscussionURL string `json:"discussionURL"`
	}{
		DiscussionID:  fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID),
		DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
	}

	return utils.NewToolResultText(string(out)), nil, nil
}

func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool {
	return NewTool(
		ToolsetMetadataDiscussions,
		mcp.Tool{
			Name:        "list_discussion_categories",
			Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."),
			Annotations: &mcp.ToolAnnotations{
				Title:        t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
				ReadOnlyHint: true,
			},
			InputSchema: &jsonschema.Schema{
				Type: "object",
				Properties: map[string]*jsonschema.Schema{
					"owner": {
						Type:        "string",
						Description: "Repository owner",
					},
					"repo": {
						Type:        "string",
						Description: "Repository name. If not provided, discussion categories will be queried at the organisation level.",
					},
				},
				Required: []string{"owner"},
			},
		},
		[]scopes.Scope{scopes.Repo},
		func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
			owner, err := RequiredParam[string](args, "owner")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			repo, err := OptionalParam[string](args, "repo")
			if err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}
			// when not provided, default to the .github repository
			// this will query discussion categories at the organisation level
			if repo == "" {
				repo = ".github"
			}

			client, err := deps.GetGQLClient(ctx)
			if err != nil {
				return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
			}

			var q struct {
				Repository struct {
					DiscussionCategories struct {
						Nodes []struct {
							ID   githubv4.ID
							Name githubv4.String
						}
						PageInfo struct {
							HasNextPage     githubv4.Boolean
							HasPreviousPage githubv4.Boolean
							StartCursor     githubv4.String
							EndCursor       githubv4.String
						}
						TotalCount int
					} `graphql:"discussionCategories(first: $first)"`
				} `graphql:"repository(owner: $owner, name: $repo)"`
			}
			vars := map[string]any{
				"owner": githubv4.String(owner),
				"repo":  githubv4.String(repo),
				"first": githubv4.Int(25),
			}
			if err := client.Query(ctx, &q, vars); err != nil {
				return utils.NewToolResultError(err.Error()), nil, nil
			}

			var categories []map[string]string
			for _, c := range q.Repository.DiscussionCategories.Nodes {
				categories = append(categories, map[string]string{
					"id":   fmt.Sprint(c.ID),
					"name": string(c.Name),
				})
			}

			// Create response with pagination info
			response := map[string]any{
				"categories": categories,
				"pageInfo": map[string]any{
					"hasNextPage":     q.Repository.DiscussionCategories.PageInfo.HasNextPage,
					"hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage,
					"startCursor":     string(q.Repository.DiscussionCategories.PageInfo.StartCursor),
					"endCursor":       string(q.Repository.DiscussionCategories.PageInfo.EndCursor),
				},
				"totalCount": q.Repository.DiscussionCategories.TotalCount,
			}

			out, err := json.Marshal(response)
			if err != nil {
				return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
			}
			result := utils.NewToolResultText(string(out))
			// Discussion categories are repo-defined structural metadata
			// (trusted); confidentiality follows repo visibility.
			result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
			return result, nil, nil
		},
	)
}
