package docs

import (
	"encoding/json"
	"net/http"
	"strings"
	"testing"
)

func TestSearch(t *testing.T) {
	srv := newFullServer(t)

	createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
	createDoc(t, srv, "我的日记", "今天天气很好，我去公园散步，心情非常愉快")
	createDoc(t, srv, "Notes", "groceries and errands for the weekend")

	search := func(q string) []searchResult {
		t.Helper()
		rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
		if rec.Code != http.StatusOK {
			t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
		}
		var out []searchResult
		if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
			t.Fatalf("decode search %q: %v", q, err)
		}
		return out
	}

	// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
	res := search("quick")
	if len(res) != 1 || res[0].Title != "My Essay" {
		t.Fatalf("english search: %+v", res)
	}
	if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
		t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
	}

	// Chinese, ≥3 chars → FTS path (trigram handles CJK).
	res = search("天气很好")
	if len(res) != 1 || res[0].Title != "我的日记" {
		t.Fatalf("chinese fts search: %+v", res)
	}
	if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
		t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
	}

	// 2-character Chinese word → LIKE fallback (below the trigram minimum).
	res = search("公园")
	if len(res) != 1 || res[0].Title != "我的日记" {
		t.Fatalf("cjk short (LIKE) search: %+v", res)
	}
	if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
		t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
	}

	// Title-only match still returns the doc, highlighting the title.
	res = search("Notes")
	if len(res) != 1 || res[0].Title != "Notes" {
		t.Fatalf("title search: %+v", res)
	}

	// Case-insensitive.
	if got := search("QUICK"); len(got) != 1 {
		t.Fatalf("case-insensitive search: %+v", got)
	}

	// No match → empty (non-nil) array.
	if got := search("zzzznotthere"); len(got) != 0 {
		t.Fatalf("expected no results, got %+v", got)
	}

	// Empty query → empty array, no error.
	rec := do(t, srv, http.MethodGet, "/search?q=", "")
	if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
		t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
	}

	// An edit re-indexes via the update trigger: the old term stops matching, the
	// new term starts.
	res = search("quick")
	docID := res[0].ID
	body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
	do(t, srv, http.MethodPut, "/docs/"+docID, body)
	if got := search("quick"); len(got) != 0 {
		t.Fatalf("stale term still matches after edit: %+v", got)
	}
	if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
		t.Fatalf("new term not indexed after edit: %+v", got)
	}
}

// urlQuery percent-encodes a search term for the query string.
func urlQuery(s string) string {
	var b strings.Builder
	for _, r := range []byte(s) {
		if r == ' ' {
			b.WriteByte('+')
		} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
			b.WriteByte(r)
		} else {
			b.WriteString(percentByte(r))
		}
	}
	return b.String()
}

func percentByte(b byte) string {
	const hex = "0123456789ABCDEF"
	return string([]byte{'%', hex[b>>4], hex[b&0xf]})
}
