package vocab

import (
	"database/sql"
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"

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

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

// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
type Word struct {
	ID           string     `json:"id"`
	Word         string     `json:"word"`
	Gloss        string     `json:"gloss"`
	Definition   string     `json:"definition"` // English fallback meaning when there's no Chinese gloss
	Phonetic     string     `json:"phonetic"`
	Example      string     `json:"example"`
	DocID        *string    `json:"doc_id"`
	DueAt        time.Time  `json:"due_at"`
	IntervalDays int        `json:"interval_days"`
	Ease         float64    `json:"ease"`
	Reps         int        `json:"reps"`
	Lapses       int        `json:"lapses"`
	LastReviewed *time.Time `json:"last_reviewed"`
	CreatedAt    time.Time  `json:"created_at"`
}

// Handler owns the vocabulary-garden routes. Everything is scoped to the local
// user while auth is deferred.
type Handler struct {
	DB *db.DB
}

func New(database *db.DB) *Handler { return &Handler{DB: database} }

// Routes mounts the garden endpoints under /api/vocab.
func (h *Handler) Routes() chi.Router {
	r := chi.NewRouter()
	r.Get("/", h.list)     // the whole garden
	r.Get("/due", h.due)   // only the cards due for review now
	r.Post("/", h.capture) // record/upsert a looked-up word
	r.Post("/{id}/review", h.review)
	r.Delete("/{id}", h.remove)
	return r
}

const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
	due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`

func scanWord(s interface {
	Scan(dest ...any) error
}) (Word, error) {
	var w Word
	err := s.Scan(
		&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
		&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
	)
	return w, err
}

// list returns the full garden, newest blossoms first.
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
	h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
		WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
}

// due returns only the cards whose review time has arrived, soonest first.
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
	h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
		WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
}

func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
	rows, err := h.DB.Query(query, args...)
	if err != nil {
		httputil.ServerError(w, err)
		return
	}
	defer rows.Close()
	out := []Word{}
	for rows.Next() {
		word, err := scanWord(rows)
		if err != nil {
			httputil.ServerError(w, err)
			return
		}
		out = append(out, word)
	}
	if err := rows.Err(); err != nil {
		httputil.ServerError(w, err)
		return
	}
	httputil.WriteJSON(w, http.StatusOK, out)
}

type captureRequest struct {
	Word       string  `json:"word"`
	Gloss      string  `json:"gloss"`
	Definition string  `json:"definition"`
	Phonetic   string  `json:"phonetic"`
	Example    string  `json:"example"`
	DocID      *string `json:"doc_id"`
}

// Field-length caps. The captured fields come from the offline lexicon and the
// editor selection, not free-typed prose, so we clamp rather than reject — a
// lookup should never fail because a definition ran long. Counts are runes so a
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
// "word" is bounded too.
const (
	maxWordLen       = 128
	maxGlossLen      = 512
	maxDefinitionLen = 4096
	maxPhoneticLen   = 256
	maxExampleLen    = 4096
)

// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
func clamp(s string, max int) string {
	r := []rune(s)
	if len(r) <= max {
		return s
	}
	return string(r[:max])
}

// capture records a looked-up word. It's an idempotent upsert keyed on the word:
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
// recent context wins. Looking words up IS the data source — no extra effort.
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
	var req captureRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
		return
	}
	// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
	// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
	// "Apple" at a sentence start and "apple" mid-line would create two separate
	// cards on independent schedules.
	word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
	if word == "" {
		httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
		return
	}
	req.Gloss = clamp(req.Gloss, maxGlossLen)
	req.Definition = clamp(req.Definition, maxDefinitionLen)
	req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
	req.Example = clamp(req.Example, maxExampleLen)

	// A blank doc_id is the same as none; otherwise the word must belong to a
	// document the user actually owns. Without this check a stale or forged id
	// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
	// instead of a clean 400 (and, once auth lands, would let a word be attached
	// to another user's document).
	if req.DocID != nil {
		if strings.TrimSpace(*req.DocID) == "" {
			req.DocID = nil
		} else {
			var ok int
			err := h.DB.QueryRow(
				`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
				*req.DocID, db.LocalUserID,
			).Scan(&ok)
			if errors.Is(err, sql.ErrNoRows) {
				httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
				return
			}
			if err != nil {
				httputil.ServerError(w, err)
				return
			}
		}
	}

	// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
	// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
	// resets its progress.
	_, err := h.DB.Exec(
		`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
		 VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
		 ON CONFLICT(user_id, word) DO UPDATE SET
		   gloss      = excluded.gloss,
		   definition = excluded.definition,
		   phonetic   = excluded.phonetic,
		   example    = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
		   doc_id     = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
		db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
	)
	if err != nil {
		httputil.ServerError(w, err)
		return
	}

	out, err := h.fetch(word)
	if err != nil {
		httputil.ServerError(w, err)
		return
	}
	httputil.WriteJSON(w, http.StatusCreated, out)
}

// fetch loads one word row by its (user, word) key.
func (h *Handler) fetch(word string) (Word, error) {
	return scanWord(h.DB.QueryRow(
		`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
		db.LocalUserID, word,
	))
}

type reviewRequest struct {
	Grade Grade `json:"grade"`
}

// review grades one card and reschedules it. The grade drives the SM-2-lite
// scheduler; the new interval is applied as `due_at = now + interval days`.
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	var req reviewRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
		return
	}
	if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
		httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
		return
	}

	// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
	// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
	// with a concurrent review of the same card and lose an update. Wrap both in a
	// transaction.
	tx, err := h.DB.Begin()
	if err != nil {
		httputil.ServerError(w, err)
		return
	}
	defer tx.Rollback() // no-op once committed

	var cur State
	err = tx.QueryRow(
		`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
		id, db.LocalUserID,
	).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
	if errors.Is(err, sql.ErrNoRows) {
		httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
		return
	}
	if err != nil {
		httputil.ServerError(w, err)
		return
	}

	nxt := cur.next(req.Grade)
	// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
	// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
	offset := "+" + strconv.Itoa(nxt.Interval) + " days"
	if _, err := tx.Exec(
		`UPDATE vocab_words SET
		   reps = ?, interval_days = ?, ease = ?, lapses = ?,
		   last_reviewed = datetime('now'), due_at = datetime('now', ?)
		 WHERE id = ? AND user_id = ?`,
		nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
	); err != nil {
		httputil.ServerError(w, err)
		return
	}

	out, err := scanWord(tx.QueryRow(
		`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
	))
	if err != nil {
		httputil.ServerError(w, err)
		return
	}
	if err := tx.Commit(); err != nil {
		httputil.ServerError(w, err)
		return
	}
	httputil.WriteJSON(w, http.StatusOK, out)
}

// remove deletes a word from the garden (e.g. the writer already knows it).
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
	res, err := h.DB.Exec(
		`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
		chi.URLParam(r, "id"), db.LocalUserID,
	)
	if err != nil {
		httputil.ServerError(w, err)
		return
	}
	if n, _ := res.RowsAffected(); n == 0 {
		httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
