// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package integration

import (
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"testing"

	"gitea.dev/models/db"
	issues_model "gitea.dev/models/issues"
	project_model "gitea.dev/models/project"
	repo_model "gitea.dev/models/repo"
	"gitea.dev/models/unit"
	"gitea.dev/models/unittest"
	user_model "gitea.dev/models/user"
	project "gitea.dev/services/projects"
	"gitea.dev/tests"

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

func TestPrivateRepoProject(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	// not logged in user
	req := NewRequest(t, "GET", "/user31/-/projects")
	MakeRequest(t, req, http.StatusNotFound)

	sess := loginUser(t, "user1")
	req = NewRequest(t, "GET", "/user31/-/projects")
	sess.MakeRequest(t, req, http.StatusOK)
}

func TestMoveRepoProjectColumns(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})

	projectsUnit := repo2.MustGetUnit(t.Context(), unit.TypeProjects)
	assert.True(t, projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo))

	project1 := project_model.Project{
		Title:        "new created project",
		RepoID:       repo2.ID,
		Type:         project_model.TypeRepository,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), &project1)
	assert.NoError(t, err)

	for i := range 3 {
		err = project_model.NewColumn(t.Context(), &project_model.Column{
			Title:     fmt.Sprintf("column %d", i+1),
			ProjectID: project1.ID,
		})
		assert.NoError(t, err)
	}

	columns, err := project1.GetColumns(t.Context())
	assert.NoError(t, err)
	assert.Len(t, columns, 3)
	assert.EqualValues(t, 0, columns[0].Sorting)
	assert.EqualValues(t, 1, columns[1].Sorting)
	assert.EqualValues(t, 2, columns[2].Sorting)

	sess := loginUser(t, "user1")
	req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID))
	sess.MakeRequest(t, req, http.StatusOK)

	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move", repo2.FullName(), project1.ID), map[string]any{
		"columns": []map[string]any{
			{"columnID": columns[1].ID, "sorting": 0},
			{"columnID": columns[2].ID, "sorting": 1},
			{"columnID": columns[0].ID, "sorting": 2},
		},
	})
	sess.MakeRequest(t, req, http.StatusOK)

	columnsAfter, err := project1.GetColumns(t.Context())
	assert.NoError(t, err)
	assert.Len(t, columnsAfter, 3)
	assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
	assert.Equal(t, columns[2].ID, columnsAfter[1].ID)
	assert.Equal(t, columns[0].ID, columnsAfter[2].ID)

	assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}

func TestUpdateIssueProject(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	sess := loginUser(t, "user2")

	t.Run("AssignAndRemove", func(t *testing.T) {
		req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
			"id": "1",
		})
		sess.MakeRequest(t, req, http.StatusOK)
		unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})

		req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
			"id": "",
		})
		sess.MakeRequest(t, req, http.StatusOK)
		unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
	})
}

func TestUpdateIssueProjectColumn(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	// fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2)
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
	assert.EqualValues(t, 1, issue.RepoID)

	sess := loginUser(t, "user2")

	t.Run("MoveColumn", func(t *testing.T) {
		req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
			"issue_id": "3",
			"id":       "3",
		})
		sess.MakeRequest(t, req, http.StatusOK)

		pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3})
		assert.EqualValues(t, 3, pi.ProjectColumnID)
	})

	t.Run("InvalidIssueID", func(t *testing.T) {
		req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
			"issue_id": "0",
			"id":       "3",
		})
		sess.MakeRequest(t, req, http.StatusNotFound)
	})

	t.Run("WrongRepo", func(t *testing.T) {
		req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
			"issue_id": "6",
			"id":       "3",
		})
		sess.MakeRequest(t, req, http.StatusNotFound)
	})

	t.Run("WrongProject", func(t *testing.T) {
		project2 := project_model.Project{
			Title:        "second project on repo1",
			RepoID:       1,
			Type:         project_model.TypeRepository,
			TemplateType: project_model.TemplateTypeNone,
		}
		require.NoError(t, project_model.NewProject(t.Context(), &project2))
		require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{
			Title:     "other column",
			ProjectID: project2.ID,
		}))
		columns, err := project2.GetColumns(t.Context())
		require.NoError(t, err)
		require.NotEmpty(t, columns)

		req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
			"issue_id": "1",
			"id":       strconv.FormatInt(columns[0].ID, 10),
		})
		sess.MakeRequest(t, req, http.StatusNotFound)
	})
}

func TestIssueSidebarProjectColumn(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	// fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3)
	sess := loginUser(t, "user2")

	req := NewRequest(t, "GET", "/user2/repo1/issues/4")
	resp := sess.MakeRequest(t, req, http.StatusOK)
	htmlDoc := NewHTMLParser(t, resp.Body)

	cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
	assert.Equal(t, 1, cards.Length())

	title := cards.Find("a span.gt-ellipsis")
	assert.Contains(t, strings.TrimSpace(title.Text()), "First project")

	columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
	assert.Equal(t, 1, columnCombo.Length())

	defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
	assert.Equal(t, 1, defaultItem.Length())

	inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`)
	assert.Equal(t, 1, inProgressItem.Length())
	doneItem := columnCombo.Find(`.menu .item[data-value="3"]`)
	assert.Equal(t, 1, doneItem.Length())

	comboVal, exists := columnCombo.Find("input.combo-value").Attr("value")
	assert.True(t, exists)
	assert.Equal(t, "3", comboVal)

	req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
	sess.MakeRequest(t, req, http.StatusOK)

	req = NewRequest(t, "GET", "/user2/repo1/issues/4")
	resp = sess.MakeRequest(t, req, http.StatusOK)
	htmlDoc = NewHTMLParser(t, resp.Body)

	cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
	assert.Equal(t, 0, cards.Length())
}

// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
	t.Helper()
	ids := make(map[int64]struct{})
	htmlDoc.Find(".issue-card[data-issue]").Each(func(_ int, s *goquery.Selection) {
		idStr, exists := s.Attr("data-issue")
		require.True(t, exists)
		id, err := strconv.ParseInt(idStr, 10, 64)
		require.NoError(t, err)
		ids[id] = struct{}{}
	})
	return ids
}

func TestRepoProjectFilterByMilestone(t *testing.T) {
	// Project 1 is on repo 1 (user2/repo1) and has issues:
	//   issue 1 (milestone_id=0), issue 2 (milestone_id=1), issue 3 (milestone_id=3), issue 5 (milestone_id=0)
	defer tests.PrepareTestEnv(t)()

	sess := loginUser(t, "user2")

	t.Run("NoFilter", func(t *testing.T) {
		req := NewRequest(t, "GET", "/user2/repo1/projects/1")
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		// All issues should be visible
		assert.Contains(t, issueIDs, int64(1))
		assert.Contains(t, issueIDs, int64(2))
		assert.Contains(t, issueIDs, int64(3))
		assert.Contains(t, issueIDs, int64(5))
	})

	t.Run("FilterByMilestone", func(t *testing.T) {
		// milestone_id=1 is "milestone1" (open), only issue 2 has it
		req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=1")
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, int64(2))
		assert.NotContains(t, issueIDs, int64(1))
		assert.NotContains(t, issueIDs, int64(3))
		assert.NotContains(t, issueIDs, int64(5))
	})

	t.Run("FilterByNoMilestone", func(t *testing.T) {
		// milestone=-1 means "no milestone", issues 1 and 5 have no milestone
		req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=-1")
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, int64(1))
		assert.Contains(t, issueIDs, int64(5))
		assert.NotContains(t, issueIDs, int64(2))
		assert.NotContains(t, issueIDs, int64(3))
	})

	t.Run("FilterByClosedMilestone", func(t *testing.T) {
		// milestone_id=3 is "milestone3" (closed), only issue 3 has it
		req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=3")
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, int64(3))
		assert.NotContains(t, issueIDs, int64(1))
		assert.NotContains(t, issueIDs, int64(2))
		assert.NotContains(t, issueIDs, int64(5))
	})
}

func TestOrgProjectFilterByMilestone(t *testing.T) {
	defer tests.PrepareTestEnv(t)()

	// org3 owns repo32 (public) which has issues 16 and 17
	org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
	issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16})
	issue17 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 17})
	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})

	// Create a milestone on repo32 and assign it to issue16
	milestone := &issues_model.Milestone{
		RepoID: repo.ID,
		Name:   "org-test-milestone",
	}
	require.NoError(t, issues_model.NewMilestone(t.Context(), milestone))

	issue16.MilestoneID = milestone.ID
	require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue16, "milestone_id"))

	// Create an org-level project
	project := project_model.Project{
		Title:        "org milestone filter test",
		OwnerID:      org.ID,
		Type:         project_model.TypeOrganization,
		TemplateType: project_model.TemplateTypeBasicKanban,
	}
	require.NoError(t, project_model.NewProject(t.Context(), &project))

	// Add issues to the project
	require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
	require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))

	sess := loginUser(t, "user1")
	projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)

	t.Run("NoFilter", func(t *testing.T) {
		req := NewRequest(t, "GET", projectURL)
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, issue16.ID)
		assert.Contains(t, issueIDs, issue17.ID)
	})

	t.Run("FilterByMilestone", func(t *testing.T) {
		req := NewRequest(t, "GET", fmt.Sprintf("%s?milestone=%d", projectURL, milestone.ID))
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, issue16.ID)
		assert.NotContains(t, issueIDs, issue17.ID)
	})

	t.Run("FilterByNoMilestone", func(t *testing.T) {
		req := NewRequest(t, "GET", projectURL+"?milestone=-1")
		resp := sess.MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, issue17.ID)
		assert.NotContains(t, issueIDs, issue16.ID)
	})

	t.Run("AnonymousAccess", func(t *testing.T) {
		// Anonymous users should be able to view org project boards for public orgs
		// and the milestone filter should work without exposing private repo data
		req := NewRequest(t, "GET", projectURL)
		resp := MakeRequest(t, req, http.StatusOK)
		htmlDoc := NewHTMLParser(t, resp.Body)
		issueIDs := getProjectIssueIDs(t, htmlDoc)
		// repo32 is public, so anonymous users should see its issues
		assert.Contains(t, issueIDs, issue16.ID)
		assert.Contains(t, issueIDs, issue17.ID)

		// Milestone filtering should also work for anonymous users
		req = NewRequest(t, "GET", fmt.Sprintf("%s?milestone=%d", projectURL, milestone.ID))
		resp = MakeRequest(t, req, http.StatusOK)
		htmlDoc = NewHTMLParser(t, resp.Body)
		issueIDs = getProjectIssueIDs(t, htmlDoc)
		assert.Contains(t, issueIDs, issue16.ID)
		assert.NotContains(t, issueIDs, issue17.ID)
	})
}

func TestProjects(t *testing.T) {
	defer tests.PrepareTestEnv(t)()
	t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
		_ = db.TruncateBeans(t.Context(), "project_issue", "issue_assignees")
		_ = db.Insert(t.Context(),
			&project_model.ProjectIssue{ProjectID: 1, IssueID: 1},
			&project_model.ProjectIssue{ProjectID: 1, IssueID: 6},
		)
		_ = db.Insert(t.Context(),
			&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 1},
			&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 10},
			&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 2},
			&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 2},
			&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 4},
		)
		assignees, err := project.LoadIssuesAssigneesForProject(t.Context(), 1)
		require.NoError(t, err)
		require.Len(t, assignees, 4)
		require.Equal(t, "user1", assignees[0].Name)
		require.Equal(t, "user10", assignees[1].Name)
		require.Equal(t, "user2", assignees[2].Name)
		require.Equal(t, "user4", assignees[3].Name)
	})
}
