package gcsql

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"path"
	"time"

	"github.com/gochan-org/gochan/pkg/config"
)

const (
	// selects all columns from DBPREFIXboards
	selectBoardsBaseSQL = `SELECT
	DBPREFIXboards.id, section_id, uri, dir, navbar_position, title, subtitle, description,
	max_file_size, max_threads, default_style, locked, created_at, anonymous_name, force_anonymous,
	autosage_after, no_images_after, max_message_length, min_message_length, allow_embeds, redirect_to_thread,
	require_file, enable_catalog
	FROM DBPREFIXboards
	INNER JOIN (
		SELECT id, hidden FROM DBPREFIXsections
	) s ON DBPREFIXboards.section_id = s.id `
)

var (
	// AllBoards provides a quick and simple way to access a list of all boards in non-hidden sections
	// without having to do any SQL queries. It and AllSections are updated by ResetBoardSectionArrays
	AllBoards []Board

	ErrNilBoard          = errors.New("board is nil")
	ErrBoardExists       = errors.New("board already exists")
	ErrBoardDoesNotExist = errors.New("board does not exist")
	ErrBoardIsLocked     = errors.New("board is locked")
)

// DoesBoardExistByID returns a bool indicating whether a board with a given id exists
func DoesBoardExistByID(ID int, requestOptions ...*RequestOptions) bool {
	opts := setupOptionsWithTimeout(requestOptions...)
	var count int
	QueryRow(opts, "SELECT COUNT(id) FROM DBPREFIXboards WHERE id = ?", []any{ID}, []any{&count})
	return count > 0
}

// DoesBoardExistByDir returns a bool indicating whether a board with a given directory exists
func DoesBoardExistByDir(dir string, requestOpts ...*RequestOptions) bool {
	opts := setupOptionsWithTimeout(requestOpts...)
	var count int
	QueryRow(opts, "SELECT COUNT(dir) FROM DBPREFIXboards WHERE dir = ?", []any{dir}, []any{&count})
	return count > 0
}

// GetAllBoards gets a list of all existing boards
func GetAllBoards(onlyNonHidden bool) ([]Board, error) {
	query := selectBoardsBaseSQL
	if onlyNonHidden {
		query += " WHERE s.hidden = FALSE"
	}
	query += " ORDER BY navbar_position ASC, DBPREFIXboards.id ASC"

	rows, cancel, err := QueryTimeoutSQL(nil, query)
	if err != nil {
		return nil, err
	}
	defer func() {
		rows.Close()
		cancel()
	}()

	var boards []Board
	for rows.Next() {
		var board Board
		if err = rows.Scan(
			&board.ID, &board.SectionID, &board.URI, &board.Dir, &board.NavbarPosition, &board.Title, &board.Subtitle,
			&board.Description, &board.MaxFilesize, &board.MaxThreads, &board.DefaultStyle, &board.Locked,
			&board.CreatedAt, &board.AnonymousName, &board.ForceAnonymous, &board.AutosageAfter, &board.NoImagesAfter,
			&board.MaxMessageLength, &board.MinMessageLength, &board.AllowEmbeds, &board.RedirectToThread, &board.RequireFile,
			&board.EnableCatalog,
		); err != nil {
			return nil, err
		}
		boards = append(boards, board)
	}
	return boards, rows.Close()
}

func GetBoardDir(id int) (string, error) {
	const query = `SELECT dir FROM DBPREFIXboards WHERE id = ?`
	var dir string
	err := QueryRowTimeoutSQL(nil, query, []any{id}, []any{&dir})
	return dir, err
}

// GetBoardFromPostID gets the boardURI that a given postid exists on
func GetBoardDirFromPostID(postID int) (string, error) {
	const query = `SELECT board.uri FROM DBPREFIXboards as board
	JOIN (
		SELECT threads.board_id FROM DBPREFIXthreads as threads
		JOIN DBPREFIXposts as posts ON posts.thread_id = threads.id
		WHERE posts.id = ?
	) as threads ON threads.board_id = board.id`
	var boardURI string
	err := QueryRowTimeoutSQL(nil, query, []any{postID}, []any{&boardURI})
	if errors.Is(err, sql.ErrNoRows) {
		err = ErrBoardDoesNotExist
	}
	return boardURI, err
}

func getBoardBase(requestOptions *RequestOptions, where string, whereParameters ...any) (*Board, error) {
	query := selectBoardsBaseSQL + where
	board := new(Board)
	err := QueryRow(requestOptions, query, whereParameters, []any{
		&board.ID, &board.SectionID, &board.URI, &board.Dir, &board.NavbarPosition, &board.Title, &board.Subtitle,
		&board.Description, &board.MaxFilesize, &board.MaxThreads, &board.DefaultStyle, &board.Locked,
		&board.CreatedAt, &board.AnonymousName, &board.ForceAnonymous, &board.AutosageAfter, &board.NoImagesAfter,
		&board.MaxMessageLength, &board.MinMessageLength, &board.AllowEmbeds, &board.RedirectToThread, &board.RequireFile,
		&board.EnableCatalog})
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBoardDoesNotExist
	}
	return board, err
}

// GetBoardFromID returns the board corresponding to a given id
func GetBoardFromID(id int, requestOptions ...*RequestOptions) (*Board, error) {
	opts := setupOptionsWithTimeout(requestOptions...)
	return getBoardBase(opts, "WHERE DBPREFIXboards.id = ?", id)
}

// GetBoardFromDir returns the board corresponding to a given dir
func GetBoardFromDir(dir string, requestOptions ...*RequestOptions) (*Board, error) {
	opts := setupOptionsWithTimeout(requestOptions...)
	return getBoardBase(opts, "WHERE DBPREFIXboards.dir = ?", dir)
}

// GetIDFromDir returns the id of the board with the given dir value
func GetBoardIDFromDir(dir string) (id int, err error) {
	const query = `SELECT id FROM DBPREFIXboards WHERE dir = ?`
	err = QueryRowTimeoutSQL(nil, query, []any{dir}, []any{&id})
	if errors.Is(err, sql.ErrNoRows) {
		return 0, ErrBoardDoesNotExist
	}
	return id, err
}

// GetBoardURIs gets a list of all existing board URIs
func GetBoardURIs() (URIS []string, err error) {
	const sql = `SELECT uri FROM DBPREFIXboards`
	rows, cancel, err := QueryTimeoutSQL(nil, sql)
	if err != nil {
		return nil, err
	}
	defer func() {
		rows.Close()
		cancel()
	}()
	var uris []string
	for rows.Next() {
		var uri string
		if err = rows.Scan(&uri); err != nil {
			return nil, err
		}
		uris = append(uris, uri)
	}
	return uris, rows.Close()
}

// ResetBoardSectionArrays is run when the board list needs to be changed
// (board/section is added, deleted, etc)
func ResetBoardSectionArrays() error {
	allBoardsArr, err := GetAllBoards(true)
	if err != nil {
		return err
	}
	AllBoards = nil
	AllBoards = append(AllBoards, allBoardsArr...)
	for _, board := range AllBoards {
		if err = config.UpdateBoardConfig(board.Dir); err != nil {
			return fmt.Errorf("unable to update board config for /%s/: %w", board.Dir, err)
		}
	}

	allSectionsArr, err := GetAllSections(true)
	if err != nil {
		return err
	}
	AllSections = nil
	AllSections = append(AllSections, allSectionsArr...)
	return nil
}

// NewBoardSimple creates a new board in the database given the directory, title, subtitle, and description.
// Generic values are used for the other columns to be optionally changed later
func NewBoardSimple(dir string, title string, subtitle string, description string, appendToAllBoards bool) (*Board, error) {
	sectionID, err := getOrCreateDefaultSectionID()
	if err != nil {
		return nil, err
	}
	board := &Board{
		SectionID:        sectionID,
		URI:              dir,
		Dir:              dir,
		NavbarPosition:   3,
		Title:            title,
		Subtitle:         subtitle,
		Description:      description,
		MaxFilesize:      15000,
		MaxThreads:       300,
		DefaultStyle:     config.GetBoardConfig("").DefaultStyle,
		Locked:           false,
		AnonymousName:    "Anonymous",
		ForceAnonymous:   false,
		AutosageAfter:    500,
		NoImagesAfter:    -1,
		MaxMessageLength: 1500,
		MinMessageLength: 0,
		AllowEmbeds:      false,
		RedirectToThread: false,
		RequireFile:      false,
		EnableCatalog:    true,
	}
	return board, CreateBoard(board, appendToAllBoards)
}

// CreateBoard inserts a new board into the database, using the fields from the given Board pointer.
// It sets board.ID and board.CreatedAt if it is successfull
func CreateBoard(board *Board, appendToAllBoards bool) error {
	const sqlINSERT = `INSERT INTO DBPREFIXboards
	(section_id, uri, dir, navbar_position, title, subtitle,
	description, max_file_size, max_threads, default_style, locked,
	anonymous_name, force_anonymous, autosage_after, no_images_after, max_message_length,
	min_message_length, allow_embeds, redirect_to_thread, require_file, enable_catalog)
	VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
	if board == nil {
		return ErrNilBoard
	}
	if DoesBoardExistByDir(board.Dir) {
		return ErrBoardExists
	}
	if board.Dir == "" {
		return errors.New("board dir string must not be empty")
	}
	if board.URI == "" {
		board.URI = board.Dir
	}
	if board.Title == "" {
		return errors.New("board title string must not be empty")
	}

	ctx, cancel := context.WithTimeout(context.Background(), gcdb.defaultTimeout)
	defer cancel()

	_, err := ExecContextSQL(ctx, nil, sqlINSERT,
		&board.SectionID, &board.URI, &board.Dir, &board.NavbarPosition, &board.Title, &board.Subtitle,
		&board.Description, &board.MaxFilesize, &board.MaxThreads, &board.DefaultStyle, &board.Locked,
		&board.AnonymousName, &board.ForceAnonymous, &board.AutosageAfter, &board.NoImagesAfter, &board.MaxMessageLength,
		&board.MinMessageLength, &board.AllowEmbeds, &board.RedirectToThread, &board.RequireFile, &board.EnableCatalog)
	if err != nil {
		return err
	}
	if err = QueryRowContextSQL(ctx, nil,
		`SELECT id FROM DBPREFIXboards WHERE dir = ?`,
		[]any{board.Dir}, []any{&board.ID}); err != nil {
		return err
	}
	board.CreatedAt = time.Now()
	if appendToAllBoards {
		AllBoards = append(AllBoards, *board)
	}
	return nil
}

// createDefaultBoardIfNoneExist creates a default board if no boards exist yet
func createDefaultBoardIfNoneExist() error {
	const query = `SELECT COUNT(id) FROM DBPREFIXboards`
	var count int
	QueryRowTimeoutSQL(nil, query, nil, []any{&count})
	if count > 0 {
		return nil
	}

	// create a default generic /test/ board
	_, err := NewBoardSimple("test", "Testing Board", "Board for testing stuff", "Board for testing stuff", true)
	return err
}

func getBoardIDFromURI(uri string) (int, error) {
	const sql = `SELECT id FROM DBPREFIXboards WHERE uri = ?`
	var id int
	err := QueryRowTimeoutSQL(nil, sql, []any{uri}, []any{&id})
	return id, err
}

func (board *Board) Delete() error {
	const query = `DELETE FROM DBPREFIXboards WHERE id = ?`
	_, err := ExecTimeoutSQL(nil, query, board.ID)
	if err != nil {
		return err
	}
	config.DeleteBoardConfig(board.Dir)
	return nil
}

// DeleteOldThreads deletes old threads that exceed the limit set by board.MaxThreads and returns the posts in those
// threads
func (board *Board) DeleteOldThreads() ([]int, error) {
	if board.MaxThreads < 1 {
		return nil, nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	tx, err := BeginContextTx(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	rows, err := QueryContextSQL(ctx, nil,
		`SELECT id FROM DBPREFIXthreads
		WHERE board_id = ? AND is_deleted = FALSE AND stickied = FALSE
		ORDER BY last_bump DESC`,
		board.ID)
	if err != nil {
		return nil, err
	}
	defer func() {
		rows.Close()
	}()

	var threadIDs []any
	var id int
	var threadsProccessed int
	for rows.Next() {
		threadsProccessed++
		if threadsProccessed <= board.MaxThreads {
			continue
		}
		if err = rows.Scan(&id); err != nil {
			return nil, err
		}
		threadIDs = append(threadIDs, id)
	}
	if threadIDs == nil {
		// no threads to trim
		return nil, nil
	}
	idSetStr := createArrayPlaceholder(threadIDs)

	if _, err = ExecContextSQL(ctx, tx, `UPDATE DBPREFIXthreads SET is_deleted = TRUE WHERE id in `+idSetStr,
		threadIDs...); err != nil {
		return nil, err
	}

	if rows, err = QueryContextSQL(ctx, tx, `SELECT id FROM DBPREFIXposts WHERE thread_id in `+idSetStr,
		threadIDs...); err != nil {
		return nil, err
	}
	defer func() {
		rows.Close()
	}()

	var postIDs []int
	for rows.Next() {
		if err = rows.Scan(&id); err != nil {
			return nil, err
		}
		postIDs = append(postIDs, id)
	}

	if _, err = ExecContextSQL(ctx, tx, `UPDATE DBPREFIXposts SET is_deleted = TRUE WHERE thread_id in `+idSetStr,
		threadIDs...); err != nil {
		return nil, err
	}

	return postIDs, tx.Commit()
}

func (board *Board) GetThreads(onlyNotDeleted bool, orderLastByBump bool, stickiedFirst bool) ([]Thread, error) {
	query := selectThreadsBaseSQL + " WHERE board_id = ?"
	if onlyNotDeleted {
		query += " AND is_deleted = FALSE"
	}
	if orderLastByBump || stickiedFirst {
		query += " ORDER BY "
	}
	if stickiedFirst {
		query += "stickied DESC"
		if orderLastByBump {
			query += ", "
		}
	}
	if orderLastByBump {
		query += " last_bump DESC"
	}
	rows, cancel, err := QueryTimeoutSQL(nil, query, board.ID)
	if err != nil {
		return nil, err
	}
	defer func() {
		rows.Close()
		cancel()
	}()
	var threads []Thread
	for rows.Next() {
		var thread Thread
		err = rows.Scan(
			&thread.ID, &thread.BoardID, &thread.Locked, &thread.Stickied, &thread.Anchored,
			&thread.Cyclic, &thread.LastBump, &thread.DeletedAt, &thread.IsDeleted,
		)
		if err != nil {
			return threads, err
		}
		threads = append(threads, thread)
	}
	return threads, rows.Close()
}

// IsHidden returns true if the board is in a section that is hidden, otherwise false. If it is in a section
// that is not in the AllSections array, it returns defValueIfMissingSection
func (board *Board) IsHidden(defValueIfMissingSection bool) bool {
	for s := range AllSections {
		if AllSections[s].ID == board.SectionID {
			return AllSections[s].Hidden
		}
	}
	return defValueIfMissingSection // board is not in a valid section (or AllSections needs to be reset)
}

// ModifyInDB updates the board dataa in the database with new values
func (board *Board) ModifyInDB() error {
	const query = `UPDATE DBPREFIXboards SET
		section_id = ?,
		navbar_position = ?,
		title = ?,
		subtitle = ?,
		description = ?,
		max_file_size = ?,
		max_threads = ?,
		default_style = ?,
		locked = ?,
		anonymous_name = ?,
		force_anonymous = ?,
		autosage_after = ?,
		no_images_after = ?,
		max_message_length = ?,
		min_message_length = ?,
		allow_embeds = ?,
		redirect_to_thread = ?,
		require_file = ?,
		enable_catalog = ?
		WHERE id = ?`
	_, err := Exec(nil, query,
		board.SectionID, board.NavbarPosition, board.Title, board.Subtitle, board.Description,
		board.MaxFilesize, board.MaxThreads, board.DefaultStyle, board.Locked, board.AnonymousName,
		board.ForceAnonymous, board.AutosageAfter, board.NoImagesAfter, board.MaxMessageLength,
		board.MinMessageLength, board.AllowEmbeds, board.RedirectToThread, board.RequireFile, board.EnableCatalog,
		board.ID)
	if err != nil {
		return err
	}
	return ResetBoardSectionArrays()
}

// AbsolutePath returns the full filepath of the board directory
func (board *Board) AbsolutePath(subpath ...string) string {
	return path.Join(config.GetSystemCriticalConfig().DocumentRoot, board.Dir, path.Join(subpath...))
}

// WebPath returns a string that represents the file's path as accessible by a browser
// fileType should be "boardPage", "threadPage", "upload", or "thumb"
func (board *Board) WebPath(fileName, fileType string) string {
	var filePath string
	systemCritical := config.GetSystemCriticalConfig()

	switch fileType {
	case "":
		fallthrough
	case "boardPage":
		filePath = path.Join(systemCritical.WebRoot, board.Dir, fileName)
	case "threadPage":
		filePath = path.Join(systemCritical.WebRoot, board.Dir, "res", fileName)
	case "upload":
		filePath = path.Join(systemCritical.WebRoot, board.Dir, "src", fileName)
	case "thumb":
		fallthrough
	case "thumbnail":
		filePath = path.Join(systemCritical.WebRoot, board.Dir, "thumb", fileName)
	}
	return filePath
}
