package vocab

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"testing"

	"github.com/go-chi/chi/v5"

	"gitea.parodia.dev/drwily/petal/internal/db"
)

func newTestServer(t *testing.T) (http.Handler, *db.DB) {
	t.Helper()
	database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	t.Cleanup(func() { database.Close() })
	r := chi.NewRouter()
	r.Mount("/vocab", New(database).Routes())
	return r, database
}

func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
	t.Helper()
	var r *http.Request
	if body != "" {
		r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
	} else {
		r = httptest.NewRequest(method, path, nil)
	}
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, r)
	return rec
}

// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
// in the future, not yet due), re-capture refreshes context without resetting
// schedule, a forced-due word shows up in /due, a review reschedules it, and
// delete removes it.
func TestCaptureUpsertAndReview(t *testing.T) {
	srv, database := newTestServer(t)

	// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
	rec := do(t, srv, http.MethodPost, "/vocab",
		`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
	}
	var w Word
	_ = json.Unmarshal(rec.Body.Bytes(), &w)
	if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
		t.Fatalf("captured word wrong: %+v", w)
	}
	if w.IntervalDays != 1 || w.Reps != 0 {
		t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
	}

	// Not due yet (due tomorrow).
	rec = do(t, srv, http.MethodGet, "/vocab/due", "")
	var due []Word
	_ = json.Unmarshal(rec.Body.Bytes(), &due)
	if len(due) != 0 {
		t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
	}

	// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
	rec = do(t, srv, http.MethodPost, "/vocab",
		`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
	}
	rec = do(t, srv, http.MethodGet, "/vocab", "")
	var all []Word
	_ = json.Unmarshal(rec.Body.Bytes(), &all)
	if len(all) != 1 {
		t.Fatalf("re-capture should not add a row, got %d", len(all))
	}
	if all[0].Gloss != "意外的好运" {
		t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
	}
	if all[0].Example != "What a serendipity." {
		t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
	}

	// Force it due now, then it appears in /due.
	if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
		t.Fatalf("force due: %v", err)
	}
	rec = do(t, srv, http.MethodGet, "/vocab/due", "")
	_ = json.Unmarshal(rec.Body.Bytes(), &due)
	if len(due) != 1 {
		t.Fatalf("forced-due word should be in /due, got %d", len(due))
	}

	// Review "good" → reschedules forward (interval 3 = second ladder rung, since
	// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
	rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
	}
	var reviewed Word
	_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
	if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
		t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
	}
	if reviewed.LastReviewed == nil {
		t.Fatalf("review should stamp last_reviewed")
	}

	// After a forward review it's no longer due.
	rec = do(t, srv, http.MethodGet, "/vocab/due", "")
	_ = json.Unmarshal(rec.Body.Bytes(), &due)
	if len(due) != 0 {
		t.Fatalf("reviewed word should leave /due, got %d", len(due))
	}

	// Bad grade → 400.
	if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
		t.Fatalf("bad grade: want 400, got %d", rec.Code)
	}

	// Delete → 204, garden empty.
	if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
		t.Fatalf("delete: code=%d", rec.Code)
	}
	if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
		t.Fatalf("re-delete: want 404, got %d", rec.Code)
	}
}

// TestCaptureValidation rejects an empty word.
func TestCaptureValidation(t *testing.T) {
	srv, _ := newTestServer(t)
	if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"   "}`); rec.Code != http.StatusBadRequest {
		t.Fatalf("empty word: want 400, got %d", rec.Code)
	}
}

// TestCaptureStoresDefinitionFallback proves a word with an English definition
// but no Chinese gloss still round-trips its definition, so review has a meaning
// to reveal instead of a blank card.
func TestCaptureStoresDefinitionFallback(t *testing.T) {
	srv, _ := newTestServer(t)
	rec := do(t, srv, http.MethodPost, "/vocab",
		`{"word":"ineffable","definition":"too great to be expressed in words"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
	}
	var w Word
	_ = json.Unmarshal(rec.Body.Bytes(), &w)
	if w.Gloss != "" {
		t.Fatalf("expected no gloss, got %q", w.Gloss)
	}
	if w.Definition != "too great to be expressed in words" {
		t.Fatalf("definition not stored, got %q", w.Definition)
	}
}

// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
// like the lexicon, so the idempotent upsert isn't defeated by casing.
func TestCaptureCaseInsensitive(t *testing.T) {
	srv, _ := newTestServer(t)
	if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
		t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
	}
	if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"  apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
		t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
	}
	rec := do(t, srv, http.MethodGet, "/vocab", "")
	var all []Word
	_ = json.Unmarshal(rec.Body.Bytes(), &all)
	if len(all) != 1 {
		t.Fatalf("Apple/apple should be one card, got %d", len(all))
	}
	if all[0].Word != "apple" {
		t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
	}
}

// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
// garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) {
	srv, database := newTestServer(t)
	var docID string
	if err := database.QueryRow(
		`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
	).Scan(&docID); err != nil {
		t.Fatalf("seed doc: %v", err)
	}
	rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
	}
	if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
		t.Fatalf("delete doc: %v", err)
	}
	rec = do(t, srv, http.MethodGet, "/vocab", "")
	var all []Word
	_ = json.Unmarshal(rec.Body.Bytes(), &all)
	if len(all) != 1 {
		t.Fatalf("word should survive doc deletion, got %d", len(all))
	}
	if all[0].DocID != nil {
		t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
	}
}
