package app

import (
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"database/sql"
	"encoding/base64"
	"errors"
	"fmt"
	"net/mail"
	"os"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	_ "modernc.org/sqlite"
)

type User struct {
	ID          int64
	Username    string
	DisplayName string
	Email       string
	Role        string
	AvatarURL   string
	Active      bool
	WorkspaceID int64 `json:"-"`
}

type Workspace struct {
	ID        int64
	Name      string
	Slug      string
	CreatedAt string
}

type WorkspaceMember struct {
	WorkspaceID int64
	UserID      int64
	Role        string
	Workspace   Workspace
	User        User
}

type Attendance struct {
	ID       int64
	UserID   int64
	Day      string
	CheckIn  string
	CheckOut string
	Mode     string
}

type Request struct {
	ID          int64
	UserID      int64
	UserName    string
	Kind        string
	StartDate   string
	EndDate     string
	Reason      string
	Status      string
	AdminNote   string
	CreatedAt   time.Time
	ReviewedAt  sql.NullString
	Reviewer    string
	DayCount    int
	StartJalali string
	EndJalali   string
}

type DayRosterRow struct {
	User        User
	CheckIn     string
	CheckOut    string
	Mode        string
	RequestKind string
}

type WorkUpdate struct {
	ID          int64
	UserID      int64
	UserName    string
	Username    string
	AvatarURL   string
	PeriodStart string
	PeriodEnd   string
	Status      string
	Note        string
	CreatedAt   string
}

type PersonReportSummary struct {
	User        User
	PresentDays int
	RemoteDays  int
	LeaveDays   int
	DoneUpdates int
	Blocked     int
	Pending     int
}

func (s PersonReportSummary) TotalUpdates() int {
	return s.DoneUpdates + s.Blocked + s.Pending
}

type BoardTask struct {
	ID          int64
	Title       string
	Description string
	Status      string
	Importance  string
	CreatorID   int64
	CreatorName string
	DueDate     string
	CreatedAt   string
	ArchivedAt  string
	Assignees   []User
	Tags        []BoardTag
	Todos       []BoardTodo
	TodoDone    int
}

type BoardTag struct {
	ID    int64
	Name  string
	Color string
}

type BoardTodo struct {
	ID        int64
	TaskID    int64
	Body      string
	Completed bool
}

type Store struct{ db *sql.DB }

func OpenStore(path string) (*Store, error) {
	db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(1)
	s := &Store{db: db}
	if err := s.migrate(); err != nil {
		db.Close()
		return nil, err
	}
	return s, nil
}

func (s *Store) Close() error { return s.db.Close() }

func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }

func (s *Store) ensureColumn(table, column, definition string) error {
	rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`)
	if err != nil {
		return err
	}
	found := false
	for rows.Next() {
		var cid, notNull, primaryKey int
		var name, columnType string
		var defaultValue any
		if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
			rows.Close()
			return err
		}
		if name == column {
			found = true
		}
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	if found {
		return nil
	}
	_, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + definition)
	return err
}

func (s *Store) migrate() error {
	const schema = `
CREATE TABLE IF NOT EXISTS users (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	username TEXT NOT NULL UNIQUE COLLATE NOCASE,
	password_hash TEXT,
	display_name TEXT NOT NULL,
	email TEXT UNIQUE COLLATE NOCASE,
	role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
	avatar_url TEXT NOT NULL DEFAULT '',
	active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workspaces (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	name TEXT NOT NULL,
	slug TEXT NOT NULL UNIQUE COLLATE NOCASE,
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workspace_members (
	workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
	PRIMARY KEY(workspace_id,user_id)
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
	provider TEXT NOT NULL,
	provider_user_id TEXT NOT NULL,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	PRIMARY KEY(provider, provider_user_id)
);
CREATE TABLE IF NOT EXISTS sessions (
	token_hash TEXT PRIMARY KEY,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	csrf_token TEXT NOT NULL,
	expires_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS attendance (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	day TEXT NOT NULL,
	check_in DATETIME,
	check_out DATETIME,
	mode TEXT NOT NULL DEFAULT 'office' CHECK(mode IN ('office','remote')),
	UNIQUE(user_id, day)
);
CREATE TABLE IF NOT EXISTS requests (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	kind TEXT NOT NULL CHECK(kind IN ('leave','remote')),
	start_date TEXT NOT NULL,
	end_date TEXT NOT NULL,
	reason TEXT NOT NULL DEFAULT '',
	status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','cancelled')),
	admin_note TEXT NOT NULL DEFAULT '',
	reviewed_by INTEGER REFERENCES users(id),
	reviewed_at DATETIME,
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
	CHECK(end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date);
CREATE TABLE IF NOT EXISTS work_updates (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	period_start TEXT NOT NULL,
	period_end TEXT NOT NULL,
	status TEXT NOT NULL CHECK(status IN ('done','blocked','pending')),
	note TEXT NOT NULL,
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
	CHECK(period_end >= period_start)
);
CREATE INDEX IF NOT EXISTS idx_work_updates_period ON work_updates(period_start,period_end);
CREATE TABLE IF NOT EXISTS board_tasks (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	workspace_id INTEGER NOT NULL DEFAULT 1,
	title TEXT NOT NULL,
	description TEXT NOT NULL DEFAULT '',
	status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')),
	importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
	assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
	creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	due_date TEXT,
	archived_at DATETIME,
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
CREATE TABLE IF NOT EXISTS board_task_assignees (
	task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	PRIMARY KEY(task_id,user_id)
);
CREATE INDEX IF NOT EXISTS idx_board_task_assignees_user ON board_task_assignees(user_id,task_id);
CREATE TABLE IF NOT EXISTS board_task_todos (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
	body TEXT NOT NULL,
	completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0,1)),
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_board_task_todos_task ON board_task_todos(task_id,completed,created_at);
CREATE TABLE IF NOT EXISTS board_tags (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	name TEXT NOT NULL UNIQUE COLLATE NOCASE,
	color TEXT NOT NULL DEFAULT 'green',
	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS board_task_tags (
	task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
	tag_id INTEGER NOT NULL REFERENCES board_tags(id) ON DELETE CASCADE,
	PRIMARY KEY(task_id,tag_id)
);
CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,task_id);
`
	if _, err := s.db.Exec(schema); err != nil {
		return err
	}
	if err := s.ensureColumn("users", "active", "active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))"); err != nil {
		return err
	}
	if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil {
		return err
	}
	if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
		return err
	}
	if err := s.ensureColumn("board_tasks", "workspace_id", "workspace_id INTEGER NOT NULL DEFAULT 1"); err != nil {
		return err
	}
	if err := s.ensureBoardTaskTodoStatus(); err != nil {
		return err
	}
	if _, err := s.db.Exec(`INSERT OR IGNORE INTO board_task_assignees(task_id,user_id)
		SELECT id,assignee_id FROM board_tasks WHERE assignee_id IS NOT NULL`); err != nil {
		return err
	}
	if _, err := s.db.Exec(`UPDATE board_tasks SET assignee_id=NULL WHERE assignee_id IS NOT NULL`); err != nil {
		return err
	}
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return err
	}
	if count == 0 {
		initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD")
		if initialPassword == "" {
			initialPassword = "admin123"
		}
		hash, err := hashPassword(initialPassword)
		if err != nil {
			return err
		}
		_, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`,
			"admin", hash, "Workspace Admin", "admin@localhost", "admin")
		if err != nil {
			return err
		}
	}
	var workspaceCount int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&workspaceCount); err != nil {
		return err
	}
	if workspaceCount == 0 {
		if _, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, "Hamkar workspace", "hamkar"); err != nil {
			return err
		}
	}
	if _, err := s.db.Exec(`INSERT OR IGNORE INTO workspace_members(workspace_id,user_id,role) SELECT w.id,u.id,u.role FROM workspaces w CROSS JOIN users u`); err != nil {
		return err
	}
	return nil
}

func (s *Store) ensureBoardTaskTodoStatus() error {
	var tableSQL string
	err := s.db.QueryRow(`SELECT COALESCE(sql,'') FROM sqlite_master WHERE type='table' AND name='board_tasks'`).Scan(&tableSQL)
	if err != nil {
		return err
	}
	if strings.Contains(strings.ToLower(tableSQL), "'todo'") {
		return nil
	}
	if _, err := s.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
		return err
	}
	restoreForeignKeys := func() { _, _ = s.db.Exec(`PRAGMA foreign_keys=ON`) }
	if _, err := s.db.Exec(`CREATE TABLE board_tasks_migrated (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		title TEXT NOT NULL,
		description TEXT NOT NULL DEFAULT '',
		status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')),
		importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
		assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
		creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
		due_date TEXT,
		archived_at DATETIME,
		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`); err != nil {
		restoreForeignKeys()
		return err
	}
	if _, err := s.db.Exec(`INSERT INTO board_tasks_migrated(id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at)
		SELECT id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at FROM board_tasks`); err != nil {
		_, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`)
		restoreForeignKeys()
		return err
	}
	if _, err := s.db.Exec(`DROP TABLE board_tasks`); err != nil {
		_, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`)
		restoreForeignKeys()
		return err
	}
	if _, err := s.db.Exec(`ALTER TABLE board_tasks_migrated RENAME TO board_tasks`); err != nil {
		restoreForeignKeys()
		return err
	}
	if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at)`); err != nil {
		restoreForeignKeys()
		return err
	}
	restoreForeignKeys()
	return nil
}

func hashPassword(password string) (string, error) {
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	const rounds = 180000
	key := deriveKey([]byte(password), salt, rounds)
	return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", rounds,
		base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}

func verifyPassword(encoded, password string) bool {
	parts := strings.Split(encoded, "$")
	if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
		return false
	}
	rounds, err := strconv.Atoi(parts[1])
	salt, err2 := base64.RawStdEncoding.DecodeString(parts[2])
	expected, err3 := base64.RawStdEncoding.DecodeString(parts[3])
	if err != nil || err2 != nil || err3 != nil || rounds < 10000 {
		return false
	}
	return hmac.Equal(expected, deriveKey([]byte(password), salt, rounds))
}

func deriveKey(password, salt []byte, rounds int) []byte {
	mac := hmac.New(sha256.New, password)
	mac.Write(salt)
	mac.Write([]byte{0, 0, 0, 1})
	u := mac.Sum(nil)
	out := append([]byte(nil), u...)
	for i := 1; i < rounds; i++ {
		mac.Reset()
		mac.Write(u)
		u = mac.Sum(nil)
		for j := range out {
			out[j] ^= u[j]
		}
	}
	return out
}

func randomToken(bytes int) (string, error) {
	b := make([]byte, bytes)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func tokenHash(token string) string {
	sum := sha256.Sum256([]byte(token))
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

func (s *Store) Authenticate(identifier, password string) (*User, error) {
	var u User
	var hash string
	err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url,active
		FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)).
		Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active)
	if err != nil || !verifyPassword(hash, password) {
		return nil, errors.New("invalid email, username, or password")
	}
	if !u.Active {
		return nil, errors.New("this account is locked; contact an administrator")
	}
	return &u, nil
}

func (s *Store) CreateUser(username, password, displayName, email, role string) (int64, error) {
	username = strings.TrimSpace(username)
	displayName = strings.TrimSpace(displayName)
	email = strings.ToLower(strings.TrimSpace(email))
	if displayName == "" || len(password) < 8 {
		return 0, errors.New("name and a password of at least 8 characters are required")
	}
	if username == "" && email == "" {
		return 0, errors.New("enter an email address or username")
	}
	if email != "" {
		parsed, err := mail.ParseAddress(email)
		if err != nil || parsed.Address != email {
			return 0, errors.New("enter a valid email address")
		}
	}
	explicitUsername := username != ""
	if !explicitUsername {
		username = usernameFromEmail(email)
	}
	if !validUsername(username) {
		return 0, errors.New("username must be 3–40 letters, numbers, dots, dashes, or underscores")
	}
	if !explicitUsername {
		base := username
		for suffix := 0; ; suffix++ {
			if suffix > 0 {
				username = fmt.Sprintf("%s-%d", base, suffix)
			}
			var count int
			if err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, username).Scan(&count); err != nil {
				return 0, err
			}
			if count == 0 {
				break
			}
		}
	}
	if role != "admin" {
		role = "member"
	}
	hash, err := hashPassword(password)
	if err != nil {
		return 0, err
	}
	result, err := s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role)
		VALUES(?,?,?,NULLIF(?,''),?)`, username, hash, displayName, email, role)
	if err != nil {
		if strings.Contains(strings.ToLower(err.Error()), "unique") {
			return 0, errors.New("that username or email is already in use")
		}
		return 0, err
	}
	return result.LastInsertId()
}

func usernameFromEmail(email string) string {
	local := strings.SplitN(email, "@", 2)[0]
	var out strings.Builder
	for _, r := range local {
		if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._-", r) {
			out.WriteRune(r)
		}
	}
	username := strings.Trim(out.String(), "._-")
	if utf8.RuneCountInString(username) < 3 {
		username = "member-" + username
	}
	if utf8.RuneCountInString(username) > 32 {
		username = string([]rune(username)[:32])
	}
	return username
}

func validUsername(username string) bool {
	count := utf8.RuneCountInString(username)
	if count < 3 || count > 40 {
		return false
	}
	for _, r := range username {
		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !strings.ContainsRune("._-", r) {
			return false
		}
	}
	return true
}

func (s *Store) Users() ([]User, error) {
	return s.users(false)
}

func (s *Store) Workspaces(userID int64) ([]Workspace, error) {
	rows, err := s.db.Query(`SELECT w.id,w.name,w.slug,w.created_at FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id WHERE m.user_id=? ORDER BY w.name`, userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Workspace
	for rows.Next() {
		var w Workspace
		if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil {
			return nil, err
		}
		out = append(out, w)
	}
	return out, rows.Err()
}

func (s *Store) AllWorkspaces() ([]Workspace, error) {
	rows, err := s.db.Query(`SELECT id,name,slug,created_at FROM workspaces ORDER BY name`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Workspace
	for rows.Next() {
		var w Workspace
		if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil {
			return nil, err
		}
		out = append(out, w)
	}
	return out, rows.Err()
}

func (s *Store) CreateWorkspace(name, slug string, ownerID int64) (int64, error) {
	result, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, strings.TrimSpace(name), strings.TrimSpace(strings.ToLower(slug)))
	if err != nil {
		return 0, err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}
	_, err = s.db.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) VALUES(?,?, 'admin')`, id, ownerID)
	return id, err
}

func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, error) {
	var m WorkspaceMember
	err := s.db.QueryRow(`SELECT w.id,w.name,w.slug,w.created_at,u.id,u.username,u.display_name,u.email,u.role,u.avatar_url,u.active,m.role FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id JOIN users u ON u.id=m.user_id WHERE w.id=? AND u.id=?`, workspaceID, userID).Scan(&m.Workspace.ID, &m.Workspace.Name, &m.Workspace.Slug, &m.Workspace.CreatedAt, &m.User.ID, &m.User.Username, &m.User.DisplayName, &m.User.Email, &m.User.Role, &m.User.AvatarURL, &m.User.Active, &m.Role)
	return m, err
}

func (s *Store) SetWorkspaceMembers(workspaceID int64, userIDs []int64) error {
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err = tx.Exec(`DELETE FROM workspace_members WHERE workspace_id=?`, workspaceID); err != nil {
		return err
	}
	for _, userID := range userIDs {
		if _, err = tx.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) SELECT ?,id,CASE WHEN role='admin' THEN 'admin' ELSE 'member' END FROM users WHERE id=?`, workspaceID, userID); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Store) WorkspaceMemberIDs(workspaceID int64) (map[int64]bool, error) {
	rows, err := s.db.Query(`SELECT user_id FROM workspace_members WHERE workspace_id=?`, workspaceID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[int64]bool{}
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		out[id] = true
	}
	return out, rows.Err()
}

func (s *Store) ActiveUsers() ([]User, error) {
	return s.users(true)
}

func (s *Store) ActiveUsersInWorkspace(workspaceID int64) ([]User, error) {
	rows, err := s.db.Query(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active FROM users u JOIN workspace_members m ON m.user_id=u.id WHERE m.workspace_id=? AND u.active=1 ORDER BY u.display_name`, workspaceID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []User
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active); err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

func (s *Store) users(activeOnly bool) ([]User, error) {
	query := `SELECT id,username,display_name,COALESCE(email,''),role,avatar_url,active FROM users`
	if activeOnly {
		query += ` WHERE active=1`
	}
	query += ` ORDER BY display_name`
	rows, err := s.db.Query(query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []User
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active); err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

func (s *Store) CreateSession(userID int64) (token, csrf string, err error) {
	token, err = randomToken(32)
	if err != nil {
		return "", "", err
	}
	csrf, err = randomToken(24)
	if err != nil {
		return "", "", err
	}
	result, err := s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at)
		SELECT ?,id,?,? FROM users WHERE id=? AND active=1`,
		tokenHash(token), csrf, time.Now().Add(30*24*time.Hour), userID)
	if err != nil {
		return "", "", err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return "", "", errors.New("account is locked or was not found")
	}
	return
}

func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayName, avatar string) (int64, error) {
	if providerID == "" || providerID == "<nil>" {
		return 0, errors.New("OAuth profile did not contain an id")
	}
	tx, err := s.db.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()
	var userID int64
	var active bool
	err = tx.QueryRow(`SELECT o.user_id,u.active FROM oauth_accounts o
		JOIN users u ON u.id=o.user_id
		WHERE o.provider=? AND o.provider_user_id=?`, provider, providerID).Scan(&userID, &active)
	if err == nil {
		if !active {
			return 0, errors.New("account is locked")
		}
		return userID, tx.Commit()
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return 0, err
	}
	if email != "" {
		err = tx.QueryRow(`SELECT id,active FROM users WHERE email=?`, email).Scan(&userID, &active)
		if err == nil && !active {
			return 0, errors.New("account is locked")
		}
	}
	if email == "" || errors.Is(err, sql.ErrNoRows) {
		username = strings.TrimSpace(username)
		if username == "" {
			username = provider + "-" + providerID
		}
		if displayName == "" {
			displayName = username
		}
		candidate := username
		for suffix := 0; ; suffix++ {
			if suffix > 0 {
				candidate = fmt.Sprintf("%s-%d", username, suffix)
			}
			var exists int
			err = tx.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, candidate).Scan(&exists)
			if err != nil {
				return 0, err
			}
			if exists == 0 {
				break
			}
		}
		result, err := tx.Exec(`INSERT INTO users(username,display_name,email,avatar_url) VALUES(?,?,NULLIF(?,''),?)`,
			candidate, displayName, email, avatar)
		if err != nil {
			return 0, err
		}
		userID, err = result.LastInsertId()
		if err != nil {
			return 0, err
		}
	} else if err != nil {
		return 0, err
	}
	if _, err := tx.Exec(`INSERT INTO oauth_accounts(provider,provider_user_id,user_id) VALUES(?,?,?)`, provider, providerID, userID); err != nil {
		return 0, err
	}
	return userID, tx.Commit()
}

func (s *Store) Session(token string) (*User, string, error) {
	var u User
	var csrf string
	err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active,s.csrf_token
		FROM sessions s JOIN users u ON u.id=s.user_id
		WHERE s.token_hash=? AND s.expires_at>? AND u.active=1`, tokenHash(token), time.Now()).
		Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active, &csrf)
	if err != nil {
		return nil, "", err
	}
	return &u, csrf, nil
}

func (s *Store) DeleteSession(token string) {
	_, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token))
}

func (s *Store) SetUserActive(userID, actorID int64, active bool) error {
	if userID < 1 {
		return errors.New("account was not found")
	}
	if !active && userID == actorID {
		return errors.New("you cannot lock your own account")
	}
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var role string
	var currentlyActive bool
	if err := tx.QueryRow(`SELECT role,active FROM users WHERE id=?`, userID).Scan(&role, &currentlyActive); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return errors.New("account was not found")
		}
		return err
	}
	if currentlyActive == active {
		return tx.Commit()
	}
	if !active && role == "admin" {
		var activeAdmins int
		if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin' AND active=1`).Scan(&activeAdmins); err != nil {
			return err
		}
		if activeAdmins <= 1 {
			return errors.New("the last active administrator cannot be locked")
		}
	}
	activeValue := 0
	if active {
		activeValue = 1
	}
	if _, err := tx.Exec(`UPDATE users SET active=? WHERE id=?`, activeValue, userID); err != nil {
		return err
	}
	if !active {
		if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Store) SetUserPassword(userID int64, password string) error {
	if userID < 1 {
		return errors.New("account was not found")
	}
	if len(password) < 8 {
		return errors.New("password must be at least 8 characters")
	}
	hash, err := hashPassword(password)
	if err != nil {
		return err
	}
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	result, err := tx.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
	if err != nil {
		return err
	}
	if count, _ := result.RowsAffected(); count == 0 {
		return errors.New("account was not found")
	}
	if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil {
		return err
	}
	return tx.Commit()
}

func (s *Store) DeleteUser(userID, actorID int64) error {
	if userID < 1 {
		return errors.New("account was not found")
	}
	if userID == actorID {
		return errors.New("you cannot delete your own account")
	}
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var role string
	if err := tx.QueryRow(`SELECT role FROM users WHERE id=?`, userID).Scan(&role); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return errors.New("account was not found")
		}
		return err
	}
	if role == "admin" {
		var admins int
		if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&admins); err != nil {
			return err
		}
		if admins <= 1 {
			return errors.New("the last administrator cannot be deleted")
		}
	}
	// Keep historical request records when removing an administrator, while
	// clearing the optional reviewer reference that has no cascade action.
	if _, err := tx.Exec(`UPDATE requests SET reviewed_by=NULL WHERE reviewed_by=?`, userID); err != nil {
		return err
	}
	if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
		return err
	}
	return tx.Commit()
}

func (s *Store) SetUserAvatar(userID int64, avatarURL string) error {
	_, err := s.db.Exec(`UPDATE users SET avatar_url=? WHERE id=?`, avatarURL, userID)
	return err
}

func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
	var a Attendance
	err := s.db.QueryRow(`SELECT id,user_id,day,
		COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode
		FROM attendance WHERE user_id=? AND day=?`, userID, day).
		Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	return &a, err
}

func (s *Store) CheckIn(userID int64, day, mode string) error {
	if mode != "remote" {
		mode = "office"
	}
	_, err := s.db.Exec(`INSERT INTO attendance(user_id,day,check_in,mode) VALUES(?,?,?,?)
		ON CONFLICT(user_id,day) DO UPDATE SET check_in=COALESCE(attendance.check_in,excluded.check_in),mode=excluded.mode`,
		userID, day, time.Now().In(time.Local).Format(time.RFC3339Nano), mode)
	return err
}

func (s *Store) CheckOut(userID int64, day string) error {
	result, err := s.db.Exec(`UPDATE attendance SET check_out=? WHERE user_id=? AND day=? AND check_in IS NOT NULL AND check_out IS NULL`,
		time.Now().In(time.Local).Format(time.RFC3339Nano), userID, day)
	if err != nil {
		return err
	}
	n, _ := result.RowsAffected()
	if n == 0 {
		return errors.New("check in before checking out")
	}
	return nil
}

func (s *Store) AttendanceBetween(userID int64, start, end string) (map[string]Attendance, error) {
	rows, err := s.db.Query(`SELECT id,user_id,day,
		COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode FROM attendance
		WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]Attendance{}
	for rows.Next() {
		var a Attendance
		if err := rows.Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode); err != nil {
			return nil, err
		}
		out[a.Day] = a
	}
	return out, rows.Err()
}

func (s *Store) CreateRequest(userID int64, kind, start, end, reason string) error {
	if kind != "leave" && kind != "remote" {
		return errors.New("invalid request type")
	}
	_, err := s.db.Exec(`INSERT INTO requests(user_id,kind,start_date,end_date,reason) VALUES(?,?,?,?,?)`,
		userID, kind, start, end, strings.TrimSpace(reason))
	return err
}

func (s *Store) Requests(userID int64, admin bool, status string) ([]Request, error) {
	query := `SELECT r.id,r.user_id,u.display_name,r.kind,r.start_date,r.end_date,r.reason,r.status,
		r.admin_note,r.created_at,r.reviewed_at,COALESCE(a.display_name,'')
		FROM requests r JOIN users u ON u.id=r.user_id LEFT JOIN users a ON a.id=r.reviewed_by`
	args := []any{}
	clauses := []string{}
	if !admin {
		clauses = append(clauses, "r.user_id=?")
		args = append(args, userID)
	}
	if status != "" {
		clauses = append(clauses, "r.status=?")
		args = append(args, status)
	}
	if len(clauses) > 0 {
		query += " WHERE " + strings.Join(clauses, " AND ")
	}
	query += " ORDER BY CASE r.status WHEN 'pending' THEN 0 ELSE 1 END,r.created_at DESC"
	rows, err := s.db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Request
	for rows.Next() {
		var r Request
		if err := rows.Scan(&r.ID, &r.UserID, &r.UserName, &r.Kind, &r.StartDate, &r.EndDate,
			&r.Reason, &r.Status, &r.AdminNote, &r.CreatedAt, &r.ReviewedAt, &r.Reviewer); err != nil {
			return nil, err
		}
		start, _ := time.Parse("2006-01-02", r.StartDate)
		end, _ := time.Parse("2006-01-02", r.EndDate)
		r.DayCount = int(end.Sub(start).Hours()/24) + 1
		out = append(out, r)
	}
	return out, rows.Err()
}

func (s *Store) ReviewRequest(ctx context.Context, id, reviewerID int64, status, note string) error {
	if status != "approved" && status != "rejected" {
		return errors.New("invalid review decision")
	}
	result, err := s.db.ExecContext(ctx, `UPDATE requests SET status=?,admin_note=?,reviewed_by=?,reviewed_at=?
		WHERE id=? AND status='pending'`, status, strings.TrimSpace(note), reviewerID, time.Now(), id)
	if err != nil {
		return err
	}
	n, _ := result.RowsAffected()
	if n == 0 {
		return errors.New("request was already reviewed")
	}
	return nil
}

func (s *Store) CancelRequest(id, userID int64) error {
	_, err := s.db.Exec(`UPDATE requests SET status='cancelled' WHERE id=? AND user_id=? AND status='pending'`, id, userID)
	return err
}

func (s *Store) ReportRows(start, end string) (*sql.Rows, error) {
	return s.db.Query(`SELECT u.display_name,u.username,a.day,
		COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
		COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
		a.mode,
		COALESCE((SELECT r.kind FROM requests r WHERE r.user_id=u.id AND r.status='approved'
			AND a.day BETWEEN r.start_date AND r.end_date ORDER BY r.id DESC LIMIT 1),'')
		FROM attendance a JOIN users u ON u.id=a.user_id
		WHERE a.day BETWEEN ? AND ? ORDER BY a.day,u.display_name`, start, end)
}

func (s *Store) Stats(userID int64, start, end string) (present, remote, leave int, err error) {
	err = s.db.QueryRow(`SELECT
		COUNT(*),COALESCE(SUM(CASE WHEN mode='remote' THEN 1 ELSE 0 END),0)
		FROM attendance WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end).Scan(&present, &remote)
	if err != nil {
		return
	}
	err = s.db.QueryRow(`SELECT COALESCE(SUM(julianday(end_date)-julianday(start_date)+1),0)
		FROM requests WHERE user_id=? AND kind='leave' AND status='approved'
		AND start_date<=? AND end_date>=?`, userID, end, start).Scan(&leave)
	return
}

func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
	rows, err := s.db.Query(`SELECT
		u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
		COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
		COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
		COALESCE(a.mode,''),
		COALESCE((
			SELECT r.kind FROM requests r
			WHERE r.user_id=u.id AND r.status='approved' AND ? BETWEEN r.start_date AND r.end_date
			ORDER BY CASE r.kind WHEN 'leave' THEN 0 ELSE 1 END,r.id DESC
			LIMIT 1
		),'')
		FROM users u
		LEFT JOIN attendance a ON a.user_id=u.id AND a.day=?
		WHERE u.active=1
		ORDER BY u.display_name`, day, day)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var roster []DayRosterRow
	for rows.Next() {
		var row DayRosterRow
		if err := rows.Scan(
			&row.User.ID, &row.User.Username, &row.User.DisplayName, &row.User.Email,
			&row.User.Role, &row.User.AvatarURL, &row.CheckIn, &row.CheckOut,
			&row.Mode, &row.RequestKind,
		); err != nil {
			return nil, err
		}
		roster = append(roster, row)
	}
	return roster, rows.Err()
}

func (s *Store) CreateWorkUpdate(userID int64, start, end, status, note string) error {
	note = strings.TrimSpace(note)
	if status != "done" && status != "blocked" && status != "pending" {
		return errors.New("choose a valid update status")
	}
	if note == "" {
		return errors.New("describe the work, outcome, or blocker")
	}
	if len([]rune(note)) > 4000 {
		return errors.New("work update must be 4,000 characters or fewer")
	}
	_, err := s.db.Exec(`INSERT INTO work_updates(user_id,period_start,period_end,status,note) VALUES(?,?,?,?,?)`,
		userID, start, end, status, note)
	return err
}

func (s *Store) WorkUpdates(start, end string, limit int) ([]WorkUpdate, error) {
	query := `SELECT w.id,w.user_id,u.display_name,u.username,u.avatar_url,
		w.period_start,w.period_end,w.status,w.note,CAST(w.created_at AS TEXT)
		FROM work_updates w JOIN users u ON u.id=w.user_id`
	args := []any{}
	if start != "" && end != "" {
		query += ` WHERE w.period_start<=? AND w.period_end>=?`
		args = append(args, end, start)
	}
	query += ` ORDER BY w.created_at DESC,w.id DESC`
	if limit > 0 {
		query += ` LIMIT ?`
		args = append(args, limit)
	}
	rows, err := s.db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var updates []WorkUpdate
	for rows.Next() {
		var update WorkUpdate
		if err := rows.Scan(
			&update.ID, &update.UserID, &update.UserName, &update.Username, &update.AvatarURL,
			&update.PeriodStart, &update.PeriodEnd, &update.Status, &update.Note, &update.CreatedAt,
		); err != nil {
			return nil, err
		}
		updates = append(updates, update)
	}
	return updates, rows.Err()
}

func (s *Store) DeleteWorkUpdate(id, userID int64) error {
	result, err := s.db.Exec(`DELETE FROM work_updates WHERE id=? AND user_id=?`, id, userID)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("work update was not found")
	}
	return nil
}

func (s *Store) ReportSummary(start, end string) ([]PersonReportSummary, error) {
	rows, err := s.db.Query(`SELECT
		u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
		(SELECT COUNT(*) FROM attendance a
			WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='office'),
		(SELECT COUNT(*) FROM attendance a
			WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='remote'),
		COALESCE((SELECT CAST(SUM(
			julianday(MIN(r.end_date,?))-julianday(MAX(r.start_date,?))+1
		) AS INTEGER) FROM requests r
			WHERE r.user_id=u.id AND r.kind='leave' AND r.status='approved'
			AND r.start_date<=? AND r.end_date>=?),0),
		(SELECT COUNT(*) FROM work_updates w
			WHERE w.user_id=u.id AND w.status='done' AND w.period_start<=? AND w.period_end>=?),
		(SELECT COUNT(*) FROM work_updates w
			WHERE w.user_id=u.id AND w.status='blocked' AND w.period_start<=? AND w.period_end>=?),
		(SELECT COUNT(*) FROM work_updates w
			WHERE w.user_id=u.id AND w.status='pending' AND w.period_start<=? AND w.period_end>=?)
		FROM users u ORDER BY u.display_name`,
		start, end,
		start, end,
		end, start, end, start,
		end, start,
		end, start,
		end, start,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var summaries []PersonReportSummary
	for rows.Next() {
		var summary PersonReportSummary
		if err := rows.Scan(
			&summary.User.ID, &summary.User.Username, &summary.User.DisplayName, &summary.User.Email,
			&summary.User.Role, &summary.User.AvatarURL, &summary.PresentDays, &summary.RemoteDays,
			&summary.LeaveDays, &summary.DoneUpdates, &summary.Blocked, &summary.Pending,
		); err != nil {
			return nil, err
		}
		summaries = append(summaries, summary)
	}
	return summaries, rows.Err()
}

func validBoardStatus(status string) bool {
	return status == "backlog" || status == "todo" || status == "in_progress" || status == "blocked" || status == "done"
}

func validBoardImportance(importance string) bool {
	return importance == "low" || importance == "normal" || importance == "high" || importance == "urgent"
}

func (s *Store) CreateBoardTask(creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
	return s.CreateBoardTaskInWorkspace(1, creatorID, title, description, status, importance, assigneeIDs, dueDate)
}

func (s *Store) CreateBoardTaskInWorkspace(workspaceID, creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
	title = strings.TrimSpace(title)
	description = strings.TrimSpace(description)
	if title == "" {
		return 0, errors.New("task title is required")
	}
	if len([]rune(title)) > 160 || len([]rune(description)) > 4000 {
		return 0, errors.New("task title or description is too long")
	}
	if !validBoardStatus(status) {
		status = "backlog"
	}
	if !validBoardImportance(importance) {
		importance = "normal"
	}
	var due any
	if dueDate != "" {
		due = dueDate
	}
	tx, err := s.db.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()
	result, err := tx.Exec(`INSERT INTO board_tasks(workspace_id,title,description,status,importance,creator_id,due_date,created_at)
		VALUES(?,?,?,?,?,?,?,?)`, workspaceID, title, description, status, importance, creatorID, due, time.Now().UTC())
	if err != nil {
		return 0, err
	}
	taskID, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}
	if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
		return 0, err
	}
	return taskID, tx.Commit()
}

func (s *Store) BoardTasks() ([]BoardTask, error) {
	return s.boardTasks(false, 0)
}

func (s *Store) BoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
	return s.boardTasks(false, workspaceID)
}

func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
	return s.boardTasks(true, 0)
}

func (s *Store) ArchivedBoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
	return s.boardTasks(true, workspaceID)
}

func (s *Store) boardTasks(archived bool, workspaceID int64) ([]BoardTask, error) {
	query := `SELECT
		t.id,t.title,t.description,t.status,t.importance,
		t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT),
		COALESCE(CAST(t.archived_at AS TEXT),'')
		FROM board_tasks t
		JOIN users c ON c.id=t.creator_id`
	if archived {
		query += ` WHERE t.archived_at IS NOT NULL` + func() string {
			if workspaceID > 0 {
				return ` AND t.workspace_id=?`
			}
			return ``
		}() + `
			ORDER BY t.archived_at DESC,t.id DESC`
	} else {
		query += ` WHERE t.archived_at IS NULL` + func() string {
			if workspaceID > 0 {
				return ` AND t.workspace_id=?`
			}
			return ``
		}() + `
			ORDER BY CASE t.status
			WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END,
			t.created_at DESC`
	}
	var rows *sql.Rows
	var err error
	if workspaceID > 0 {
		rows, err = s.db.Query(query, workspaceID)
	} else {
		rows, err = s.db.Query(query)
	}
	if err != nil {
		return nil, err
	}
	var tasks []BoardTask
	for rows.Next() {
		var task BoardTask
		if err := rows.Scan(
			&task.ID, &task.Title, &task.Description, &task.Status, &task.Importance,
			&task.CreatorID, &task.CreatorName,
			&task.DueDate, &task.CreatedAt, &task.ArchivedAt,
		); err != nil {
			return nil, err
		}
		tasks = append(tasks, task)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	taskIndex := make(map[int64]int, len(tasks))
	for index := range tasks {
		taskIndex[tasks[index].ID] = index
	}
	assigneeRows, err := s.db.Query(`SELECT a.task_id,u.id,u.username,u.display_name,
		COALESCE(u.email,''),u.role,u.avatar_url,u.active
		FROM board_task_assignees a JOIN users u ON u.id=a.user_id
		ORDER BY u.display_name`)
	if err != nil {
		return nil, err
	}
	for assigneeRows.Next() {
		var taskID int64
		var user User
		if err := assigneeRows.Scan(
			&taskID, &user.ID, &user.Username, &user.DisplayName,
			&user.Email, &user.Role, &user.AvatarURL, &user.Active,
		); err != nil {
			assigneeRows.Close()
			return nil, err
		}
		if index, ok := taskIndex[taskID]; ok {
			tasks[index].Assignees = append(tasks[index].Assignees, user)
		}
	}
	if err := assigneeRows.Err(); err != nil {
		assigneeRows.Close()
		return nil, err
	}
	if err := assigneeRows.Close(); err != nil {
		return nil, err
	}
	tagRows, err := s.db.Query(`SELECT tt.task_id,t.id,t.name,t.color
		FROM board_task_tags tt JOIN board_tags t ON t.id=tt.tag_id
		ORDER BY t.name`)
	if err != nil {
		return nil, err
	}
	defer tagRows.Close()
	for tagRows.Next() {
		var taskID int64
		var tag BoardTag
		if err := tagRows.Scan(&taskID, &tag.ID, &tag.Name, &tag.Color); err != nil {
			return nil, err
		}
		if index, ok := taskIndex[taskID]; ok {
			tasks[index].Tags = append(tasks[index].Tags, tag)
		}
	}
	if err := tagRows.Err(); err != nil {
		return nil, err
	}
	todoRows, err := s.db.Query(`SELECT id,task_id,body,completed FROM board_task_todos
		ORDER BY completed,created_at,id`)
	if err != nil {
		return nil, err
	}
	defer todoRows.Close()
	for todoRows.Next() {
		var todo BoardTodo
		if err := todoRows.Scan(&todo.ID, &todo.TaskID, &todo.Body, &todo.Completed); err != nil {
			return nil, err
		}
		if index, ok := taskIndex[todo.TaskID]; ok {
			tasks[index].Todos = append(tasks[index].Todos, todo)
			if todo.Completed {
				tasks[index].TodoDone++
			}
		}
	}
	return tasks, todoRows.Err()
}

type sqlExecutor interface {
	Exec(query string, args ...any) (sql.Result, error)
}

func setBoardTaskAssignees(executor sqlExecutor, taskID int64, userIDs []int64) error {
	seen := map[int64]bool{}
	for _, userID := range userIDs {
		if userID < 1 || seen[userID] {
			continue
		}
		seen[userID] = true
		if _, err := executor.Exec(`INSERT INTO board_task_assignees(task_id,user_id)
			SELECT ?,id FROM users WHERE id=? AND active=1`, taskID, userID); err != nil {
			return err
		}
	}
	return nil
}

func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importance string) error {
	if !validBoardImportance(importance) {
		return errors.New("choose a valid importance")
	}
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	result, err := tx.Exec(`UPDATE board_tasks SET importance=? WHERE id=?`, importance, taskID)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("task was not found")
	}
	if _, err := tx.Exec(`DELETE FROM board_task_assignees WHERE task_id=?`, taskID); err != nil {
		return err
	}
	if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
		return err
	}
	return tx.Commit()
}

func (s *Store) CreateBoardTaskTodo(taskID int64, body string) error {
	body = strings.TrimSpace(body)
	if body == "" || len([]rune(body)) > 240 {
		return errors.New("to-do item must be between 1 and 240 characters")
	}
	result, err := s.db.Exec(`INSERT INTO board_task_todos(task_id,body) SELECT id,? FROM board_tasks WHERE id=?`, body, taskID)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("task was not found")
	}
	return nil
}

func (s *Store) SetBoardTaskTodoCompleted(taskID, todoID int64, completed bool) error {
	value := 0
	if completed {
		value = 1
	}
	result, err := s.db.Exec(`UPDATE board_task_todos SET completed=? WHERE id=? AND task_id=?`, value, todoID, taskID)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("to-do item was not found")
	}
	return nil
}

func (s *Store) DeleteBoardTaskTodo(taskID, todoID int64) error {
	result, err := s.db.Exec(`DELETE FROM board_task_todos WHERE id=? AND task_id=?`, todoID, taskID)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("to-do item was not found")
	}
	return nil
}

func (s *Store) MoveBoardTask(id int64, status string) error {
	if !validBoardStatus(status) {
		return errors.New("invalid board column")
	}
	result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=? AND archived_at IS NULL`, status, id)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("task was not found")
	}
	return nil
}

func (s *Store) SetBoardTaskArchived(id, userID int64, admin, archived bool) error {
	archiveValue := any(nil)
	if archived {
		archiveValue = time.Now().UTC()
	}
	query := `UPDATE board_tasks SET archived_at=? WHERE id=?`
	args := []any{archiveValue, id}
	if !admin {
		query += ` AND creator_id=?`
		args = append(args, userID)
	}
	result, err := s.db.Exec(query, args...)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("only the creator or an admin can archive or restore this task")
	}
	return nil
}

func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
	query := `DELETE FROM board_tasks WHERE id=?`
	args := []any{id}
	if !admin {
		query += ` AND creator_id=?`
		args = append(args, userID)
	}
	result, err := s.db.Exec(query, args...)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("only the creator or an admin can delete this task")
	}
	return nil
}

func validTagColor(color string) bool {
	switch color {
	case "green", "blue", "red", "amber", "purple", "slate":
		return true
	default:
		return false
	}
}

func (s *Store) CreateBoardTag(name, color string) error {
	name = strings.TrimSpace(name)
	color = strings.TrimSpace(color)
	if name == "" || len([]rune(name)) > 40 {
		return errors.New("tag name must be between 1 and 40 characters")
	}
	if !validTagColor(color) {
		return errors.New("choose a valid tag color")
	}
	_, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)`, name, color)
	if err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") {
		return errors.New("a tag with that name already exists")
	}
	return err
}

func (s *Store) FindOrCreateBoardTag(name, color string) (BoardTag, error) {
	name = strings.TrimSpace(name)
	color = strings.TrimSpace(color)
	if name == "" || len([]rune(name)) > 40 {
		return BoardTag{}, errors.New("tag name must be between 1 and 40 characters")
	}
	if !validTagColor(color) {
		return BoardTag{}, errors.New("choose a valid tag color")
	}
	if _, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)
		ON CONFLICT(name) DO NOTHING`, name, color); err != nil {
		return BoardTag{}, err
	}
	var tag BoardTag
	err := s.db.QueryRow(`SELECT id,name,color FROM board_tags
		WHERE name = ? COLLATE NOCASE`, name).Scan(&tag.ID, &tag.Name, &tag.Color)
	return tag, err
}

func (s *Store) BoardTags() ([]BoardTag, error) {
	rows, err := s.db.Query(`SELECT id,name,color FROM board_tags ORDER BY name`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var tags []BoardTag
	for rows.Next() {
		var tag BoardTag
		if err := rows.Scan(&tag.ID, &tag.Name, &tag.Color); err != nil {
			return nil, err
		}
		tags = append(tags, tag)
	}
	return tags, rows.Err()
}

func (s *Store) SetBoardTaskTags(taskID int64, tagIDs []int64) error {
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var exists int
	if err := tx.QueryRow(`SELECT COUNT(*) FROM board_tasks WHERE id=?`, taskID).Scan(&exists); err != nil {
		return err
	}
	if exists == 0 {
		return errors.New("task was not found")
	}
	if _, err := tx.Exec(`DELETE FROM board_task_tags WHERE task_id=?`, taskID); err != nil {
		return err
	}
	seen := map[int64]bool{}
	for _, tagID := range tagIDs {
		if tagID < 1 || seen[tagID] {
			continue
		}
		seen[tagID] = true
		if _, err := tx.Exec(`INSERT INTO board_task_tags(task_id,tag_id)
			SELECT ?,id FROM board_tags WHERE id=?`, taskID, tagID); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Store) DeleteBoardTag(id int64) error {
	result, err := s.db.Exec(`DELETE FROM board_tags WHERE id=?`, id)
	if err != nil {
		return err
	}
	count, _ := result.RowsAffected()
	if count == 0 {
		return errors.New("tag was not found")
	}
	return nil
}
