// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package webtests

import (
	"net/http"
	"net/url"
	"testing"

	"code.vikunja.io/api/pkg/db"
	"code.vikunja.io/api/pkg/models"
	"code.vikunja.io/api/pkg/modules/auth"
	"code.vikunja.io/api/pkg/user"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestHumaTaskComment ports the v1 webtest coverage (TestTaskComments +
// TestTaskCommentIDOR) to /api/v2, plus v2-specific HTTP assertions (status
// codes, ETag). It re-proves the full permission/sharing matrix independently
// because the v1 routes and their tests will be removed.
//
// The crux of the author-only rule: across tasks 15–26, testuser1 is granted
// access through every share kind but never authored the comments (user 5/6
// did), so a 403 there exercises authorship rather than plain access denial.
func TestHumaTaskComment(t *testing.T) {
	// task 1 belongs to project 1, owned by testuser1.
	onTask1 := webHandlerTestV2{
		user:     &testuser1,
		basePath: "/api/v2/tasks/1/comments",
		idParam:  "commentid",
		t:        t,
	}
	require.NoError(t, onTask1.ensureEnv())
	// onTaskAs reuses the one Echo instance (and its single fixture load) for a
	// different task. v2 does not reload fixtures per request, so the subtests
	// are ordered to avoid clobbering each other's rows.
	onTaskAs := func(taskID string, u *user.User) *webHandlerTestV2 {
		return &webHandlerTestV2{
			user:     u,
			basePath: "/api/v2/tasks/" + taskID + "/comments",
			idParam:  "commentid",
			t:        t,
			e:        onTask1.e,
		}
	}
	// task 35 also belongs to testuser1.
	onTask35 := onTaskAs("35", &testuser1)
	// task 2 also belongs to project 1; used for the wrong-parent negative.
	onTask2 := onTaskAs("2", &testuser1)
	// user6 has no access to project 1, so it is neither author nor writer on
	// task 1's comment 1 — used for the no-access forbidden negatives.
	asUser6 := onTaskAs("1", &testuser6)

	t.Run("ReadAll", func(t *testing.T) {
		t.Run("Normal", func(t *testing.T) {
			rec, err := onTask1.testReadAllWithUser(nil, nil)
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `Lorem Ipsum Dolor Sit Amet`)
			// comments from other tasks must not leak in.
			assert.NotContains(t, rec.Body.String(), `comment 2`)
		})
		t.Run("Link share author resolves", func(t *testing.T) {
			rec, err := onTask35.testReadAllWithUser(nil, nil)
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `comment 15`)
			assert.Contains(t, rec.Body.String(), `comment 17`)
		})
		t.Run("order_by desc", func(t *testing.T) {
			rec, err := onTask35.testReadAllWithUser(url.Values{"order_by": []string{"desc"}}, nil)
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `comment 15`)
		})
		t.Run("Search filter", func(t *testing.T) {
			// Mirrors the v1 model ReadAll search test: search is case-insensitive.
			rec, err := onTask35.testReadAllWithUser(url.Values{"q": []string{"COMMENT 15"}}, nil)
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `comment 15`)
			assert.NotContains(t, rec.Body.String(), `comment 17`)
		})
		t.Run("Forbidden", func(t *testing.T) {
			// user6 cannot read task 1.
			_, err := asUser6.testReadAllWithUser(nil, nil)
			require.Error(t, err)
			assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
		})
	})

	t.Run("ReadOne", func(t *testing.T) {
		t.Run("Normal", func(t *testing.T) {
			rec, err := onTask1.testReadOneWithUser(nil, map[string]string{"commentid": "1"})
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `Lorem Ipsum Dolor Sit Amet`)
			assert.Contains(t, rec.Body.String(), `"id":1`)
			assert.Contains(t, rec.Body.String(), `"max_permission":`)
			assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
		})
		t.Run("Nonexisting", func(t *testing.T) {
			_, err := onTask1.testReadOneWithUser(nil, map[string]string{"commentid": "9999"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
		})
		t.Run("Comment from another task", func(t *testing.T) {
			// comment 1 belongs to task 1; reading it under task 2 must 404.
			_, err := onTask2.testReadOneWithUser(nil, map[string]string{"commentid": "1"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
		})
		t.Run("IDOR via accessible task", func(t *testing.T) {
			// Port of v1 TestTaskCommentIDOR: comment 18 belongs to task 34
			// (owned by user 13, inaccessible to testuser1). Task 1 is
			// accessible to testuser1. Requesting it under task 1 must 404 with
			// the comment-does-not-exist code (not leak the inaccessible row).
			_, err := onTask1.testReadOneWithUser(nil, map[string]string{"commentid": "18"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskCommentDoesNotExist)
		})
		t.Run("Forbidden", func(t *testing.T) {
			_, err := asUser6.testReadOneWithUser(nil, map[string]string{"commentid": "1"})
			require.Error(t, err)
			assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
		})
	})

	t.Run("Create", func(t *testing.T) {
		t.Run("Normal", func(t *testing.T) {
			rec, err := onTask1.testCreateWithUser(nil, nil, `{"comment":"A brand new comment"}`)
			require.NoError(t, err)
			assert.Equal(t, http.StatusCreated, rec.Code)
			assert.Contains(t, rec.Body.String(), `"comment":"A brand new comment"`)
			// author is set server-side from the authenticated user.
			assert.Contains(t, rec.Body.String(), `"username":"user1"`)
		})
		t.Run("Nonexisting task", func(t *testing.T) {
			// Creating a comment on a task that does not exist surfaces the
			// task-does-not-exist domain error as a 404.
			onMissing := onTaskAs("9999", &testuser1)
			_, err := onMissing.testCreateWithUser(nil, nil, `{"comment":"Lorem Ipsum"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
		})
		t.Run("Forbidden", func(t *testing.T) {
			// user6 has no write access to task 1.
			_, err := asUser6.testCreateWithUser(nil, nil, `{"comment":"Nope"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
		})

		// Permission matrix: CREATE requires write access to the task, so
		// read-only shares are forbidden while write/admin shares are allowed.
		// These mirror v1 TestTaskComments/Create/Permissions_check exactly.
		t.Run("Permissions check", func(t *testing.T) {
			// task 34 is owned by user 13 — testuser1 has no access at all.
			t.Run("Forbidden", func(t *testing.T) {
				_, err := onTaskAs("34", &testuser1).testCreateWithUser(nil, nil, `{"comment":"Lorem Ipsum"}`)
				require.Error(t, err)
				assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
			})

			// Read-only shares: create forbidden.
			forbiddenCreate := map[string]string{
				"Shared Via Team readonly":                "15",
				"Shared Via User readonly":                "18",
				"Shared Via Parent Project Team readonly": "21",
				"Shared Via Parent Project User readonly": "24",
			}
			for name, taskID := range forbiddenCreate {
				t.Run(name, func(t *testing.T) {
					_, err := onTaskAs(taskID, &testuser1).testCreateWithUser(nil, nil, `{"comment":"Lorem Ipsum"}`)
					require.Error(t, err)
					assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
				})
			}

			// Write/admin shares: create allowed (8 positive cases).
			allowedCreate := map[string]string{
				"Shared Via Team write":                "16",
				"Shared Via Team admin":                "17",
				"Shared Via User write":                "19",
				"Shared Via User admin":                "20",
				"Shared Via Parent Project Team write": "22",
				"Shared Via Parent Project Team admin": "23",
				"Shared Via Parent Project User write": "25",
				"Shared Via Parent Project User admin": "26",
			}
			for name, taskID := range allowedCreate {
				t.Run(name, func(t *testing.T) {
					rec, err := onTaskAs(taskID, &testuser1).testCreateWithUser(nil, nil, `{"comment":"Lorem Ipsum"}`)
					require.NoError(t, err)
					assert.Equal(t, http.StatusCreated, rec.Code)
					assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
				})
			}
		})

		t.Run("Link Share", func(t *testing.T) {
			// Port of v1 TestTaskComments/Create/Link_Share: link share id 2 has
			// write access to project 2 (task 13). The created comment is
			// attributed to the synthetic link-share user (author_id == -2,
			// i.e. share.ID * -1). Driven through the full Huma stack with a
			// real link-share JWT so v2's auth bridging is exercised too.
			token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{
				ID:          2,
				Hash:        "test2",
				ProjectID:   2,
				Permission:  models.PermissionWrite,
				SharingType: models.SharingTypeWithoutPassword,
				SharedByID:  1,
			})
			require.NoError(t, err)
			rec := humaRequest(t, onTask1.e, http.MethodPost, "/api/v2/tasks/13/comments", `{"comment":"Lorem Ipsum"}`, token, "")
			require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
			assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
			db.AssertExists(t, "task_comments", map[string]interface{}{
				"task_id":   13,
				"comment":   "Lorem Ipsum",
				"author_id": -2,
			}, false)
		})
	})

	t.Run("Update", func(t *testing.T) {
		t.Run("Normal", func(t *testing.T) {
			rec, err := onTask1.testUpdateWithUser(nil, map[string]string{"commentid": "1"}, `{"comment":"Edited comment"}`)
			require.NoError(t, err)
			assert.Contains(t, rec.Body.String(), `"comment":"Edited comment"`)
		})
		t.Run("Nonexisting task", func(t *testing.T) {
			// v1 used task 99999 / comment 9999: no access to the task yields
			// the task-does-not-exist error rather than leaking the comment.
			onMissing := onTaskAs("99999", &testuser1)
			_, err := onMissing.testUpdateWithUser(nil, map[string]string{"commentid": "9999"}, `{"comment":"Lorem Ipsum"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
		})
		t.Run("Nonexisting comment on accessible task", func(t *testing.T) {
			// commentid 9999 under task 1 (writable, owned by testuser1):
			// the comment lookup fails after the write check, so this 404s with
			// the comment-does-not-exist code.
			_, err := onTask1.testUpdateWithUser(nil, map[string]string{"commentid": "9999"}, `{"comment":"x"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskCommentDoesNotExist)
		})
		t.Run("Comment from another task", func(t *testing.T) {
			// comment 1 is on task 1; updating it under task 2 must 404.
			_, err := onTask2.testUpdateWithUser(nil, map[string]string{"commentid": "1"}, `{"comment":"x"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
		})
		t.Run("Forbidden no access", func(t *testing.T) {
			// user6 has no access to task 1 at all.
			_, err := asUser6.testUpdateWithUser(nil, map[string]string{"commentid": "1"}, `{"comment":"x"}`)
			require.Error(t, err)
			assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
		})

		// Author-only matrix: even a user WITH write/admin access who is NOT the
		// comment author is forbidden from updating it. testuser1 has access to
		// every share kind below (read/write/admin) but authored none of
		// comments 3–14, so every case must 403. This is the distinctive
		// author-only rule and the heart of the v1 Update/Permissions matrix.
		t.Run("Permissions check (author-only)", func(t *testing.T) {
			cases := map[string]struct {
				task    string
				comment string
			}{
				"Forbidden":                               {"14", "2"},
				"Shared Via Team readonly":                {"15", "3"},
				"Shared Via Team write":                   {"16", "4"},
				"Shared Via Team admin":                   {"17", "5"},
				"Shared Via User readonly":                {"18", "6"},
				"Shared Via User write":                   {"19", "7"},
				"Shared Via User admin":                   {"20", "8"},
				"Shared Via Parent Project Team readonly": {"21", "9"},
				"Shared Via Parent Project Team write":    {"22", "10"},
				"Shared Via Parent Project Team admin":    {"23", "11"},
				"Shared Via Parent Project User readonly": {"24", "12"},
				"Shared Via Parent Project User write":    {"25", "13"},
				"Shared Via Parent Project User admin":    {"26", "14"},
			}
			for name, c := range cases {
				t.Run(name, func(t *testing.T) {
					_, err := onTaskAs(c.task, &testuser1).testUpdateWithUser(nil, map[string]string{"commentid": c.comment}, `{"comment":"Lorem Ipsum"}`)
					require.Error(t, err)
					assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
				})
			}
		})
	})

	t.Run("Delete", func(t *testing.T) {
		t.Run("Nonexisting task", func(t *testing.T) {
			onMissing := onTaskAs("99999", &testuser1)
			_, err := onMissing.testDeleteWithUser(nil, map[string]string{"commentid": "9999"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
		})
		t.Run("Nonexisting comment on accessible task", func(t *testing.T) {
			_, err := onTask1.testDeleteWithUser(nil, map[string]string{"commentid": "9999"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
			assertHandlerErrorCode(t, err, models.ErrCodeTaskCommentDoesNotExist)
		})
		t.Run("Comment from another task", func(t *testing.T) {
			_, err := onTask2.testDeleteWithUser(nil, map[string]string{"commentid": "1"})
			require.Error(t, err)
			assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
		})
		t.Run("Forbidden no access", func(t *testing.T) {
			_, err := asUser6.testDeleteWithUser(nil, map[string]string{"commentid": "1"})
			require.Error(t, err)
			assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
		})

		// Author-only matrix for delete, mirroring the update matrix above:
		// write/admin access is not enough — only the author may delete.
		t.Run("Permissions check (author-only)", func(t *testing.T) {
			cases := map[string]struct {
				task    string
				comment string
			}{
				"Forbidden":                               {"14", "2"},
				"Shared Via Team readonly":                {"15", "3"},
				"Shared Via Team write":                   {"16", "4"},
				"Shared Via Team admin":                   {"17", "5"},
				"Shared Via User readonly":                {"18", "6"},
				"Shared Via User write":                   {"19", "7"},
				"Shared Via User admin":                   {"20", "8"},
				"Shared Via Parent Project Team readonly": {"21", "9"},
				"Shared Via Parent Project Team write":    {"22", "10"},
				"Shared Via Parent Project Team admin":    {"23", "11"},
				"Shared Via Parent Project User readonly": {"24", "12"},
				"Shared Via Parent Project User write":    {"25", "13"},
				"Shared Via Parent Project User admin":    {"26", "14"},
			}
			for name, c := range cases {
				t.Run(name, func(t *testing.T) {
					_, err := onTaskAs(c.task, &testuser1).testDeleteWithUser(nil, map[string]string{"commentid": c.comment})
					require.Error(t, err)
					assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
				})
			}
		})

		t.Run("Normal", func(t *testing.T) {
			// Run last: comment 1 is the author's own, so this succeeds and
			// removes the fixture row used by the read/update cases above.
			rec, err := onTask1.testDeleteWithUser(nil, map[string]string{"commentid": "1"})
			require.NoError(t, err)
			assert.Equal(t, http.StatusNoContent, rec.Code)
			assert.Empty(t, rec.Body.String())
		})
	})
}

func TestHumaTaskComment_ETagReflectsPermission(t *testing.T) {
	// Comment 6 is on task 18 in project 9: user6 owns the project (admin) while
	// user1 has only a read share (users_projects #3). max_permission is folded
	// into the ETag, so the same comment must yield different ETags per caller —
	// else a 304 would serve a stale permission level.
	e, err := setupTestEnv()
	require.NoError(t, err)

	owner := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/18/comments/6", "", humaTokenFor(t, &testuser6), "")
	require.Equal(t, http.StatusOK, owner.Code, "body: %s", owner.Body.String())
	reader := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/18/comments/6", "", humaTokenFor(t, &testuser1), "")
	require.Equal(t, http.StatusOK, reader.Code, "body: %s", reader.Body.String())

	assert.NotEmpty(t, owner.Header().Get("ETag"))
	assert.NotEqual(t, owner.Header().Get("ETag"), reader.Header().Get("ETag"),
		"same comment, different caller permission must produce different ETags")
}
