package store

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

// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
// lifecycle instead of a valid-time: captured, then either done or dropped.
//
// Why not facts: a fact is a claim about the world and a correction supersedes
// it (append-only, voids_id). A task is not a claim — it is work, it has a
// state that moves forward once, and the read the prioritiser needs is "every
// live task right now", which over an append-only log would mean replaying
// history on every question.
//
// Nothing in this file schedules, fires or announces anything. Capture is a
// store, not a trigger: a task exists to be answered when asked about, and the
// owner's reminders remain the only thing that speaks unprompted.
const (
	// TaskCandidate — Maven derived this task from something she read (mail,
	// once the email reader exists) and the owner has not confirmed it. A
	// candidate is inert: it is listed as a candidate and never counted as work
	// he agreed to.
	TaskCandidate = "candidate"
	// TaskOpen — work the owner stated himself, or a candidate he confirmed.
	TaskOpen = "open"
	// TaskDone — finished.
	TaskDone = "done"
	// TaskDropped — declined, or a candidate rejected. Kept for provenance, so
	// the same mail cannot resurrect it silently; nothing re-proposes a
	// dropped task.
	TaskDropped = "dropped"
)

// Task — one captured piece of work.
//
// Source is provenance in the same vocabulary facts use: "tap:voice" for
// something he said, "tap:web" for the review page, "email:<account>" for a
// mail-derived candidate. Evidence is the free-text trail a derived task came
// from (a subject line), empty for anything he stated himself — it is what
// makes a candidate reviewable instead of mysterious.
//
// Due is optional. Weight is an explicit importance hint (0 = none), which the
// prioritiser reads; capture never invents one.
// ExternalID is the identity of the thing this task was derived FROM — a
// message id plus the extracted span, for a source that re-reads the same
// immutable text forever. Empty for anything he stated himself.
//
// ResolvedBy names the caller that moved the task to its terminal state, in the
// source vocabulary. Empty while the task is live.
type Task struct {
	ID         int64
	CreatedTs  time.Time
	Text       string
	Source     string
	Evidence   string
	ExternalID string
	Status     string
	Due        *time.Time
	Weight     int
	ResolvedTs *time.Time
	ResolvedBy string

	// DoneWhen — the acceptance criterion, in his words. It must be able to
	// close on either outcome: "it already works" counts as complete, and a
	// criterion only one result satisfies is a wish rather than a definition
	// (Vikunja #510). Empty until he writes one.
	DoneWhen string
	// BlockedOn — a canonical Nexus entity id, never a name. Identity lives in
	// Nexus, so storing "Саша" here would be a second answer to a question
	// Nexus already owns. Empty when nothing blocks the task.
	BlockedOn string
}

// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
// existing candidate this capture turned into open work: he stated out loud a
// task Maven had only proposed, which is a confirmation, and the caller says so
// instead of "уже в списке".
type CaptureResult struct {
	ID       int64
	Created  bool
	Promoted bool
}

var (
	ErrTaskNotFound = errors.New("store: task not found")
	ErrTaskEmpty    = errors.New("store: task text is empty")
	ErrTaskStatus   = errors.New("store: invalid task status")
	// ErrTaskNoDoneWhen — a candidate cannot be promoted to open without a
	// definition of done (Vikunja #510). Same refusal ParseTaskCapture makes
	// for a capture marker with nothing after it: confirming work whose
	// finish line nobody wrote is how a board fills with rows that can never
	// leave it. Dropping such a candidate stays legal.
	ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
	// ErrTaskDuplicate — an edit would give this task the normalised text of
	// another live row (Vikunja #509). A refusal, not a merge: two live rows
	// carry two provenances, two capture times and possibly two external
	// identities, and merging picks a winner for all three silently. The
	// surface tells the owner which row already holds the text and lets him
	// drop one.
	ErrTaskDuplicate = errors.New("store: another live task already has this text")
	// ErrTaskResolved — a resolved task is not editable. Its text is the
	// record of what was finished, and rewriting it rewrites history.
	ErrTaskResolved = errors.New("store: task is resolved")
)

// liveTaskStatuses — the two statuses that count as outstanding work.
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}

// derivedSourcePrefixes — provenance that means "Maven read this somewhere",
// as opposed to "he said it". A task from one of these is a candidate and
// nothing else; see CaptureTask.
var derivedSourcePrefixes = []string{"email:"}

// IsDerivedSource reports whether a task source means Maven inferred the task
// from something she read rather than being told it.
func IsDerivedSource(source string) bool {
	for _, p := range derivedSourcePrefixes {
		if strings.HasPrefix(source, p) {
			return true
		}
	}
	return false
}

// CaptureTask inserts a task, or returns the existing one when the same work is
// already there. The result says which happened, so a caller can tell the owner
// "уже в списке" instead of pretending it wrote something.
//
// Two dedupe keys, because voice and mail have different intake semantics:
//
//   - ExternalID, unique over EVERY row whatever its status. A source that
//     re-reads the same immutable text forever must never resurrect work he has
//     already finished. This is the property the email intake depends on: it
//     may call CaptureTask for every message it extracts from, as often as it
//     likes, without growing the list.
//   - The normalised text among LIVE rows only (the partial unique index in
//     migration #14), for anything with no external identity. A weekly errand
//     captured again once the last one is done must produce a new row, because
//     him saying it again IS the recurrence signal.
//
// A capture with Status open over an existing candidate PROMOTES it. Stating
// the work out loud is a confirmation, and leaving it a candidate would have
// Maven read it back as something he never confirmed.
//
// A derived source may only ever capture a candidate. The doc on the intake
// seam said "must NOT set Status open"; this is where that stops being an
// honour system, so a compromised reader cannot file work he never reviewed.
func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error) {
	text := strings.TrimSpace(t.Text)
	if text == "" {
		return CaptureResult{}, ErrTaskEmpty
	}
	status := t.Status
	if status == "" {
		status = TaskOpen
	}
	if status != TaskCandidate && status != TaskOpen {
		// Capturing straight into a resolved state is meaningless — a task is
		// captured live and moved later.
		return CaptureResult{}, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
	}
	if status == TaskOpen && IsDerivedSource(t.Source) {
		return CaptureResult{}, fmt.Errorf("%w: derived source %q may only capture a candidate", ErrTaskStatus, t.Source)
	}
	norm := NormalizeTaskText(text)
	created2 := t.CreatedTs
	if created2.IsZero() {
		created2 = time.Now()
	}
	var due sql.NullInt64
	if t.Due != nil {
		due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
	}
	var ext sql.NullString
	if e := strings.TrimSpace(t.ExternalID); e != "" {
		ext = sql.NullString{String: e, Valid: true}
	}

	// Untargeted DO NOTHING: either unique index may be the one that fires, and
	// the lookup below sorts out which.
	res, err := s.db.ExecContext(ctx,
		`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight, done_when, blocked_on)
		 VALUES (?,?,?,?,?,?,?,?,?,?,?)
		 ON CONFLICT DO NOTHING`,
		created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight, t.DoneWhen, t.BlockedOn)
	if err != nil {
		return CaptureResult{}, fmt.Errorf("capture task: %w", err)
	}
	if n, err := res.RowsAffected(); err != nil {
		return CaptureResult{}, fmt.Errorf("capture task: rows affected: %w", err)
	} else if n > 0 {
		id, err := res.LastInsertId()
		if err != nil {
			return CaptureResult{}, fmt.Errorf("capture task: last insert id: %w", err)
		}
		return CaptureResult{ID: id, Created: true}, nil
	}

	// Something already holds one of the keys. External identity first: that
	// row may be resolved, in which case the answer is "already handled", not a
	// new task.
	var existing Task
	if ext.Valid {
		existing, err = s.lookupTaskByExternalID(ctx, ext.String)
		if err != nil && !errors.Is(err, ErrTaskNotFound) {
			return CaptureResult{}, err
		}
	}
	if existing.ID == 0 {
		existing, err = s.lookupLiveTaskByNorm(ctx, norm)
		if err != nil {
			return CaptureResult{}, err
		}
	}
	if status == TaskOpen && existing.Status == TaskCandidate {
		if err := s.setTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source, false); err != nil {
			return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
		}
		return CaptureResult{ID: existing.ID, Promoted: true}, nil
	}
	return CaptureResult{ID: existing.ID}, nil
}

// lookupTaskByExternalID finds a task by the identity of what it was derived
// from, in ANY status. Resolved rows count: the whole point of the key is that
// re-reading the mail that produced a finished task produces nothing.
func (s *Store) lookupTaskByExternalID(ctx context.Context, ext string) (Task, error) {
	row := s.db.QueryRowContext(ctx, taskSelect+` WHERE ext_id = ?`, ext)
	t, err := scanTask(row)
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, ErrTaskNotFound
	}
	if err != nil {
		return Task{}, fmt.Errorf("lookup task by external id: %w", err)
	}
	return t, nil
}

// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
	row := s.db.QueryRowContext(ctx, taskSelect+`
		 WHERE norm = ? AND status IN (?,?)`, norm, liveTaskStatuses[0], liveTaskStatuses[1])
	t, err := scanTask(row)
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, ErrTaskNotFound
	}
	if err != nil {
		return Task{}, fmt.Errorf("lookup live task: %w", err)
	}
	return t, nil
}

const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by, done_when, blocked_on FROM tasks`

// LookupTask returns one task by id.
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
	row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
	t, err := scanTask(row)
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
	}
	if err != nil {
		return Task{}, fmt.Errorf("lookup task: %w", err)
	}
	return t, nil
}

// MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a
// person keeps by hand and never approaches this; the resolved set grows for as
// long as the box runs, and an unbounded read of it is a page that gets slower
// every month. Newest first, so the bound drops the oldest finished work.
const MaxTaskRows = 500

// ListTasks returns tasks in one status, newest first, at most MaxTaskRows of
// them. An empty status returns every row; "live" returns candidate + open,
// which is what every read path that means "outstanding work" wants.
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
	q := taskSelect
	var args []any
	switch status {
	case "":
	case "live":
		q += ` WHERE status IN (?,?)`
		args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
	default:
		q += ` WHERE status = ?`
		args = append(args, status)
	}
	q += fmt.Sprintf(` ORDER BY created_ts DESC, id DESC LIMIT %d`, MaxTaskRows)

	rows, err := s.db.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list tasks: %w", err)
	}
	defer rows.Close()
	var out []Task
	for rows.Next() {
		t, err := scanTask(rows)
		if err != nil {
			return nil, fmt.Errorf("list tasks: %w", err)
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// SetTaskStatus moves a task once, forward. The legal moves are:
//
//	candidate → open     (the owner confirms a derived task)
//	candidate → dropped  (he rejects it)
//	open      → done     (finished)
//	open      → dropped  (abandoned)
//
// Anything else — including re-resolving a resolved task — is refused with
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
// tools use: an answered question is not answered twice.
//
// Resolving frees the NORM dedupe key, which is the point: the work can recur
// when he says it again. It does not free an external identity — see
// CaptureTask for why a re-read mailbox must not resurrect finished work.
//
// by names the caller making the move, in the source vocabulary ("tap:web",
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
// says what resolved it.
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
	return s.setTaskStatus(ctx, id, status, ts, by, true)
}

// setTaskStatus — the move, with the promotion gate optional.
//
// It is optional for exactly one caller: CaptureTask promoting a candidate he
// stated out loud (Vikunja #510). Refusing there would deny intake rather than
// ask for a criterion, and a direct open capture never had one either — the gate
// belongs to the deliberate promotion on /tasks, where there is a form to fill.
func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string, gateDoneWhen bool) error {
	var from []string
	switch status {
	case TaskOpen:
		from = []string{TaskCandidate}
	case TaskDone:
		from = []string{TaskOpen}
	case TaskDropped:
		from = []string{TaskCandidate, TaskOpen}
	default:
		return fmt.Errorf("%w: %q", ErrTaskStatus, status)
	}

	// resolved_ts is only meaningful for a terminal state; confirming a
	// candidate leaves it null (the task is still live).
	var resolved sql.NullInt64
	if status == TaskDone || status == TaskDropped {
		resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
	}

	q := `UPDATE tasks SET status = ?, resolved_ts = ?, resolved_by = ? WHERE id = ? AND status IN (?` +
		strings.Repeat(",?", len(from)-1) + `)`
	args := []any{status, resolved, by, id}
	for _, f := range from {
		args = append(args, f)
	}
	if status == TaskOpen && gateDoneWhen {
		// Promotion needs an acceptance criterion. Checked in the same
		// statement rather than read-then-write, so two callers confirming one
		// candidate cannot race past it; the row is read afterwards only to say
		// WHICH refusal this was.
		q += ` AND done_when <> ''`
	}
	res, err := s.db.ExecContext(ctx, q, args...)
	if err != nil {
		return fmt.Errorf("set task status: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("set task status: rows affected: %w", err)
	}
	if n == 0 {
		if status == TaskOpen && gateDoneWhen {
			if t, lookErr := s.LookupTask(ctx, id); lookErr == nil && t.Status == TaskCandidate && t.DoneWhen == "" {
				return fmt.Errorf("%w: id=%d", ErrTaskNoDoneWhen, id)
			}
		}
		return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
	}
	return nil
}

// EditTask rewrites the three fields capture set and nothing else: text, due
// date and weight (Vikunja #509). Status stays the one-way ladder SetTaskStatus
// owns, and a resolved task is refused outright — its text is the record of
// what was finished.
//
// Editing text re-normalises the dedupe key, which can collide with another
// live row. That is ErrTaskDuplicate and it is a refusal: merging would pick
// one row's provenance, capture time and external identity over the other's
// with nobody asked.
//
// due nil clears the date. Clearing has to be sayable, so an absent date and
// "remove the date" cannot be the same argument.
func (s *Store) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
	text = strings.TrimSpace(text)
	if text == "" {
		return ErrTaskEmpty
	}
	cur, err := s.LookupTask(ctx, id)
	if err != nil {
		return err
	}
	if cur.Status != TaskCandidate && cur.Status != TaskOpen {
		return fmt.Errorf("%w: id=%d is %s", ErrTaskResolved, id, cur.Status)
	}
	norm := NormalizeTaskText(text)
	if norm != NormalizeTaskText(cur.Text) {
		if other, err := s.lookupLiveTaskByNorm(ctx, norm); err == nil && other.ID != id {
			return fmt.Errorf("%w: id=%d holds it", ErrTaskDuplicate, other.ID)
		} else if err != nil && !errors.Is(err, ErrTaskNotFound) {
			return err
		}
	}
	var dueVal sql.NullInt64
	if due != nil {
		dueVal = sql.NullInt64{Int64: due.UnixMilli(), Valid: true}
	}
	if _, err := s.db.ExecContext(ctx,
		`UPDATE tasks SET text = ?, norm = ?, due_ts = ?, weight = ? WHERE id = ?`,
		text, norm, dueVal, weight, id); err != nil {
		return fmt.Errorf("edit task: %w", err)
	}
	return nil
}

// SetTaskFields writes the two board columns. Separate from SetTaskStatus
// because a status move is one-way and these are not: he may sharpen a
// definition of done, and a blocker clears when the person answers.
//
// blockedOn is a canonical Nexus entity id or empty. Free text does not belong
// here — identity lives in Nexus, and a local name would be a second answer to
// a question Nexus already owns. The caller resolves before it writes.
func (s *Store) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
	res, err := s.db.ExecContext(ctx,
		`UPDATE tasks SET done_when = ?, blocked_on = ? WHERE id = ?`,
		strings.TrimSpace(doneWhen), strings.TrimSpace(blockedOn), id)
	if err != nil {
		return fmt.Errorf("set task fields: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("set task fields: rows affected: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
	}
	return nil
}

// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
// whitespace collapsed. Exported because the intake seam (and its tests) needs
// to reason about what will and will not be treated as the same task.
//
// Deliberately shallow — no stemming, no synonyms. Russian morphology would
// need a real lemmatiser to do better, and a normaliser that guesses would
// silently swallow two different tasks. This only catches the case that
// actually happens: the same sentence arriving twice with different casing or
// punctuation.
func NormalizeTaskText(s string) string {
	var b strings.Builder
	space := true // leading space collapses to nothing
	for _, r := range strings.ToLower(s) {
		switch {
		case unicode.IsLetter(r) || unicode.IsDigit(r):
			b.WriteRune(r)
			space = false
		case !space:
			b.WriteRune(' ')
			space = true
		}
	}
	return strings.TrimSpace(b.String())
}

func scanTask(sc scanner) (Task, error) {
	var t Task
	var created int64
	var due, resolved sql.NullInt64
	if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy, &t.DoneWhen, &t.BlockedOn); err != nil {
		return Task{}, err
	}
	t.CreatedTs = time.UnixMilli(created).UTC()
	t.Due = millisToTime(due)
	t.ResolvedTs = millisToTime(resolved)
	return t, nil
}
