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

package integration

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

	auth_model "code.gitea.io/gitea/models/auth"
	issues_model "code.gitea.io/gitea/models/issues"
	project_model "code.gitea.io/gitea/models/project"
	repo_model "code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/unittest"
	user_model "code.gitea.io/gitea/models/user"
	api "code.gitea.io/gitea/modules/structs"
	issue_service "code.gitea.io/gitea/services/issue"
	projects_service "code.gitea.io/gitea/services/projects"
	"code.gitea.io/gitea/tests"

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

func TestAPIProjects(t *testing.T) {
	defer tests.PrepareTestEnv(t)()
	t.Run("ListProjects", testAPIListProjects)
	t.Run("GetProject", testAPIGetProject)
	t.Run("CreateProject", testAPICreateProject)
	t.Run("UpdateProject", testAPIUpdateProject)
	t.Run("ChangeProjectStatus", testAPIChangeProjectStatus)
	t.Run("DeleteProject", testAPIDeleteProject)
	t.Run("ListProjectColumns", testAPIListProjectColumns)
	t.Run("CreateProjectColumn", testAPICreateProjectColumn)
	t.Run("UpdateProjectColumn", testAPIUpdateProjectColumn)
	t.Run("DeleteProjectColumn", testAPIDeleteProjectColumn)
	t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn)
	t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn)
	t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues)
	t.Run("MoveProjectIssue", testAPIMoveProjectIssue)
	t.Run("Permissions", testAPIProjectPermissions)
}

func testAPIMoveProjectIssue(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})

	project := &project_model.Project{
		Title:        "Project for MoveIssue",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	colA := &project_model.Column{Title: "A", ProjectID: project.ID, CreatorID: owner.ID}
	err = project_model.NewColumn(t.Context(), colA)
	assert.NoError(t, err)
	colB := &project_model.Column{Title: "B", ProjectID: project.ID, CreatorID: owner.ID}
	err = project_model.NewColumn(t.Context(), colB)
	assert.NoError(t, err)

	err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
	assert.NoError(t, err)
	err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID})
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	req := NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
		&api.MoveProjectIssueOption{ColumnID: colB.ID},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: issueA.ID})
	assert.Equal(t, colB.ID, pi.ProjectColumnID)

	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
		&api.MoveProjectIssueOption{ColumnID: 99999},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)

	issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueB.ID),
		&api.MoveProjectIssueOption{ColumnID: colA.ID},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIListProjects(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	// Test listing all projects
	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects", owner.Name, repo.Name).
		AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var projects []*api.Project
	DecodeJSON(t, resp, &projects)

	// Test state filter - open
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=open", owner.Name, repo.Name).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &projects)
	for _, project := range projects {
		assert.Equal(t, api.StateOpen, project.State, "Project should be open")
	}

	// Test state filter - all
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=all", owner.Name, repo.Name).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &projects)

	// Test pagination
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?page=1&limit=5", owner.Name, repo.Name).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusOK)
}

func testAPIGetProject(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project
	project := &project_model.Project{
		Title:        "Test Project for API",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	// Test getting the project
	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var apiProject api.Project
	DecodeJSON(t, resp, &apiProject)
	assert.Equal(t, project.Title, apiProject.Title)
	assert.Equal(t, project.ID, apiProject.ID)
	assert.Equal(t, repo.ID, apiProject.RepoID)
	assert.NotEmpty(t, apiProject.HTMLURL)

	// Test getting non-existent project
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPICreateProject(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test creating a project
	req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
		Title:        "API Created Project",
		Description:  "This is a test project created via API",
		TemplateType: "basic_kanban",
		CardType:     "images_and_text",
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusCreated)

	var project api.Project
	DecodeJSON(t, resp, &project)
	assert.Equal(t, "API Created Project", project.Title)
	assert.Equal(t, "This is a test project created via API", project.Description)
	assert.Equal(t, "basic_kanban", project.TemplateType)
	assert.Equal(t, "images_and_text", project.CardType)
	assert.Equal(t, api.StateOpen, project.State)

	// Test creating with minimal data
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
		Title: "Minimal Project",
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusCreated)

	var minimalProject api.Project
	DecodeJSON(t, resp, &minimalProject)
	assert.Equal(t, "Minimal Project", minimalProject.Title)

	// Test creating without authentication
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
		Title: "Unauthorized Project",
	})
	MakeRequest(t, req, http.StatusUnauthorized)

	// Test creating with invalid data (empty title)
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
		Title: "",
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)
}

func testAPIUpdateProject(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project
	project := &project_model.Project{
		Title:        "Project to Update",
		Description:  "Original description",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test updating project title and description
	newTitle := "Updated Project Title"
	newDesc := "Updated description"
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		Title:       &newTitle,
		Description: &newDesc,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var updatedProject api.Project
	DecodeJSON(t, resp, &updatedProject)
	assert.Equal(t, newTitle, updatedProject.Title)
	assert.Equal(t, newDesc, updatedProject.Description)

	// Test updating non-existent project
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{
		Title: &newTitle,
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIChangeProjectStatus(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	project := &project_model.Project{
		Title:        "Project to Close",
		Description:  "Project to close and reopen",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	closed := api.StateClosed
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		State: &closed,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var updatedProject api.Project
	DecodeJSON(t, resp, &updatedProject)
	assert.Equal(t, api.StateClosed, updatedProject.State)
	assert.NotNil(t, updatedProject.ClosedAt)

	open := api.StateOpen
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		State: &open,
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)

	DecodeJSON(t, resp, &updatedProject)
	assert.Equal(t, api.StateOpen, updatedProject.State)

	bogus := api.StateType("reopen")
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		State: &bogus,
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)
}

func testAPIDeleteProject(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project
	project := &project_model.Project{
		Title:        "Project to Delete",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test deleting the project
	req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	// Test deleting non-existent project (including the one we just deleted)
	req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIListProjectColumns(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project
	project := &project_model.Project{
		Title:        "Project for Columns Test",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	// Create test columns
	for i := 1; i <= 3; i++ {
		column := &project_model.Column{
			Title:     fmt.Sprintf("Column %d", i),
			ProjectID: project.ID,
			CreatorID: owner.ID,
		}
		err = project_model.NewColumn(t.Context(), column)
		assert.NoError(t, err)
	}

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	// Test listing all columns — X-Total-Count must equal 3
	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var columns []*api.ProjectColumn
	DecodeJSON(t, resp, &columns)
	assert.Len(t, columns, 3)
	assert.Equal(t, "Column 1", columns[0].Title)
	assert.Equal(t, "Column 2", columns[1].Title)
	assert.Equal(t, "Column 3", columns[2].Title)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))

	// Test pagination: page 1 with limit 2 returns first 2 columns, total count still 3
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=1&limit=2", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &columns)
	assert.Len(t, columns, 2)
	assert.Equal(t, "Column 1", columns[0].Title)
	assert.Equal(t, "Column 2", columns[1].Title)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))

	// Test pagination: page 2 with limit 2 returns remaining column
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=2&limit=2", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &columns)
	assert.Len(t, columns, 1)
	assert.Equal(t, "Column 3", columns[0].Title)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))

	// Test listing columns for non-existent project
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPICreateProjectColumn(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project
	project := &project_model.Project{
		Title:        "Project for Column Creation",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test creating a column with color
	req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
		Title: "New Column",
		Color: "#FF5733",
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusCreated)

	var column api.ProjectColumn
	DecodeJSON(t, resp, &column)
	assert.Equal(t, "New Column", column.Title)
	assert.Equal(t, "#FF5733", column.Color)
	assert.Equal(t, project.ID, column.ProjectID)

	// Test creating a column without color
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
		Title: "Simple Column",
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusCreated)

	DecodeJSON(t, resp, &column)
	assert.Equal(t, "Simple Column", column.Title)

	// Test creating with empty title
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
		Title: "",
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)

	// Test creating for non-existent project
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name), &api.CreateProjectColumnOption{
		Title: "Orphan Column",
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUpdateProjectColumn(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project and column
	project := &project_model.Project{
		Title:        "Project for Column Update",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)
	column := &project_model.Column{
		Title:     "Original Column",
		ProjectID: project.ID,
		CreatorID: owner.ID,
		Color:     "#000000",
	}
	err = project_model.NewColumn(t.Context(), column)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test updating column title
	newTitle := "Updated Column"
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
		Title: &newTitle,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var updatedColumn api.ProjectColumn
	DecodeJSON(t, resp, &updatedColumn)
	assert.Equal(t, newTitle, updatedColumn.Title)

	// Test updating column color
	newColor := "#FF0000"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
		Color: &newColor,
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)

	DecodeJSON(t, resp, &updatedColumn)
	assert.Equal(t, newColor, updatedColumn.Color)

	// Test updating non-existent column
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999", owner.Name, repo.Name, project.ID), &api.EditProjectColumnOption{
		Title: &newTitle,
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIDeleteProjectColumn(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})

	// Create a test project and column
	project := &project_model.Project{
		Title:        "Project for Column Deletion",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)
	column := &project_model.Column{
		Title:     "Column to Delete",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), column)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test deleting the column
	req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	// Test deleting non-existent column (including the one we just deleted)
	req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIAddIssueToProjectColumn(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})

	// Create a test project and column
	project := &project_model.Project{
		Title:        "Project for Issue Assignment",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	column1 := &project_model.Column{
		Title:     "Column 1",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), column1)
	assert.NoError(t, err)

	column2 := &project_model.Column{
		Title:     "Column 2",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), column2)
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Test adding issue to column
	req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, issue.ID), nil).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	// Verify issue is in the column
	projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
		ProjectID: project.ID,
		IssueID:   issue.ID,
	})
	assert.Equal(t, column1.ID, projectIssue.ProjectColumnID)

	// Test moving issue to another column
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	// Verify issue moved to new column
	projectIssue = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
		ProjectID: project.ID,
		IssueID:   issue.ID,
	})
	assert.Equal(t, column2.ID, projectIssue.ProjectColumnID)

	// Test adding same issue to same column (should be idempotent)
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	// Test adding non-existent issue
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)

	// Test adding to non-existent column
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIListProjectColumnIssues(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
	issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})

	project := &project_model.Project{
		Title:        "Project for Column Issues",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	columnA := &project_model.Column{
		Title:     "Column A",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), columnA)
	assert.NoError(t, err)

	columnB := &project_model.Column{
		Title:     "Column B",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), columnB)
	assert.NoError(t, err)

	// Place issueA in columnA, issueB in columnB.
	err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
	assert.NoError(t, err)
	err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnA, map[int64]int64{0: issueA.ID})
	assert.NoError(t, err)

	err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{project.ID})
	assert.NoError(t, err)
	err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnB, map[int64]int64{0: issueB.ID})
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	// Column A should contain only issueA.
	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
		AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var issuesA []api.Issue
	DecodeJSON(t, resp, &issuesA)
	assert.Len(t, issuesA, 1)
	assert.Equal(t, issueA.ID, issuesA[0].ID)

	// Column B should contain only issueB.
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnB.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var issuesB []api.Issue
	DecodeJSON(t, resp, &issuesB)
	assert.Len(t, issuesB, 1)
	assert.Equal(t, issueB.ID, issuesB[0].ID)

	// Close issueA, then exercise the new state= query parameter.
	assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))

	// Default (state omitted) -> open only -> columnA returns nothing.
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var gotOpenOnly []api.Issue
	DecodeJSON(t, resp, &gotOpenOnly)
	assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA")

	// state=closed -> returns issueA.
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var gotClosed []api.Issue
	DecodeJSON(t, resp, &gotClosed)
	assert.Len(t, gotClosed, 1)
	assert.Equal(t, issueA.ID, gotClosed[0].ID)

	// state=all -> returns issueA.
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var gotAll []api.Issue
	DecodeJSON(t, resp, &gotAll)
	assert.Len(t, gotAll, 1)

	// And the columns endpoint must populate num_issues / num_open_issues /
	// num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open.
	req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
		AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var listed []*api.ProjectColumn
	DecodeJSON(t, resp, &listed)

	byID := map[int64]*api.ProjectColumn{}
	for _, c := range listed {
		byID[c.ID] = c
	}
	if assert.NotNil(t, byID[columnA.ID]) {
		assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total")
		assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open")
		assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed")
	}
	if assert.NotNil(t, byID[columnB.ID]) {
		assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total")
		assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open")
		assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed")
	}
}

func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})

	project := &project_model.Project{
		Title:        "Project for Issue Removal",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	column := &project_model.Column{
		Title:     "Column for Issue Removal",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), column)
	assert.NoError(t, err)

	otherColumn := &project_model.Column{
		Title:     "Other Column",
		ProjectID: project.ID,
		CreatorID: owner.ID,
	}
	err = project_model.NewColumn(t.Context(), otherColumn)
	assert.NoError(t, err)

	err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID})
	assert.NoError(t, err)

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// Removing via a column the issue does not live in must 404 and not detach the issue
	req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, otherColumn.ID, issue.ID), nil).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
	unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
		ProjectID: project.ID,
		IssueID:   issue.ID,
	})

	req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil).
		AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{
		ProjectID: project.ID,
		IssueID:   issue.ID,
	})
}

func testAPIProjectPermissions(t *testing.T) {
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})

	// Create a test project
	project := &project_model.Project{
		Title:        "Permission Test Project",
		RepoID:       repo.ID,
		Type:         project_model.TypeRepository,
		CreatorID:    owner.ID,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), project)
	assert.NoError(t, err)

	ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
	nonCollaboratorToken := getUserToken(t, nonCollaborator.Name, auth_model.AccessTokenScopeWriteIssue)

	// Owner should be able to read
	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
		AddTokenAuth(ownerToken)
	MakeRequest(t, req, http.StatusOK)

	// Owner should be able to update
	newTitle := "Updated by Owner"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		Title: &newTitle,
	}).AddTokenAuth(ownerToken)
	MakeRequest(t, req, http.StatusOK)

	// Non-collaborator should not be able to update
	anotherTitle := "Updated by Non-collaborator"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
		Title: &anotherTitle,
	}).AddTokenAuth(nonCollaboratorToken)
	MakeRequest(t, req, http.StatusForbidden)

	// Non-collaborator should not be able to delete
	req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
		AddTokenAuth(nonCollaboratorToken)
	MakeRequest(t, req, http.StatusForbidden)
}
