package store

import (
	"context"
	"errors"
	"fmt"
	"testing"
	"time"
)

func TestCaptureTaskDedupesLiveWork(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)

	first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	if !first.Created {
		t.Fatal("first capture must create a row")
	}

	// Same work, different casing and punctuation — one task, not two.
	again, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "tap:web", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	if again.Created {
		t.Error("second capture of the same live work must not create a row")
	}
	if again.ID != first.ID {
		t.Errorf("dedupe returned id %d, want the existing %d", again.ID, first.ID)
	}

	live, err := st.ListTasks(ctx, "live")
	if err != nil {
		t.Fatal(err)
	}
	if len(live) != 1 {
		t.Fatalf("live tasks = %d, want 1", len(live))
	}
}

func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)

	first, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	id := first.ID
	if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}
	// The dedupe key is free again: a recurring errand must be capturable.
	second, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
	if err != nil {
		t.Fatal(err)
	}
	id2 := second.ID
	if !second.Created || id2 == id {
		t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", second.Created, id2, id)
	}
	live, err := st.ListTasks(ctx, "live")
	if err != nil {
		t.Fatal(err)
	}
	if len(live) != 1 || live[0].ID != id2 {
		t.Fatalf("live = %+v, want only the new task %d", live, id2)
	}
}

func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	due := now.Add(48 * time.Hour)

	res, err := st.CaptureTask(ctx, Task{
		Text:      "продлить страховку",
		Source:    "email:kami",
		Evidence:  "Re: страховой полис истекает",
		Status:    TaskCandidate,
		Due:       &due,
		Weight:    2,
		CreatedTs: now,
	})
	if err != nil {
		t.Fatal(err)
	}
	got, err := st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != TaskCandidate {
		t.Errorf("status = %q, want candidate", got.Status)
	}
	if got.Evidence != "Re: страховой полис истекает" {
		t.Errorf("evidence = %q", got.Evidence)
	}
	if got.Due == nil || !got.Due.Equal(due.UTC()) {
		t.Errorf("due = %v, want %v", got.Due, due.UTC())
	}
	if got.Weight != 2 {
		t.Errorf("weight = %d, want 2", got.Weight)
	}
	if got.ResolvedTs != nil {
		t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
	}
}

func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)

	res, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	cand := res.ID
	// candidate → done is not a legal move: he has to confirm it first.
	if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
		t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
	}
	// Promotion needs a definition of done; see
	// TestPromotingACandidateNeedsADefinitionOfDone for that refusal.
	if err := st.SetTaskFields(ctx, cand, "запись есть", ""); err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}
	// Already resolved — a second resolve must not move it again.
	if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour), "tap:web"); !errors.Is(err, ErrTaskNotFound) {
		t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
	}
	got, err := st.LookupTask(ctx, cand)
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != TaskDone {
		t.Errorf("status = %q, want done", got.Status)
	}
	if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
		t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
	}
}

func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	res, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
	if err != nil {
		t.Fatal(err)
	}
	id := res.ID
	if err := st.SetTaskStatus(ctx, id, "candidate", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
		t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
	}
	if err := st.SetTaskStatus(ctx, id, "urgent", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
		t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
	}
}

func TestCaptureTaskRejectsEmptyText(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	if _, err := st.CaptureTask(ctx, Task{Text: "   ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
		t.Errorf("err = %v, want ErrTaskEmpty", err)
	}
}

func TestListTasksFiltersByStatus(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)

	open1, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
	_, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
	done, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
	if err := st.SetTaskStatus(ctx, done.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}

	cands, err := st.ListTasks(ctx, TaskCandidate)
	if err != nil {
		t.Fatal(err)
	}
	if len(cands) != 1 || cands[0].Text != "вторая" {
		t.Fatalf("candidates = %+v", cands)
	}
	opens, err := st.ListTasks(ctx, TaskOpen)
	if err != nil {
		t.Fatal(err)
	}
	if len(opens) != 1 || opens[0].ID != open1.ID {
		t.Fatalf("open = %+v", opens)
	}
	all, err := st.ListTasks(ctx, "")
	if err != nil {
		t.Fatal(err)
	}
	if len(all) != 3 {
		t.Fatalf("all = %d, want 3", len(all))
	}
	// Newest first.
	if all[0].Text != "третья" {
		t.Errorf("first = %q, want newest ('третья')", all[0].Text)
	}
}

func TestNormalizeTaskText(t *testing.T) {
	cases := []struct{ in, want string }{
		{"Купить молоко!", "купить молоко"},
		{"  купить   МОЛОКО  ", "купить молоко"},
		{"позвонить в банк (важно)", "позвонить в банк важно"},
		{"", ""},
	}
	for _, c := range cases {
		if got := NormalizeTaskText(c.in); got != c.want {
			t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// A mail the reader keeps seeing must not resurrect work he already finished.
// The norm key alone frees on resolve, which is right for voice and wrong for a
// mailbox: mavmaild never marks anything read, so the same message is extracted
// again on every poll, forever.
func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	mail := Task{
		Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
		ExternalID: "email:kami#412:продлить страховку", CreatedTs: now,
	}

	first, err := st.CaptureTask(ctx, mail)
	if err != nil {
		t.Fatal(err)
	}
	if !first.Created {
		t.Fatal("first capture must create a row")
	}
	// He confirms it and does it. Confirming needs a criterion (Vikunja #510).
	if err := st.SetTaskFields(ctx, first.ID, "страховка продлена", ""); err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(2*time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}

	// The next poll reads the same message again.
	mail.CreatedTs = now.AddDate(0, 0, 1)
	again, err := st.CaptureTask(ctx, mail)
	if err != nil {
		t.Fatal(err)
	}
	if again.Created {
		t.Error("re-reading the same mail created a second task after the first was done")
	}
	if again.ID != first.ID {
		t.Errorf("id = %d, want the resolved row %d", again.ID, first.ID)
	}
	live, err := st.ListTasks(ctx, "live")
	if err != nil {
		t.Fatal(err)
	}
	if len(live) != 0 {
		t.Fatalf("live = %+v, want nothing: he already did this", live)
	}
}

// Stating out loud a task Maven only proposed is a confirmation. Leaving it a
// candidate had her read it straight back as something he had not confirmed.
func TestCaptureTaskPromotesCandidate(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)

	cand, err := st.CaptureTask(ctx, Task{
		Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
		ExternalID: "email:kami#7:продлить страховку", CreatedTs: now,
	})
	if err != nil {
		t.Fatal(err)
	}
	spoken, err := st.CaptureTask(ctx, Task{
		Text: "продлить страховку", Source: "tap:voice", Status: TaskOpen,
		CreatedTs: now.Add(time.Minute),
	})
	if err != nil {
		t.Fatal(err)
	}
	if spoken.Created {
		t.Error("capture over a live candidate must not create a second row")
	}
	if !spoken.Promoted {
		t.Error("capture with status open over a candidate must promote it")
	}
	got, err := st.LookupTask(ctx, cand.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != TaskOpen {
		t.Errorf("status = %q, want open", got.Status)
	}
	if got.ResolvedTs != nil {
		t.Errorf("resolved_ts = %v, want nil: the task is still live", got.ResolvedTs)
	}
}

// A derived source may only ever file a candidate. The intake seam documented
// this and nothing enforced it, so a caller could skip review entirely.
func TestCaptureTaskRefusesOpenFromDerivedSource(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	_, err := st.CaptureTask(ctx, Task{Text: "оплатить счёт", Source: "email:kami", Status: TaskOpen})
	if !errors.Is(err, ErrTaskStatus) {
		t.Errorf("err = %v, want ErrTaskStatus", err)
	}
}

// resolved_ts said when a task was resolved and never by what.
func TestSetTaskStatusRecordsWho(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	res, err := st.CaptureTask(ctx, Task{Text: "выкинуть мусор", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:voice"); err != nil {
		t.Fatal(err)
	}
	got, err := st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.ResolvedBy != "tap:voice" {
		t.Errorf("resolved_by = %q, want tap:voice", got.ResolvedBy)
	}
}

// The resolved history only grows; an unbounded read of it is a page that gets
// slower every month.
func TestListTasksIsBounded(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	for i := 0; i < MaxTaskRows+5; i++ {
		res, err := st.CaptureTask(ctx, Task{
			Text: fmt.Sprintf("задача %d", i), Source: "tap:voice",
			CreatedTs: now.Add(time.Duration(i) * time.Minute),
		})
		if err != nil {
			t.Fatal(err)
		}
		if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
			t.Fatal(err)
		}
	}
	all, err := st.ListTasks(ctx, "")
	if err != nil {
		t.Fatal(err)
	}
	if len(all) != MaxTaskRows {
		t.Fatalf("all = %d rows, want the %d-row bound", len(all), MaxTaskRows)
	}
	if all[0].Text != fmt.Sprintf("задача %d", MaxTaskRows+4) {
		t.Errorf("first = %q, want the newest", all[0].Text)
	}
}

func TestTaskBoardColumnsRoundTrip(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)

	res, err := st.CaptureTask(ctx, Task{
		Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now,
		DoneWhen: "квитанция оплачена", BlockedOn: "ent_kate",
	})
	if err != nil {
		t.Fatal(err)
	}
	got, err := st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.DoneWhen != "квитанция оплачена" || got.BlockedOn != "ent_kate" {
		t.Fatalf("task = %+v, want both board columns back", got)
	}

	// Not one-way, unlike a status move: he sharpens the criterion, and the
	// blocker clears when the person answers.
	if err := st.SetTaskFields(ctx, res.ID, "  пришло подтверждение  ", ""); err != nil {
		t.Fatal(err)
	}
	got, err = st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.DoneWhen != "пришло подтверждение" {
		t.Errorf("done_when = %q, want the trimmed rewrite", got.DoneWhen)
	}
	if got.BlockedOn != "" {
		t.Errorf("blocked_on = %q, want it cleared", got.BlockedOn)
	}
	if err := st.SetTaskFields(ctx, 9999, "x", ""); !errors.Is(err, ErrTaskNotFound) {
		t.Errorf("SetTaskFields on a missing row = %v, want ErrTaskNotFound", err)
	}
}

func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)

	res, err := st.CaptureTask(ctx, Task{
		Text: "продлить домен", Source: "email:main", Status: TaskCandidate,
		ExternalID: "msg-1:0", Evidence: "Domain expiring", CreatedTs: now,
	})
	if err != nil {
		t.Fatal(err)
	}
	// A board row whose finish line nobody wrote can never leave the board.
	if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); !errors.Is(err, ErrTaskNoDoneWhen) {
		t.Fatalf("promotion with no criterion = %v, want ErrTaskNoDoneWhen", err)
	}
	// Dropping it stays legal — declining work does not need one.
	dropped, err := st.CaptureTask(ctx, Task{
		Text: "перезвонить в банк", Source: "email:main", Status: TaskCandidate,
		ExternalID: "msg-2:0", CreatedTs: now,
	})
	if err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, dropped.ID, TaskDropped, now, "tap:web"); err != nil {
		t.Fatalf("dropping a candidate with no criterion: %v", err)
	}

	if err := st.SetTaskFields(ctx, res.ID, "домен продлён до 2027", ""); err != nil {
		t.Fatal(err)
	}
	if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); err != nil {
		t.Fatalf("promotion after writing a criterion: %v", err)
	}
	got, err := st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != TaskOpen {
		t.Errorf("status = %q, want open", got.Status)
	}
}

func TestEditTaskRewritesTheCaptureFields(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
	due := now.Add(48 * time.Hour)

	res, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now, Weight: 1})
	if err != nil {
		t.Fatal(err)
	}
	if err := st.EditTask(ctx, res.ID, "  купить молоко  ", &due, 3); err != nil {
		t.Fatal(err)
	}
	got, err := st.LookupTask(ctx, res.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.Text != "купить молоко" || got.Weight != 3 || got.Due == nil || !got.Due.Equal(due) {
		t.Fatalf("task = %+v, want the dictation typo fixed with the date and weight", got)
	}
	// Clearing the date has to be sayable, or an absent date and "remove the
	// date" would be one argument.
	if err := st.EditTask(ctx, res.ID, "купить молоко", nil, 3); err != nil {
		t.Fatal(err)
	}
	if got, err = st.LookupTask(ctx, res.ID); err != nil {
		t.Fatal(err)
	} else if got.Due != nil {
		t.Errorf("due = %v, want it cleared", got.Due)
	}
	// The dedupe key moved with the text: capturing the old wording is new work.
	again, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	if !again.Created {
		t.Error("the old normalised text must be free after the edit")
	}
	if err := st.EditTask(ctx, res.ID, "", nil, 0); !errors.Is(err, ErrTaskEmpty) {
		t.Errorf("empty text = %v, want ErrTaskEmpty", err)
	}
}

func TestEditTaskRefusesACollisionAndAResolvedRow(t *testing.T) {
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)

	first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	second, err := st.CaptureTask(ctx, Task{Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now})
	if err != nil {
		t.Fatal(err)
	}
	// Two live rows carry two provenances and two capture times, so a merge
	// would pick a winner for both with nobody asked.
	if err := st.EditTask(ctx, second.ID, "Купить молоко!", nil, 0); !errors.Is(err, ErrTaskDuplicate) {
		t.Errorf("collision = %v, want ErrTaskDuplicate", err)
	}
	// Editing a row to the text it already has is not a collision with itself.
	if err := st.EditTask(ctx, first.ID, "купить молоко", nil, 2); err != nil {
		t.Errorf("re-saving the same text: %v", err)
	}
	if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
		t.Fatal(err)
	}
	// A resolved task's text is the record of what was finished.
	if err := st.EditTask(ctx, first.ID, "купить кефир", nil, 0); !errors.Is(err, ErrTaskResolved) {
		t.Errorf("editing a resolved task = %v, want ErrTaskResolved", err)
	}
	if err := st.EditTask(ctx, 9999, "что-то", nil, 0); !errors.Is(err, ErrTaskNotFound) {
		t.Errorf("editing a missing row = %v, want ErrTaskNotFound", err)
	}
}
