package main

import (
	"database/sql"
	"embed"
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"time"

	"fn-registry/functions/core"
	"fn-registry/functions/infra"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

type Column struct {
	ID             string `json:"id"`
	Name           string `json:"name"`
	Position       int    `json:"position"`
	Location       string `json:"location"`
	Width          int    `json:"width"`
	WIPLimit       int    `json:"wip_limit"`
	IsDone         bool   `json:"is_done"`
	MaxTimeMinutes int    `json:"max_time_minutes"`
	CreatedAt      string `json:"created_at"`
}

type Sticker struct {
	Emoji string  `json:"emoji"`
	X     float64 `json:"x"`
	Y     float64 `json:"y"`
}

type Card struct {
	ID           string    `json:"id"`
	SeqNum       int       `json:"seq_num"`
	Requester    string    `json:"requester"`
	Title        string    `json:"title"`
	Description  string    `json:"description"`
	Color        string    `json:"color"`
	ColumnID     string    `json:"column_id"`
	Position     int       `json:"position"`
	Locked       bool      `json:"locked"`
	AssigneeID   *string   `json:"assignee_id"`
	CompletedAt  *string   `json:"completed_at"`
	DeletedAt    *string   `json:"deleted_at"`
	ArchivedAt   *string   `json:"archived_at"`
	Tags         []string  `json:"tags"`
	Stickers     []Sticker `json:"stickers"`
	Deadline     *string   `json:"deadline"`
	CreatedAt    string    `json:"created_at"`
	UpdatedAt    string    `json:"updated_at"`
	EnteredAt     string  `json:"entered_at"`
	TimeInColumn  int64   `json:"time_in_column_ms"`
	LockedAt      *string `json:"locked_at"`
	TotalLockedMs int64   `json:"total_locked_ms"`
}

type HistoryEntry struct {
	ID         string  `json:"id"`
	CardID     string  `json:"card_id"`
	ColumnID   string  `json:"column_id"`
	ColumnName string  `json:"column_name"`
	EnteredAt  string  `json:"entered_at"`
	ExitedAt   *string `json:"exited_at"`
	DurationMs int64   `json:"duration_ms"`
	ActorID    *string `json:"actor_id"`
}

type LockPeriod struct {
	ID         string  `json:"id"`
	CardID     string  `json:"card_id"`
	LockedAt   string  `json:"locked_at"`
	UnlockedAt *string `json:"unlocked_at"`
	DurationMs int64   `json:"duration_ms"`
	ActorID    *string `json:"actor_id"`
}

type CardHistoryResponse struct {
	ColumnHistory  []HistoryEntry `json:"column_history"`
	LockPeriods    []LockPeriod   `json:"lock_periods"`
	Events         []CardEvent    `json:"events"`
	TotalLockedMs  int64          `json:"total_locked_ms"`
	CurrentlyLock  bool           `json:"currently_locked"`
}

type CardEvent struct {
	ID        string  `json:"id"`
	CardID    string  `json:"card_id"`
	Kind      string  `json:"kind"`
	ActorID   *string `json:"actor_id"`
	Payload   string  `json:"payload"`
	CreatedAt string  `json:"created_at"`
}

type DB struct{ conn *sql.DB }

func openDB(path string) (*DB, error) {
	conn, err := infra.SQLiteOpen(path, "")
	if err != nil {
		return nil, err
	}
	if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil {
		conn.Close()
		return nil, fmt.Errorf("migrate: %w", err)
	}
	// Idempotent backstop for very old DBs whose schema diverged before
	// migration files existed. New columns SIEMPRE se añaden via migracion.
	if err := ensureColumns(conn); err != nil {
		conn.Close()
		return nil, fmt.Errorf("ensure columns: %w", err)
	}
	return &DB{conn: conn}, nil
}

// ensureColumns adds columns missing from older schemas without dropping data.
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
func ensureColumns(conn *sql.DB) error {
	type colSpec struct{ table, name, ddl string }
	specs := []colSpec{
		{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
		{"columns", "width", "INTEGER NOT NULL DEFAULT 300"},
		{"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"},
		{"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"},
		{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
		{"cards", "locked", "INTEGER NOT NULL DEFAULT 0"},
		{"cards", "assignee_id", "TEXT"},
		{"cards", "completed_at", "TEXT"},
		{"cards", "deleted_at", "TEXT"},
		{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
		{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
		{"cards", "deadline", "TEXT"},
		{"card_column_history", "actor_id", "TEXT"},
		{"card_lock_history", "actor_id", "TEXT"},
	}
	for _, s := range specs {
		exists, err := infra.ColumnExists(conn, s.table, s.name)
		if err != nil {
			return err
		}
		if exists {
			continue
		}
		if _, err := conn.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", s.table, s.name, s.ddl)); err != nil {
			return fmt.Errorf("add %s.%s: %w", s.table, s.name, err)
		}
	}
	if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil {
		return fmt.Errorf("create assignee index: %w", err)
	}
	return nil
}

func (db *DB) Close() error { return db.conn.Close() }

func newID() string {
	id, err := core.RandomHexID(8)
	if err != nil {
		panic(fmt.Errorf("kanban: cannot generate id: %w", err))
	}
	return id
}

func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }

func parseTags(s string) []string {
	out := []string{}
	if s == "" {
		return out
	}
	if err := json.Unmarshal([]byte(s), &out); err != nil {
		return []string{}
	}
	return out
}

func normalizeTags(in []string) []string {
	seen := map[string]struct{}{}
	out := []string{}
	for _, t := range in {
		t = strings.TrimSpace(t)
		if t == "" {
			continue
		}
		if _, ok := seen[t]; ok {
			continue
		}
		seen[t] = struct{}{}
		out = append(out, t)
	}
	sort.Strings(out)
	return out
}

func encodeTags(in []string) string {
	b, _ := json.Marshal(normalizeTags(in))
	return string(b)
}

func parseStickers(s string) []Sticker {
	out := []Sticker{}
	if s == "" {
		return out
	}
	if err := json.Unmarshal([]byte(s), &out); err != nil {
		return []Sticker{}
	}
	return out
}

func clamp01(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

func normalizeStickers(in []Sticker) []Sticker {
	out := make([]Sticker, 0, len(in))
	for _, s := range in {
		emoji := strings.TrimSpace(s.Emoji)
		if emoji == "" {
			continue
		}
		out = append(out, Sticker{Emoji: emoji, X: clamp01(s.X), Y: clamp01(s.Y)})
	}
	return out
}

func encodeStickers(in []Sticker) string {
	b, _ := json.Marshal(normalizeStickers(in))
	return string(b)
}

func (db *DB) UpdateStickers(id string, stickers []Sticker) error {
	_, err := db.conn.Exec(`UPDATE cards SET stickers=?, updated_at=? WHERE id=?`, encodeStickers(stickers), nowRFC3339(), id)
	return err
}

func (db *DB) ListAllTags() ([]string, error) {
	rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	seen := map[string]struct{}{}
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return nil, err
		}
		for _, t := range parseTags(s) {
			seen[t] = struct{}{}
		}
	}
	out := make([]string, 0, len(seen))
	for k := range seen {
		out = append(out, k)
	}
	sort.Strings(out)
	return out, nil
}

func (db *DB) ListDistinctRequesters() ([]string, error) {
	rows, err := db.conn.Query(`SELECT DISTINCT requester FROM cards WHERE deleted_at IS NULL AND requester != '' ORDER BY requester`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []string{}
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return nil, err
		}
		out = append(out, s)
	}
	return out, rows.Err()
}

func nullableActor(actorID string) any {
	if actorID == "" {
		return nil
	}
	return actorID
}

// insertEvent registra un evento timeline de la card. tx puede ser nil para usar conn.
func insertCardEvent(execer interface {
	Exec(string, ...any) (sql.Result, error)
}, cardID, kind, actorID string, payload any) error {
	pj, _ := json.Marshal(payload)
	_, err := execer.Exec(
		`INSERT INTO card_events (id, card_id, kind, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
		newID(), cardID, kind, nullableActor(actorID), string(pj), nowRFC3339(),
	)
	return err
}

// --- Columns ---

func (db *DB) ListColumns() ([]Column, error) {
	rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []Column{}
	for rows.Next() {
		var c Column
		var isDone int
		if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
			return nil, err
		}
		c.IsDone = isDone != 0
		out = append(out, c)
	}
	return out, rows.Err()
}

func (db *DB) CreateColumn(name string) (*Column, error) {
	var maxPos sql.NullInt64
	if err := db.conn.QueryRow(`SELECT MAX(position) FROM columns`).Scan(&maxPos); err != nil {
		return nil, err
	}
	pos := 0
	if maxPos.Valid {
		pos = int(maxPos.Int64) + 1
	}
	c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()}
	_, err := db.conn.Exec(
		`INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt,
	)
	if err != nil {
		return nil, err
	}
	return &c, nil
}

type ColumnPatch struct {
	Name           *string
	Position       *int
	Location       *string
	Width          *int
	WIPLimit       *int
	IsDone         *bool
	MaxTimeMinutes *int
}

func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
	if patch.Name != nil {
		if _, err := db.conn.Exec(`UPDATE columns SET name=? WHERE id=?`, *patch.Name, id); err != nil {
			return err
		}
	}
	if patch.Position != nil {
		if _, err := db.conn.Exec(`UPDATE columns SET position=? WHERE id=?`, *patch.Position, id); err != nil {
			return err
		}
	}
	if patch.Location != nil {
		if *patch.Location != "board" && *patch.Location != "sidebar" {
			return fmt.Errorf("invalid location: %s", *patch.Location)
		}
		if _, err := db.conn.Exec(`UPDATE columns SET location=? WHERE id=?`, *patch.Location, id); err != nil {
			return err
		}
	}
	if patch.Width != nil {
		w := *patch.Width
		if w < 200 {
			w = 200
		} else if w > 800 {
			w = 800
		}
		if _, err := db.conn.Exec(`UPDATE columns SET width=? WHERE id=?`, w, id); err != nil {
			return err
		}
	}
	if patch.WIPLimit != nil {
		l := *patch.WIPLimit
		if l < 0 {
			l = 0
		}
		if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil {
			return err
		}
	}
	if patch.IsDone != nil {
		v := 0
		if *patch.IsDone {
			v = 1
		}
		if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil {
			return err
		}
		// Re-evaluate completed_at for cards in this column.
		now := nowRFC3339()
		if v == 1 {
			if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil {
				return err
			}
		} else {
			if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil {
				return err
			}
		}
	}
	if patch.MaxTimeMinutes != nil {
		m := *patch.MaxTimeMinutes
		if m < 0 {
			m = 0
		}
		if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
			return err
		}
	}
	return nil
}

func (db *DB) DeleteColumn(id string) error {
	_, err := db.conn.Exec(`DELETE FROM columns WHERE id=?`, id)
	return err
}

func (db *DB) ReorderColumns(ids []string) error {
	tx, err := db.conn.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	for i, id := range ids {
		if _, err := tx.Exec(`UPDATE columns SET position=? WHERE id=?`, i, id); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// --- Cards ---

func (db *DB) ListCardsWithTime() ([]Card, error) {
	rows, err := db.conn.Query(`
		SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
		       h.entered_at, l.locked_at,
		       COALESCE((
		         SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
		         FROM card_lock_history WHERE card_id = c.id
		       ), 0) AS total_locked_ms
		FROM cards c
		LEFT JOIN card_column_history h
		  ON h.card_id = c.id AND h.exited_at IS NULL
		LEFT JOIN card_lock_history l
		  ON l.card_id = c.id AND l.unlocked_at IS NULL
		WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
		ORDER BY c.column_id, c.position, c.created_at
	`, time.Now().UTC().Format(time.RFC3339Nano))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	now := time.Now().UTC()
	out := []Card{}
	for rows.Next() {
		var c Card
		var entered sql.NullString
		var assignee sql.NullString
		var completed sql.NullString
		var deleted sql.NullString
		var archived sql.NullString
		var tagsJSON string
		var stickersJSON string
		var deadline sql.NullString
		var lockedAt sql.NullString
		var locked int
		if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
			return nil, err
		}
		c.Stickers = parseStickers(stickersJSON)
		if deadline.Valid && deadline.String != "" {
			s := deadline.String
			c.Deadline = &s
		}
		if lockedAt.Valid && lockedAt.String != "" {
			s := lockedAt.String
			c.LockedAt = &s
		}
		c.Locked = locked != 0
		if assignee.Valid && assignee.String != "" {
			s := assignee.String
			c.AssigneeID = &s
		}
		if completed.Valid && completed.String != "" {
			s := completed.String
			c.CompletedAt = &s
		}
		if deleted.Valid && deleted.String != "" {
			s := deleted.String
			c.DeletedAt = &s
		}
		if archived.Valid && archived.String != "" {
			s := archived.String
			c.ArchivedAt = &s
		}
		c.Tags = parseTags(tagsJSON)
		if entered.Valid {
			c.EnteredAt = entered.String
			if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
				c.TimeInColumn = now.Sub(t).Milliseconds()
			}
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) {
	var maxPos sql.NullInt64
	if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
		return nil, err
	}
	pos := 0
	if maxPos.Valid {
		pos = int(maxPos.Int64) + 1
	}
	now := nowRFC3339()
	tx, err := db.conn.Begin()
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	var maxSeq sql.NullInt64
	if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
		return nil, err
	}
	seqNum := 1
	if maxSeq.Valid {
		seqNum = int(maxSeq.Int64) + 1
	}
	c := Card{
		ID: newID(), SeqNum: seqNum, Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
		Tags:      []string{},
		Stickers:  []Sticker{},
		CreatedAt: now, UpdatedAt: now, EnteredAt: now,
	}
	if _, err := tx.Exec(
		`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
	); err != nil {
		return nil, err
	}
	if _, err := tx.Exec(
		`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
		newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
	); err != nil {
		return nil, err
	}
	// If the destination column is_done, set completed_at.
	var destDone int
	if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, columnID).Scan(&destDone); err != nil {
		return nil, err
	}
	if destDone == 1 {
		if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
			return nil, err
		}
		c.CompletedAt = &now
	}
	if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": title, "column_id": columnID}); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return &c, nil
}

type CardPatch struct {
	Requester   *string
	Title       *string
	Description *string
	Color       *string
	Locked      *bool
	AssigneeID  *string // empty string clears assignment
	HasAssignee bool    // distinguishes "set to null" from "not provided"
	Tags        *[]string
	Deadline    *string // empty string clears deadline
	HasDeadline bool    // distinguishes "set to null" from "not provided"
}

func (db *DB) UpdateCard(id string, patch CardPatch) error {
	return db.UpdateCardWithActor(id, patch, "")
}

func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error {
	tx, err := db.conn.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if patch.Requester != nil {
		var oldReq string
		_ = tx.QueryRow(`SELECT requester FROM cards WHERE id=?`, id).Scan(&oldReq)
		if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil {
			return err
		}
		if oldReq != *patch.Requester {
			if err := insertCardEvent(tx, id, "requester_changed", actorID, map[string]any{"old": oldReq, "new": *patch.Requester}); err != nil {
				return err
			}
		}
	}
	if patch.Title != nil {
		var oldTitle string
		_ = tx.QueryRow(`SELECT title FROM cards WHERE id=?`, id).Scan(&oldTitle)
		if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil {
			return err
		}
		if oldTitle != *patch.Title {
			if err := insertCardEvent(tx, id, "title_changed", actorID, map[string]any{"old": oldTitle, "new": *patch.Title}); err != nil {
				return err
			}
		}
	}
	if patch.Description != nil {
		var oldDesc string
		_ = tx.QueryRow(`SELECT description FROM cards WHERE id=?`, id).Scan(&oldDesc)
		if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil {
			return err
		}
		if oldDesc != *patch.Description {
			if err := insertCardEvent(tx, id, "description_changed", actorID, map[string]any{}); err != nil {
				return err
			}
		}
	}
	if patch.Color != nil {
		if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
			return err
		}
		if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil {
			return err
		}
	}
	if patch.HasAssignee {
		var oldAssignee sql.NullString
		_ = tx.QueryRow(`SELECT assignee_id FROM cards WHERE id=?`, id).Scan(&oldAssignee)
		if patch.AssigneeID == nil || *patch.AssigneeID == "" {
			if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
				return err
			}
			if oldAssignee.Valid && oldAssignee.String != "" {
				if err := insertCardEvent(tx, id, "unassigned", actorID, map[string]any{"prev": oldAssignee.String}); err != nil {
					return err
				}
			}
		} else {
			if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
				return err
			}
			if oldAssignee.String != *patch.AssigneeID {
				if err := insertCardEvent(tx, id, "assigned", actorID, map[string]any{"assignee_id": *patch.AssigneeID}); err != nil {
					return err
				}
			}
		}
	}
	if patch.Tags != nil {
		if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
			return err
		}
		if err := insertCardEvent(tx, id, "tags_changed", actorID, map[string]any{"tags": *patch.Tags}); err != nil {
			return err
		}
	}
	if patch.HasDeadline {
		var oldDeadline sql.NullString
		_ = tx.QueryRow(`SELECT deadline FROM cards WHERE id=?`, id).Scan(&oldDeadline)
		if patch.Deadline == nil || *patch.Deadline == "" {
			if _, err := tx.Exec(`UPDATE cards SET deadline=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
				return err
			}
			if oldDeadline.Valid && oldDeadline.String != "" {
				if err := insertCardEvent(tx, id, "deadline_cleared", actorID, map[string]any{"prev": oldDeadline.String}); err != nil {
					return err
				}
			}
		} else {
			if _, err := tx.Exec(`UPDATE cards SET deadline=?, updated_at=? WHERE id=?`, *patch.Deadline, nowRFC3339(), id); err != nil {
				return err
			}
			if oldDeadline.String != *patch.Deadline {
				if err := insertCardEvent(tx, id, "deadline_set", actorID, map[string]any{"deadline": *patch.Deadline}); err != nil {
					return err
				}
			}
		}
	}
	if patch.Locked != nil {
		var current int
		if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(&current); err != nil {
			return err
		}
		desired := 0
		if *patch.Locked {
			desired = 1
		}
		if current != desired {
			now := nowRFC3339()
			if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil {
				return err
			}
			if desired == 1 {
				if _, err := tx.Exec(
					`INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`,
					newID(), id, now, nullableActor(actorID),
				); err != nil {
					return err
				}
			} else {
				if _, err := tx.Exec(
					`UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`,
					now, id,
				); err != nil {
					return err
				}
			}
		}
	}
	return tx.Commit()
}

// DeleteCard soft-deletes the card (moves it to trash).
func (db *DB) DeleteCard(id string) error {
	return db.DeleteCardWithActor(id, "")
}

func (db *DB) DeleteCardWithActor(id, actorID string) error {
	tx, err := db.conn.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id); err != nil {
		return err
	}
	if err := insertCardEvent(tx, id, "deleted", actorID, map[string]any{}); err != nil {
		return err
	}
	return tx.Commit()
}

// RestoreCard removes the deleted_at flag.
func (db *DB) RestoreCard(id string) error {
	return db.RestoreCardWithActor(id, "")
}

func (db *DB) RestoreCardWithActor(id, actorID string) error {
	tx, err := db.conn.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
		return err
	}
	if err := insertCardEvent(tx, id, "restored", actorID, map[string]any{}); err != nil {
		return err
	}
	return tx.Commit()
}

// PurgeCard permanently removes the card from the DB.
func (db *DB) PurgeCard(id string) error {
	_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
	return err
}

// ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) {
	rows, err := db.conn.Query(`
		SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
		FROM cards c
		WHERE c.deleted_at IS NOT NULL
		ORDER BY c.deleted_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []Card{}
	for rows.Next() {
		var c Card
		var assignee sql.NullString
		var completed sql.NullString
		var deleted sql.NullString
		var tagsJSON string
		var stickersJSON string
		var deadline sql.NullString
		var locked int
		if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
			return nil, err
		}
		c.Stickers = parseStickers(stickersJSON)
		if deadline.Valid && deadline.String != "" {
			s := deadline.String
			c.Deadline = &s
		}
		c.Locked = locked != 0
		if assignee.Valid && assignee.String != "" {
			s := assignee.String
			c.AssigneeID = &s
		}
		if completed.Valid && completed.String != "" {
			s := completed.String
			c.CompletedAt = &s
		}
		if deleted.Valid {
			s := deleted.String
			c.DeletedAt = &s
		}
		c.Tags = parseTags(tagsJSON)
		out = append(out, c)
	}
	return out, rows.Err()
}

// ArchiveCard moves a card to the archive (out of the board, retrievable).
// Used both manually and by AutoArchiveDoneOlderThan.
func (db *DB) ArchiveCard(id string) error {
	now := nowRFC3339()
	_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
	return err
}

// UnarchiveCard pulls a card out of the archive back into its column.
func (db *DB) UnarchiveCard(id string) error {
	now := nowRFC3339()
	_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
	return err
}

// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
// whose entered_at in that column is older than `older`. Idempotent: cards
// already archived or deleted are skipped. Returns the count affected.
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
	cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
	now := nowRFC3339()
	res, err := db.conn.Exec(`
		UPDATE cards SET archived_at=?, updated_at=?
		WHERE archived_at IS NULL
		  AND deleted_at IS NULL
		  AND column_id IN (SELECT id FROM columns WHERE is_done=1)
		  AND id IN (
		    SELECT card_id FROM card_column_history
		    WHERE exited_at IS NULL AND entered_at < ?
		  )
	`, now, now, cutoff)
	if err != nil {
		return 0, err
	}
	n, _ := res.RowsAffected()
	return n, nil
}

// ListArchivedCards returns cards in the archive, newest first.
func (db *DB) ListArchivedCards() ([]Card, error) {
	rows, err := db.conn.Query(`
		SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
		FROM cards c
		WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
		ORDER BY c.archived_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []Card{}
	for rows.Next() {
		var c Card
		var assignee, completed, deleted, archived sql.NullString
		var tagsJSON, stickersJSON string
		var deadline sql.NullString
		var locked int
		if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
			return nil, err
		}
		c.Stickers = parseStickers(stickersJSON)
		if deadline.Valid && deadline.String != "" {
			s := deadline.String
			c.Deadline = &s
		}
		c.Locked = locked != 0
		if assignee.Valid && assignee.String != "" {
			s := assignee.String
			c.AssigneeID = &s
		}
		if completed.Valid && completed.String != "" {
			s := completed.String
			c.CompletedAt = &s
		}
		if archived.Valid && archived.String != "" {
			s := archived.String
			c.ArchivedAt = &s
		}
		c.Tags = parseTags(tagsJSON)
		out = append(out, c)
	}
	return out, rows.Err()
}

// MoveCard updates the card's column and/or position. If the column changes,
// the open history entry is closed and a new one is opened.
// orderedIDs is the new order of cards in the destination column (including this card).
// actorID is the user performing the move (empty string for system/anonymous).
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error {
	tx, err := db.conn.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	var srcColumnID string
	var locked int
	if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil {
		return fmt.Errorf("card not found: %w", err)
	}
	if locked != 0 && srcColumnID != destColumnID {
		return fmt.Errorf("card locked: cannot move between columns")
	}

	now := nowRFC3339()

	if srcColumnID != destColumnID {
		if _, err := tx.Exec(
			`UPDATE card_column_history SET exited_at=? WHERE card_id=? AND exited_at IS NULL`,
			now, cardID,
		); err != nil {
			return err
		}
		if _, err := tx.Exec(
			`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
			newID(), cardID, destColumnID, now, nullableActor(actorID),
		); err != nil {
			return err
		}
		_ = actorID
		if _, err := tx.Exec(
			`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
			destColumnID, now, cardID,
		); err != nil {
			return err
		}
		// Recompute completed_at based on destination column's is_done flag.
		var destDone int
		if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil {
			return err
		}
		if destDone == 1 {
			if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil {
				return err
			}
			// Auto-assign: if card had no assignee and an actor is moving it, claim it.
			if actorID != "" {
				if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil {
					return err
				}
			}
		} else {
			if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil {
				return err
			}
		}
	}

	for i, id := range orderedIDs {
		if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, id); err != nil {
			return err
		}
	}

	// Re-pack source column positions to keep them dense.
	if srcColumnID != destColumnID {
		rows, err := tx.Query(`SELECT id FROM cards WHERE column_id=? ORDER BY position, created_at`, srcColumnID)
		if err != nil {
			return err
		}
		var srcIDs []string
		for rows.Next() {
			var sid string
			if err := rows.Scan(&sid); err != nil {
				rows.Close()
				return err
			}
			srcIDs = append(srcIDs, sid)
		}
		rows.Close()
		for i, sid := range srcIDs {
			if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, sid); err != nil {
				return err
			}
		}
	}

	return tx.Commit()
}

func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
	rows, err := db.conn.Query(`
		SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at, h.actor_id
		FROM card_column_history h
		LEFT JOIN columns c ON c.id = h.column_id
		WHERE h.card_id=?
		ORDER BY h.entered_at
	`, cardID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	now := time.Now().UTC()
	cols := []HistoryEntry{}
	for rows.Next() {
		var h HistoryEntry
		var exited sql.NullString
		var actor sql.NullString
		if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited, &actor); err != nil {
			return nil, err
		}
		if actor.Valid && actor.String != "" {
			s := actor.String
			h.ActorID = &s
		}
		entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
		if err != nil {
			return nil, err
		}
		var end time.Time
		if exited.Valid {
			h.ExitedAt = &exited.String
			end, _ = time.Parse(time.RFC3339Nano, exited.String)
		} else {
			end = now
		}
		h.DurationMs = end.Sub(entered).Milliseconds()
		cols = append(cols, h)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	lockRows, err := db.conn.Query(`
		SELECT id, card_id, locked_at, unlocked_at, actor_id
		FROM card_lock_history
		WHERE card_id=?
		ORDER BY locked_at
	`, cardID)
	if err != nil {
		return nil, err
	}
	defer lockRows.Close()
	locks := []LockPeriod{}
	var totalLocked int64
	currently := false
	for lockRows.Next() {
		var lp LockPeriod
		var unlocked sql.NullString
		var actor sql.NullString
		if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked, &actor); err != nil {
			return nil, err
		}
		if actor.Valid && actor.String != "" {
			s := actor.String
			lp.ActorID = &s
		}
		start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
		if err != nil {
			return nil, err
		}
		var end time.Time
		if unlocked.Valid {
			lp.UnlockedAt = &unlocked.String
			end, _ = time.Parse(time.RFC3339Nano, unlocked.String)
		} else {
			end = now
			currently = true
		}
		lp.DurationMs = end.Sub(start).Milliseconds()
		totalLocked += lp.DurationMs
		locks = append(locks, lp)
	}
	if err := lockRows.Err(); err != nil {
		return nil, err
	}

	evRows, err := db.conn.Query(`
		SELECT id, card_id, kind, actor_id, payload, created_at
		FROM card_events
		WHERE card_id=?
		ORDER BY created_at
	`, cardID)
	if err != nil {
		return nil, err
	}
	defer evRows.Close()
	events := []CardEvent{}
	for evRows.Next() {
		var e CardEvent
		var actor sql.NullString
		if err := evRows.Scan(&e.ID, &e.CardID, &e.Kind, &actor, &e.Payload, &e.CreatedAt); err != nil {
			return nil, err
		}
		if actor.Valid && actor.String != "" {
			s := actor.String
			e.ActorID = &s
		}
		events = append(events, e)
	}

	return &CardHistoryResponse{
		ColumnHistory: cols,
		LockPeriods:   locks,
		Events:        events,
		TotalLockedMs: totalLocked,
		CurrentlyLock: currently,
	}, nil
}

type CardMessage struct {
	ID        string  `json:"id"`
	CardID    string  `json:"card_id"`
	AuthorID  *string `json:"author_id"`
	Body      string  `json:"body"`
	CreatedAt string  `json:"created_at"`
}

func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
	rows, err := db.conn.Query(
		`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
		cardID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []CardMessage{}
	for rows.Next() {
		var m CardMessage
		var author sql.NullString
		if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
			return nil, err
		}
		if author.Valid && author.String != "" {
			s := author.String
			m.AuthorID = &s
		}
		out = append(out, m)
	}
	return out, rows.Err()
}

func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
	body = strings.TrimSpace(body)
	if body == "" {
		return nil, fmt.Errorf("body required")
	}
	if authorID == "" {
		return nil, fmt.Errorf("author required")
	}
	var exists int
	if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
		return nil, fmt.Errorf("card not found: %w", err)
	}
	s := authorID
	m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
	if _, err := db.conn.Exec(
		`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
		m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
	); err != nil {
		return nil, err
	}
	return m, nil
}

func (db *DB) DeleteCardMessage(id, requesterID string) error {
	if requesterID == "" {
		return fmt.Errorf("session required")
	}
	res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return fmt.Errorf("not found or not author")
	}
	return nil
}

// DuplicateCard clones a card into the same column at the end of the list.
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
// Title gets " (copia)" suffix.
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
	tx, err := db.conn.Begin()
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	var src Card
	var assignee sql.NullString
	var deadline sql.NullString
	var tagsJSON, stickersJSON string
	if err := tx.QueryRow(
		`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
		 FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
	).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
		return nil, fmt.Errorf("card not found: %w", err)
	}
	if assignee.Valid && assignee.String != "" {
		s := assignee.String
		src.AssigneeID = &s
	}
	if deadline.Valid && deadline.String != "" {
		s := deadline.String
		src.Deadline = &s
	}
	src.Tags = parseTags(tagsJSON)
	src.Stickers = parseStickers(stickersJSON)

	var maxPos sql.NullInt64
	if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
		return nil, err
	}
	pos := 0
	if maxPos.Valid {
		pos = int(maxPos.Int64) + 1
	}
	var maxSeq sql.NullInt64
	if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
		return nil, err
	}
	seqNum := 1
	if maxSeq.Valid {
		seqNum = int(maxSeq.Int64) + 1
	}
	now := nowRFC3339()
	newTitle := src.Title + " (copia)"
	c := Card{
		ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
		Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
		AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
		CreatedAt: now, UpdatedAt: now, EnteredAt: now,
	}
	var assigneeVal any
	if c.AssigneeID != nil && *c.AssigneeID != "" {
		assigneeVal = *c.AssigneeID
	}
	var deadlineVal any
	if c.Deadline != nil && *c.Deadline != "" {
		deadlineVal = *c.Deadline
	}
	if _, err := tx.Exec(
		`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
		assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
	); err != nil {
		return nil, err
	}
	if _, err := tx.Exec(
		`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
		newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
	); err != nil {
		return nil, err
	}
	var destDone int
	if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
		return nil, err
	}
	if destDone == 1 {
		if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
			return nil, err
		}
		c.CompletedAt = &now
	}
	if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return &c, nil
}
