package comment

import (
	"bytes"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"testing"

	"github.com/cli/cli/v2/api"
	"github.com/cli/cli/v2/internal/browser"
	"github.com/cli/cli/v2/internal/ghrepo"
	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/httpmock"
	"github.com/cli/cli/v2/pkg/iostreams"
	"github.com/google/shlex"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNewCmdComment(t *testing.T) {
	tmpFile := filepath.Join(t.TempDir(), "my-body.md")
	err := os.WriteFile(tmpFile, []byte("a body from file"), 0600)
	require.NoError(t, err)

	tests := []struct {
		name     string
		input    string
		stdin    string
		output   shared.CommentableOptions
		wantsErr bool
		isTTY    bool
	}{
		{
			name:  "no arguments",
			input: "",
			output: shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:     "two arguments",
			input:    "1 2",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:  "pr number",
			input: "1",
			output: shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "pr url",
			input: "https://github.com/OWNER/REPO/pull/12",
			output: shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "pr branch",
			input: "branch-name",
			output: shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "body flag",
			input: "1 --body test",
			output: shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeInline,
				Body:        "test",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "body from stdin",
			input: "1 --body-file -",
			stdin: "this is on standard input",
			output: shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeInline,
				Body:        "this is on standard input",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "body from file",
			input: fmt.Sprintf("1 --body-file '%s'", tmpFile),
			output: shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeInline,
				Body:        "a body from file",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "editor flag",
			input: "1 --editor",
			output: shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeEditor,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "web flag",
			input: "1 --web",
			output: shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeWeb,
				Body:        "",
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "edit last flag",
			input: "1 --edit-last",
			output: shared.CommentableOptions{
				Interactive: true,
				InputType:   shared.InputTypeEditor,
				Body:        "",
				EditLast:    true,
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "edit last flag with create if none",
			input: "1 --edit-last --create-if-none",
			output: shared.CommentableOptions{
				Interactive:  true,
				InputType:    shared.InputTypeEditor,
				Body:         "",
				EditLast:     true,
				CreateIfNone: true,
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:     "delete last flag non-interactive",
			input:    "1 --delete-last",
			isTTY:    false,
			wantsErr: true,
		},
		{
			name:  "delete last flag and pre-confirmation non-interactive",
			input: "1 --delete-last --yes",
			output: shared.CommentableOptions{
				DeleteLast:          true,
				DeleteLastConfirmed: true,
			},
			isTTY:    false,
			wantsErr: false,
		},
		{
			name:  "delete last flag interactive",
			input: "1 --delete-last",
			output: shared.CommentableOptions{
				Interactive: true,
				DeleteLast:  true,
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:  "delete last flag and pre-confirmation interactive",
			input: "1 --delete-last --yes",
			output: shared.CommentableOptions{
				Interactive:         true,
				DeleteLast:          true,
				DeleteLastConfirmed: true,
			},
			isTTY:    true,
			wantsErr: false,
		},
		{
			name:     "delete last flag and pre-confirmation with web flag",
			input:    "1 --delete-last --yes --web",
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "delete last flag and pre-confirmation with editor flag",
			input:    "1 --delete-last --yes --editor",
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "delete last flag and pre-confirmation with body flag",
			input:    "1 --delete-last --yes --body",
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "delete pre-confirmation without delete last flag",
			input:    "1 --yes",
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "body and body-file flags",
			input:    "1 --body 'test' --body-file 'test-file.txt'",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "editor and web flags",
			input:    "1 --editor --web",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "editor and body flags",
			input:    "1 --editor --body test",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "web and body flags",
			input:    "1 --web --body test",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "editor, web, and body flags",
			input:    "1 --editor --web --body test",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
		{
			name:     "create-if-none flag without edit-last",
			input:    "1 --create-if-none",
			output:   shared.CommentableOptions{},
			isTTY:    true,
			wantsErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ios, stdin, _, _ := iostreams.Test()
			isTTY := tt.isTTY
			ios.SetStdoutTTY(isTTY)
			ios.SetStdinTTY(isTTY)
			ios.SetStderrTTY(isTTY)

			if tt.stdin != "" {
				_, _ = stdin.WriteString(tt.stdin)
			}

			f := &cmdutil.Factory{
				IOStreams: ios,
				Browser:   &browser.Stub{},
			}

			argv, err := shlex.Split(tt.input)
			assert.NoError(t, err)

			var gotOpts *shared.CommentableOptions
			cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) error {
				gotOpts = opts
				return nil
			})
			cmd.Flags().BoolP("help", "x", false, "")

			cmd.SetArgs(argv)
			cmd.SetIn(&bytes.Buffer{})
			cmd.SetOut(&bytes.Buffer{})
			cmd.SetErr(&bytes.Buffer{})

			_, err = cmd.ExecuteC()
			if tt.wantsErr {
				assert.Error(t, err)
				return
			}

			assert.NoError(t, err)
			assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
			assert.Equal(t, tt.output.InputType, gotOpts.InputType)
			assert.Equal(t, tt.output.Body, gotOpts.Body)
			assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
			assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
		})
	}
}

func Test_commentRun(t *testing.T) {
	tests := []struct {
		name          string
		input         *shared.CommentableOptions
		emptyComments bool
		comments      api.Comments
		httpStubs     func(*testing.T, *httpmock.Registry)
		stdout        string
		stderr        string
		wantsErr      bool
	}{
		{
			name: "creating new comment with interactive editor succeeds",
			input: &shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",

				InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
				ConfirmSubmitSurvey:   func() (bool, error) { return true, nil },
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "updating last comment with interactive editor fails if there are no comments and decline prompt to create",
			input: &shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
				EditLast:    true,

				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
				ConfirmCreateIfNoneSurvey: func() (bool, error) { return false, nil },
			},
			emptyComments: true,
			wantsErr:      true,
			stdout:        "no comments found for current user",
		},
		{
			name: "updating last comment with interactive editor succeeds if there are comments",
			input: &shared.CommentableOptions{
				Interactive: true,
				InputType:   0,
				Body:        "",
				EditLast:    true,

				InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
				ConfirmSubmitSurvey:   func() (bool, error) { return true, nil },
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentUpdate(t, reg)
			},
			emptyComments: false,
			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
		},
		{
			name: "updating last comment with interactive editor creates new comment if there are no comments but --create-if-none",
			input: &shared.CommentableOptions{
				Interactive:  true,
				InputType:    0,
				Body:         "",
				EditLast:     true,
				CreateIfNone: true,

				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
				ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil },
				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			emptyComments: true,
			stderr:        "No comments found. Creating a new comment.\n",
			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "creating new comment with non-interactive web opens pull request in browser focusing on new comment",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeWeb,
				Body:        "",

				OpenInBrowser: func(string) error { return nil },
			},
			emptyComments: true,
			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
		},
		{
			name: "updating last comment with non-interactive web opens pull request in browser focusing on the last comment",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeWeb,
				Body:        "",
				EditLast:    true,

				OpenInBrowser: func(u string) error {
					assert.Contains(t, u, "#issuecomment-111")
					return nil
				},
			},
			emptyComments: false,
			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
		},
		{
			name: "updating last comment with non-interactive web errors because there are no comments",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeWeb,
				Body:        "",
				EditLast:    true,
			},
			emptyComments: true,
			wantsErr:      true,
			stdout:        "no comments found for current user",
		},
		{
			name: "creating new comment with non-interactive editor succeeds",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeEditor,
				Body:        "",

				EditSurvey: func(string) (string, error) { return "comment body", nil },
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "updating last comment with non-interactive editor fails if there are no comments",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeEditor,
				Body:        "",
				EditLast:    true,

				EditSurvey: func(string) (string, error) { return "comment body", nil },
			},
			emptyComments: true,
			wantsErr:      true,
			stdout:        "no comments found for current user",
		},
		{
			name: "updating last comment with non-interactive editor succeeds if there are comments",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeEditor,
				Body:        "",
				EditLast:    true,

				EditSurvey: func(string) (string, error) { return "comment body", nil },
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentUpdate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
		},
		{
			name: "updating last comment with non-interactive editor creates new comment if there are no comments but --create-if-none",
			input: &shared.CommentableOptions{
				Interactive:  false,
				InputType:    shared.InputTypeEditor,
				Body:         "",
				EditLast:     true,
				CreateIfNone: true,

				EditSurvey: func(string) (string, error) { return "comment body", nil },
			},
			emptyComments: true,
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "creating new comment with non-interactive inline succeeds if comment body is provided",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeInline,
				Body:        "comment body",
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "updating last comment with non-interactive inline succeeds if there are comments and comment body is provided",
			input: &shared.CommentableOptions{
				Interactive: false,
				InputType:   shared.InputTypeInline,
				Body:        "comment body",
				EditLast:    true,
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentUpdate(t, reg)
			},
			emptyComments: false,
			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
		},
		{
			name: "updating last comment with non-interactive inline creates new comment if there are no comments but --create-if-none",
			input: &shared.CommentableOptions{
				Interactive:  false,
				InputType:    shared.InputTypeInline,
				Body:         "comment body",
				EditLast:     true,
				CreateIfNone: true,
			},
			emptyComments: true,
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentCreate(t, reg)
			},
			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
		},
		{
			name: "deleting last comment non-interactively without any comment",
			input: &shared.CommentableOptions{
				Interactive: false,
				DeleteLast:  true,
			},
			emptyComments: true,
			wantsErr:      true,
			stdout:        "no comments found for current user",
		},
		{
			name: "deleting last comment interactively without any comment",
			input: &shared.CommentableOptions{
				Interactive: true,
				DeleteLast:  true,
			},
			emptyComments: true,
			wantsErr:      true,
			stdout:        "no comments found for current user",
		},
		{
			name: "deleting last comment non-interactively and pre-confirmed",
			input: &shared.CommentableOptions{
				Interactive:         false,
				DeleteLast:          true,
				DeleteLastConfirmed: true,
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentDelete(t, reg)
			},
			stderr: "Comment deleted\n",
		},
		{
			name: "deleting last comment interactively and pre-confirmed",
			input: &shared.CommentableOptions{
				Interactive:         true,
				DeleteLast:          true,
				DeleteLastConfirmed: true,
			},
			comments: api.Comments{Nodes: []api.Comment{
				{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
			}},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentDelete(t, reg)
			},
			stderr: "Comment deleted\n",
		},
		{
			name: "deleting last comment interactively and confirmed",
			input: &shared.CommentableOptions{
				Interactive: true,
				DeleteLast:  true,

				ConfirmDeleteLastComment: func(body string) (bool, error) {
					if body != "comment body" {
						return false, errors.New("unexpected comment body")
					}
					return true, nil
				},
			},
			comments: api.Comments{Nodes: []api.Comment{
				{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
			}},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentDelete(t, reg)
			},
			stdout: "! Deleted comments cannot be recovered.\n",
			stderr: "Comment deleted\n",
		},
		{
			name: "deleting last comment interactively and confirmation declined",
			input: &shared.CommentableOptions{
				Interactive: true,
				DeleteLast:  true,

				ConfirmDeleteLastComment: func(body string) (bool, error) {
					if body != "comment body" {
						return false, errors.New("unexpected comment body")
					}
					return true, nil
				},
			},
			comments: api.Comments{Nodes: []api.Comment{
				{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
			}},
			wantsErr: true,
			stdout:   "deletion not confirmed",
		},
		{
			name: "deleting last comment interactively and confirmed with long comment body",
			input: &shared.CommentableOptions{
				Interactive: true,
				DeleteLast:  true,

				ConfirmDeleteLastComment: func(body string) (bool, error) {
					if body != "Lorem ipsum dolor sit amet, consectet lo..." {
						return false, errors.New("unexpected comment body")
					}
					return true, nil
				},
			},
			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
				mockCommentDelete(t, reg)
			},
			comments: api.Comments{Nodes: []api.Comment{
				{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
			}},
			wantsErr: false,
			stdout:   "! Deleted comments cannot be recovered.\n",
			stderr:   "Comment deleted\n",
		},
	}
	for _, tt := range tests {
		ios, _, stdout, stderr := iostreams.Test()
		ios.SetStdoutTTY(true)
		ios.SetStdinTTY(true)
		ios.SetStderrTTY(true)

		reg := &httpmock.Registry{}
		defer reg.Verify(t)
		if tt.httpStubs != nil {
			tt.httpStubs(t, reg)
		}

		httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }

		tt.input.IO = ios
		tt.input.HttpClient = httpClient

		comments := api.Comments{Nodes: []api.Comment{
			{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
			{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
		}}
		if tt.emptyComments {
			comments.Nodes = []api.Comment{}
		} else if len(tt.comments.Nodes) > 0 {
			comments = tt.comments
		}

		tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
			return &api.PullRequest{
				Number:   123,
				URL:      "https://github.com/OWNER/REPO/pull/123",
				Comments: comments,
			}, ghrepo.New("OWNER", "REPO"), nil
		}

		t.Run(tt.name, func(t *testing.T) {
			err := shared.CommentableRun(tt.input)
			if tt.wantsErr {
				assert.Error(t, err)
				assert.Equal(t, tt.stderr, stderr.String())
				return
			}
			assert.NoError(t, err)
			assert.Equal(t, tt.stdout, stdout.String())
			assert.Equal(t, tt.stderr, stderr.String())
		})
	}
}

func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
	reg.Register(
		httpmock.GraphQL(`mutation CommentCreate\b`),
		httpmock.GraphQLMutation(`
		{ "data": { "addComment": { "commentEdge": { "node": {
			"url": "https://github.com/OWNER/REPO/pull/123#issuecomment-456"
		} } } } }`,
			func(inputs map[string]interface{}) {
				assert.Equal(t, "comment body", inputs["body"])
			}),
	)
}

func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
	reg.Register(
		httpmock.GraphQL(`mutation CommentUpdate\b`),
		httpmock.GraphQLMutation(`
		{ "data": { "updateIssueComment": { "issueComment": {
			"url": "https://github.com/OWNER/REPO/pull/123#issuecomment-111"
		} } } }`,
			func(inputs map[string]interface{}) {
				assert.Equal(t, "id1", inputs["id"])
				assert.Equal(t, "comment body", inputs["body"])
			}),
	)
}

func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
	reg.Register(
		httpmock.GraphQL(`mutation CommentDelete\b`),
		httpmock.GraphQLMutation(`
		{ "data": { "deleteIssueComment": {} } }`,
			func(inputs map[string]interface{}) {
				assert.Equal(t, "id1", inputs["id"])
			},
		),
	)
}
