package store

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"
)

// List items — the fourth append-only shape (Vikunja #453).
//
// A list is a standing set of short strings under a tag: покупки, аптека,
// хозяйство. It is not work and it is not a claim about the world, which is
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
// nudges about it, and the digestion worker does not read it. The only two
// things a list does are grow and shrink.
//
// The consequence that made it worth a table: because no predicate touches a
// list item, several people adding to the same list at once cost nothing. There
// is no ranking to disagree about and no lifecycle beyond crossed-off.
const (
	// ListItemOpen — on the list.
	ListItemOpen = "open"
	// ListItemDone — bought, taken, crossed off.
	ListItemDone = "done"
	// ListItemDropped — removed without being got.
	ListItemDropped = "dropped"
)

// DefaultList — the list a capture lands on when he names none. Almost every
// spoken list item is groceries, and asking "в какой список?" for the common
// case would be a nag.
const DefaultList = "покупки"

// ListItem — one line on one list.
type ListItem struct {
	ID         int64
	CreatedTs  time.Time
	List       string
	Item       string
	Source     string
	Status     string
	ResolvedTs *time.Time
}

var (
	ErrListItemNotFound = errors.New("store: list item not found")
	ErrListItemEmpty    = errors.New("store: list item is empty")
	ErrListItemStatus   = errors.New("store: invalid list item status")
)

// NormalizeListName folds a list tag to its dedupe form. Lists are named out
// loud, so "Покупки" and "покупки " are the same list.
func NormalizeListName(s string) string {
	n := NormalizeTaskText(s)
	if n == "" {
		return DefaultList
	}
	return n
}

// AddListItem puts an item on a list, or returns the existing row when the same
// item is already on it. Created says which happened, so the caller can say
// "уже есть" instead of pretending it wrote something.
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
	item := strings.TrimSpace(li.Item)
	if item == "" {
		return CaptureResult{}, ErrListItemEmpty
	}
	list := NormalizeListName(li.List)
	norm := NormalizeTaskText(item)
	created := li.CreatedTs
	if created.IsZero() {
		created = time.Now()
	}
	res, err := s.db.ExecContext(ctx,
		`INSERT INTO list_items (created_ts, list, item, norm, source, status)
		 VALUES (?,?,?,?,?,?)
		 ON CONFLICT DO NOTHING`,
		created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
	if err != nil {
		return CaptureResult{}, fmt.Errorf("add list item: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
	}
	if n > 0 {
		id, err := res.LastInsertId()
		if err != nil {
			return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
		}
		return CaptureResult{ID: id, Created: true}, nil
	}
	var id int64
	err = s.db.QueryRowContext(ctx,
		`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
		list, norm, ListItemOpen).Scan(&id)
	if errors.Is(err, sql.ErrNoRows) {
		return CaptureResult{}, ErrListItemNotFound
	}
	if err != nil {
		return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
	}
	return CaptureResult{ID: id}, nil
}

// ListItems reads one list in the order it was added. An empty status reads the
// open items, which is what reading the list aloud means.
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
	if status == "" {
		status = ListItemOpen
	}
	rows, err := s.db.QueryContext(ctx,
		`SELECT id, created_ts, list, item, source, status, resolved_ts
		   FROM list_items WHERE list = ? AND status = ?
		  ORDER BY created_ts, id`,
		NormalizeListName(list), status)
	if err != nil {
		return nil, fmt.Errorf("list items: %w", err)
	}
	defer rows.Close()
	var out []ListItem
	for rows.Next() {
		var (
			li       ListItem
			created  int64
			resolved sql.NullInt64
		)
		if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
			return nil, fmt.Errorf("list items: scan: %w", err)
		}
		li.CreatedTs = time.UnixMilli(created)
		if resolved.Valid {
			t := time.UnixMilli(resolved.Int64)
			li.ResolvedTs = &t
		}
		out = append(out, li)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list items: %w", err)
	}
	return out, nil
}

// SetListItemStatus crosses an item off, or removes it. Moving an item that is
// already resolved is not an error — crossing off twice is the same list.
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
	if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
		return fmt.Errorf("%w: %q", ErrListItemStatus, status)
	}
	var resolved sql.NullInt64
	if status != ListItemOpen {
		if at.IsZero() {
			at = time.Now()
		}
		resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
	}
	res, err := s.db.ExecContext(ctx,
		`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
		status, resolved, id)
	if err != nil {
		return fmt.Errorf("set list item status: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("set list item status: rows affected: %w", err)
	}
	if n == 0 {
		return ErrListItemNotFound
	}
	return nil
}

// ClearList crosses off every open item on a list and reports how many. This is
// "всё купил", which is one sentence and must not become one turn per item.
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
	if at.IsZero() {
		at = time.Now()
	}
	res, err := s.db.ExecContext(ctx,
		`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
		ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
	if err != nil {
		return 0, fmt.Errorf("clear list: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return 0, fmt.Errorf("clear list: rows affected: %w", err)
	}
	return int(n), nil
}
