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

package project

import (
	"context"
	"errors"
	"fmt"
	"regexp"

	"code.gitea.io/gitea/models/db"
	"code.gitea.io/gitea/modules/setting"
	"code.gitea.io/gitea/modules/timeutil"
	"code.gitea.io/gitea/modules/util"

	"xorm.io/builder"
)

type (
	// BoardType is used to represent a project board type
	BoardType uint8

	// CardType is used to represent a project board card type
	CardType uint8

	// BoardList is a list of all project boards in a repository
	BoardList []*Board
)

const (
	// BoardTypeNone is a project board type that has no predefined columns
	BoardTypeNone BoardType = iota

	// BoardTypeBasicKanban is a project board type that has basic predefined columns
	BoardTypeBasicKanban

	// BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
	BoardTypeBugTriage
)

const (
	// CardTypeTextOnly is a project board card type that is text only
	CardTypeTextOnly CardType = iota

	// CardTypeImagesAndText is a project board card type that has images and text
	CardTypeImagesAndText
)

// BoardColorPattern is a regexp witch can validate BoardColor
var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")

// Board is used to represent boards on a project
type Board struct {
	ID      int64 `xorm:"pk autoincr"`
	Title   string
	Default bool   `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
	Sorting int8   `xorm:"NOT NULL DEFAULT 0"`
	Color   string `xorm:"VARCHAR(7)"`

	ProjectID int64 `xorm:"INDEX NOT NULL"`
	CreatorID int64 `xorm:"NOT NULL"`

	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}

// TableName return the real table name
func (Board) TableName() string {
	return "project_board"
}

// NumIssues return counter of all issues assigned to the board
func (b *Board) NumIssues(ctx context.Context) int {
	c, err := db.GetEngine(ctx).Table("project_issue").
		Where("project_id=?", b.ProjectID).
		And("project_board_id=?", b.ID).
		GroupBy("issue_id").
		Cols("issue_id").
		Count()
	if err != nil {
		return 0
	}
	return int(c)
}

func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
	issues := make([]*ProjectIssue, 0, 5)
	if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID).
		And("project_board_id=?", b.ID).
		OrderBy("sorting, id").
		Find(&issues); err != nil {
		return nil, err
	}
	return issues, nil
}

func init() {
	db.RegisterModel(new(Board))
}

// IsBoardTypeValid checks if the project board type is valid
func IsBoardTypeValid(p BoardType) bool {
	switch p {
	case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage:
		return true
	default:
		return false
	}
}

// IsCardTypeValid checks if the project board card type is valid
func IsCardTypeValid(p CardType) bool {
	switch p {
	case CardTypeTextOnly, CardTypeImagesAndText:
		return true
	default:
		return false
	}
}

func createBoardsForProjectsType(ctx context.Context, project *Project) error {
	var items []string

	switch project.BoardType {
	case BoardTypeBugTriage:
		items = setting.Project.ProjectBoardBugTriageType

	case BoardTypeBasicKanban:
		items = setting.Project.ProjectBoardBasicKanbanType
	case BoardTypeNone:
		fallthrough
	default:
		return nil
	}

	board := Board{
		CreatedUnix: timeutil.TimeStampNow(),
		CreatorID:   project.CreatorID,
		Title:       "Backlog",
		ProjectID:   project.ID,
		Default:     true,
	}
	if err := db.Insert(ctx, board); err != nil {
		return err
	}

	if len(items) == 0 {
		return nil
	}

	boards := make([]Board, 0, len(items))

	for _, v := range items {
		boards = append(boards, Board{
			CreatedUnix: timeutil.TimeStampNow(),
			CreatorID:   project.CreatorID,
			Title:       v,
			ProjectID:   project.ID,
		})
	}

	return db.Insert(ctx, boards)
}

// maxProjectColumns max columns allowed in a project, this should not bigger than 127
// because sorting is int8 in database
const maxProjectColumns = 20

// NewBoard adds a new project board to a given project
func NewBoard(ctx context.Context, board *Board) error {
	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
		return fmt.Errorf("bad color code: %s", board.Color)
	}
	res := struct {
		MaxSorting  int64
		ColumnCount int64
	}{}
	if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
		Where("project_id=?", board.ProjectID).Get(&res); err != nil {
		return err
	}
	if res.ColumnCount >= maxProjectColumns {
		return fmt.Errorf("NewBoard: maximum number of columns reached")
	}
	board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
	_, err := db.GetEngine(ctx).Insert(board)
	return err
}

// DeleteBoardByID removes all issues references to the project board.
func DeleteBoardByID(ctx context.Context, boardID int64) error {
	ctx, committer, err := db.TxContext(ctx)
	if err != nil {
		return err
	}
	defer committer.Close()

	if err := deleteBoardByID(ctx, boardID); err != nil {
		return err
	}

	return committer.Commit()
}

func deleteBoardByID(ctx context.Context, boardID int64) error {
	board, err := GetBoard(ctx, boardID)
	if err != nil {
		if IsErrProjectBoardNotExist(err) {
			return nil
		}

		return err
	}

	if board.Default {
		return fmt.Errorf("deleteBoardByID: cannot delete default board")
	}

	// move all issues to the default column
	project, err := GetProjectByID(ctx, board.ProjectID)
	if err != nil {
		return err
	}
	defaultColumn, err := project.GetDefaultBoard(ctx)
	if err != nil {
		return err
	}

	if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
		return err
	}

	if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil {
		return err
	}
	return nil
}

func deleteBoardByProjectID(ctx context.Context, projectID int64) error {
	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Board{})
	return err
}

// GetBoard fetches the current board of a project
func GetBoard(ctx context.Context, boardID int64) (*Board, error) {
	board := new(Board)
	has, err := db.GetEngine(ctx).ID(boardID).Get(board)
	if err != nil {
		return nil, err
	} else if !has {
		return nil, ErrProjectBoardNotExist{BoardID: boardID}
	}

	return board, nil
}

// UpdateBoard updates a project board
func UpdateBoard(ctx context.Context, board *Board) error {
	var fieldToUpdate []string

	if board.Sorting != 0 {
		fieldToUpdate = append(fieldToUpdate, "sorting")
	}

	if board.Title != "" {
		fieldToUpdate = append(fieldToUpdate, "title")
	}

	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
		return fmt.Errorf("bad color code: %s", board.Color)
	}
	fieldToUpdate = append(fieldToUpdate, "color")

	_, err := db.GetEngine(ctx).ID(board.ID).Cols(fieldToUpdate...).Update(board)

	return err
}

// GetBoards fetches all boards related to a project
func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
	boards := make([]*Board, 0, 5)
	if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
		return nil, err
	}

	return boards, nil
}

// GetDefaultBoard return default board and ensure only one exists
func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) {
	var board Board
	has, err := db.GetEngine(ctx).
		Where("project_id=? AND `default` = ?", p.ID, true).
		Desc("id").Get(&board)
	if err != nil {
		return nil, err
	}

	if has {
		return &board, nil
	}

	// create a default board if none is found
	board = Board{
		ProjectID: p.ID,
		Default:   true,
		Title:     "Uncategorized",
		CreatorID: p.CreatorID,
	}
	if _, err := db.GetEngine(ctx).Insert(&board); err != nil {
		return nil, err
	}
	return &board, nil
}

// SetDefaultBoard represents a board for issues not assigned to one
func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error {
	return db.WithTx(ctx, func(ctx context.Context) error {
		if _, err := GetBoard(ctx, boardID); err != nil {
			return err
		}

		if _, err := db.GetEngine(ctx).Where(builder.Eq{
			"project_id": projectID,
			"`default`":  true,
		}).Cols("`default`").Update(&Board{Default: false}); err != nil {
			return err
		}

		_, err := db.GetEngine(ctx).ID(boardID).
			Where(builder.Eq{"project_id": projectID}).
			Cols("`default`").Update(&Board{Default: true})
		return err
	})
}

// UpdateBoardSorting update project board sorting
func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
	return db.WithTx(ctx, func(ctx context.Context) error {
		for i := range bs {
			if _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols(
				"sorting",
			).Update(bs[i]); err != nil {
				return err
			}
		}
		return nil
	})
}

func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) {
	columns := make([]*Board, 0, 5)
	if err := db.GetEngine(ctx).
		Where("project_id =?", projectID).
		In("id", columnsIDs).
		OrderBy("sorting").Find(&columns); err != nil {
		return nil, err
	}
	return columns, nil
}

// MoveColumnsOnProject sorts columns in a project
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
	return db.WithTx(ctx, func(ctx context.Context) error {
		sess := db.GetEngine(ctx)
		columnIDs := util.ValuesOfMap(sortedColumnIDs)
		movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
		if err != nil {
			return err
		}
		if len(movedColumns) != len(sortedColumnIDs) {
			return errors.New("some columns do not exist")
		}

		for _, column := range movedColumns {
			if column.ProjectID != project.ID {
				return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
			}
		}

		for sorting, columnID := range sortedColumnIDs {
			if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
				return err
			}
		}
		return nil
	})
}