package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/tasks"
)

// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
// writes the page made.
type fakeTaskCore struct {
	ipc.UnimplementedCoreAPI

	tasks   []ipc.Task
	listErr error

	captured   []ipc.CaptureTaskReq
	created    bool
	captureErr error

	statusID  int64
	statusVal string
	statusBy  string
	statusErr error

	promoted bool
}

func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	return f.tasks, nil
}

func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
	f.captured = append(f.captured, req)
	if f.captureErr != nil {
		return ipc.CaptureTaskResp{}, f.captureErr
	}
	return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
}

func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
	f.statusID, f.statusVal, f.statusBy = id, status, by
	return f.statusErr
}

func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
	now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	resolved := now.Add(time.Hour)
	core := &fakeTaskCore{tasks: []ipc.Task{
		{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
		{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
		{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
	}}
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	body := rec.Body.String()
	for _, want := range []string{
		"купить молоко", "продлить страховку", "полить цветы",
		"полис истекает",       // the evidence trail is visible for review
		"found, not confirmed", // candidates get their own section
	} {
		if !strings.Contains(body, want) {
			t.Errorf("body missing %q", want)
		}
	}
	// The candidate must offer the intake form, and it asks for a definition of
	// done before it will confirm anything (V-511).
	if !strings.Contains(body, "value=promote") {
		t.Error("candidate row has no confirm action")
	}
	if !strings.Contains(body, "name=done_when") {
		t.Error("the confirm form does not ask for a definition of done")
	}
}

func TestHandleTasksAddCaptures(t *testing.T) {
	core := &fakeTaskCore{created: true}
	form := url.Values{"action": {"add"}, "text": {"  позвонить в банк "}, "due": {"2026-08-05"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handleTasks(rec, req, core)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if len(core.captured) != 1 {
		t.Fatalf("captured %d requests, want 1", len(core.captured))
	}
	got := core.captured[0]
	if got.Text != "позвонить в банк" {
		t.Errorf("text = %q, want trimmed", got.Text)
	}
	if got.Source != "tap:web" {
		t.Errorf("source = %q, want tap:web", got.Source)
	}
	if got.Status != "open" {
		t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
	}
	if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
		t.Errorf("due = %v", got.Due)
	}
	if !strings.Contains(rec.Body.String(), "added task") {
		t.Error("no confirmation message")
	}
}

func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
	core := &fakeTaskCore{created: false}
	form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handleTasks(rec, req, core)
	if !strings.Contains(rec.Body.String(), "already on the list") {
		t.Error("a deduped capture must not claim it saved something new")
	}
}

func TestHandleTasksStatusActions(t *testing.T) {
	for _, tc := range []struct{ action, want string }{
		{"confirm", "open"},
		{"done", "done"},
		{"drop", "dropped"},
	} {
		core := &fakeTaskCore{}
		form := url.Values{"action": {tc.action}, "id": {"42"}}
		req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		handleTasks(httptest.NewRecorder(), req, core)
		if core.statusID != 42 || core.statusVal != tc.want {
			t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
		}
	}
}

func TestHandleTasksRejectsBadPost(t *testing.T) {
	core := &fakeTaskCore{}
	form := url.Values{"action": {"explode"}, "id": {"1"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handleTasks(rec, req, core)
	// The page still renders, with the error inline — and nothing was written.
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if core.statusVal != "" || len(core.captured) != 0 {
		t.Error("an unknown action must write nothing")
	}
	if !strings.Contains(rec.Body.String(), "unknown action") {
		t.Error("error not surfaced on the page")
	}
}

func TestHandleTasksNoCore(t *testing.T) {
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
	if rec.Code != http.StatusServiceUnavailable {
		t.Errorf("status = %d, want 503", rec.Code)
	}
}

// The open list is ordered by the ranker, and the reason is shown so the page
// says why a task is first instead of asking him to trust the order.
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
	now := time.Now()
	due := now
	core := &fakeTaskCore{tasks: []ipc.Task{
		{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
		{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
	}}
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
	body := rec.Body.String()
	if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
		t.Error("want the dated task rendered first")
	}
	if !strings.Contains(body, "сегодня") {
		t.Error("want the ranker's reason shown in the why column")
	}
}

// A candidate is ranked into place but never carries a priority reason: its due
// date is Maven's reading of a mail, not something he stated.
func TestHandleTasksHidesCandidateReason(t *testing.T) {
	now := time.Now()
	due := now
	core := &fakeTaskCore{tasks: []ipc.Task{
		{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
	}}
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
	if strings.Contains(rec.Body.String(), "сегодня") {
		t.Error("a candidate must not be shown with a priority reason")
	}
}

func TestApplyTaskPostCarriesWeight(t *testing.T) {
	core := &fakeTaskCore{created: true}
	form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	handleTasks(httptest.NewRecorder(), req, core)
	if len(core.captured) != 1 || core.captured[0].Weight != 3 {
		t.Fatalf("captured = %+v, want weight 3", core.captured)
	}
}

// Out of range clamps rather than 400s; a non-number is a real client error.
func TestApplyTaskPostClampsWeight(t *testing.T) {
	core := &fakeTaskCore{created: true}
	form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	handleTasks(httptest.NewRecorder(), req, core)
	if core.captured[0].Weight != tasks.MaxWeight {
		t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
	}
}

// The page ranked with the wall clock while the daemon path ranked with a clock
// it was handed, so this was the one surface that could only be tested at
// whatever time it happened to run.
func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
	fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
	old := now
	now = func() time.Time { return fixed }
	t.Cleanup(func() { now = old })

	// Due tomorrow, local time, stored the way the store hands it back: UTC.
	due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
	core := &fakeTaskCore{tasks: []ipc.Task{
		{ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
	}}
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
	body := rec.Body.String()
	if !strings.Contains(body, "завтра") {
		t.Errorf("why column does not say завтра: %q", why(body))
	}
	if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
		t.Error("a task due tomorrow was ranked as today's or overdue")
	}
}

// why is a crude excerpt of the rendered why column, for a readable failure.
func why(body string) string {
	i := strings.Index(body, "<td class=hint>")
	if i < 0 {
		return body
	}
	j := i + 200
	if j > len(body) {
		j = len(body)
	}
	return body[i:j]
}

// The resolved section rendered every row that ever existed.
func TestHandleTasksBoundsResolved(t *testing.T) {
	base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
	var rows []ipc.Task
	for i := 0; i < resolvedShown+10; i++ {
		ts := base.Add(time.Duration(i) * time.Minute)
		rows = append(rows, ipc.Task{
			ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
			CreatedTs: ts, Resolved: &ts,
		})
	}
	core := &fakeTaskCore{tasks: rows}
	rec := httptest.NewRecorder()
	handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
	body := rec.Body.String()
	if n := strings.Count(body, "задача "); n != resolvedShown {
		t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
	}
	if !strings.Contains(body, "most recent are shown") {
		t.Error("the page must say it is showing only part of the history")
	}
}

// A capture over a candidate is a confirmation, not a duplicate.
func TestHandleTasksAddSaysPromoted(t *testing.T) {
	core := &fakeTaskCore{promoted: true}
	form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handleTasks(rec, req, core)
	if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
		t.Error("a promoted capture must not read as a duplicate")
	}
}

// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
	core := &fakeTaskCore{created: true}
	form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handleTasks(rec, req, core)
	if len(core.captured) != 0 {
		t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
	}
	if !strings.Contains(rec.Body.String(), "bad weight") {
		t.Error("error not surfaced on the page")
	}

	core = &fakeTaskCore{}
	form = url.Values{"action": {"done"}, "id": {"42junk"}}
	req = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	handleTasks(httptest.NewRecorder(), req, core)
	if core.statusID != 0 {
		t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
	}
}

// A resolution says what resolved it: resolved_ts recorded when and never by
// what.
func TestHandleTasksRecordsTheCaller(t *testing.T) {
	core := &fakeTaskCore{}
	form := url.Values{"action": {"done"}, "id": {"42"}}
	req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	handleTasks(httptest.NewRecorder(), req, core)
	if core.statusBy != "tap:web" {
		t.Errorf("resolved by %q, want tap:web", core.statusBy)
	}
}
