package phraser

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/kami/maven/internal/loop"
)

// grammarSpy stands in for llama-server: it records the grammar field of every
// request and always answers with a contract-shaped reply.
type grammarSpy struct {
	srv      *httptest.Server
	grammars []string
}

func newGrammarSpy(t *testing.T) *grammarSpy {
	t.Helper()
	s := &grammarSpy{}
	s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var req chatReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			t.Errorf("spy: decode request: %v", err)
		}
		s.grammars = append(s.grammars, req.Grammar)
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\": \"ага\", \"mood\": \"neutral\"}"}}]}`))
	}))
	t.Cleanup(s.srv.Close)
	return s
}

// callAllPhrasingPaths hits every path that expects the JSON contract.
// LLMNudges must be set on the phraser under test: nudges come from templates
// by default and never reach the model at all.
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
	t.Helper()
	ctx := context.Background()
	if _, err := p.PhraseNudge(ctx, loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if _, err := p.PhraseChat(ctx, "привет", nil); err != nil {
		t.Fatalf("PhraseChat: %v", err)
	}
	// Both branches: no notes (general knowledge) and with notes (grounded).
	if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", nil); err != nil {
		t.Fatalf("PhraseQuery (no notes): %v", err)
	}
	if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", []string{"два литра"}); err != nil {
		t.Fatalf("PhraseQuery (notes): %v", err)
	}
}

func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
	if strings.TrimSpace(responseGrammar) == "" {
		t.Fatal("responseGrammar is empty")
	}
	spy := newGrammarSpy(t)
	p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})

	callAllPhrasingPaths(t, p)

	if len(spy.grammars) != 4 {
		t.Fatalf("expected 4 requests, got %d", len(spy.grammars))
	}
	for i, g := range spy.grammars {
		if g != responseGrammar {
			t.Errorf("request %d carries grammar %q, want responseGrammar", i, g)
		}
	}
}

func TestNoGrammarConfigDisablesIt(t *testing.T) {
	spy := newGrammarSpy(t)
	p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})

	callAllPhrasingPaths(t, p)

	for i, g := range spy.grammars {
		if g != "" {
			t.Errorf("request %d still carries a grammar with NoGrammar set: %q", i, g)
		}
	}
}

// The grammar's string rule must accept any codepoint, not just ASCII. Replies
// are Russian: an ASCII-only class would constrain the model into empty replies.
func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
	if !strings.Contains(responseGrammar, `[^"\\\x00-\x1F]`) {
		t.Error("string rule is not the any-codepoint-except-quote-backslash-and-controls class; Cyrillic replies would be impossible")
	}
	// The control range must be out (Vikunja #537): a raw newline inside a JSON
	// string is not JSON, and the model wrote one whenever it wanted two lines.
	if strings.Contains(responseGrammar, `([^"\\] |`) {
		t.Error("string rule still admits raw control characters; a multi-line reply will fail to parse")
	}
}

// What the grammar describes must survive the parser that reads it back — a
// Russian body with an escaped quote inside, hand-built to test the contract.
func TestGrammarShapedJSONParses(t *testing.T) {
	raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
	text, mood, err := parseResponseMood(raw)
	if err != nil {
		t.Fatalf("grammar-shaped JSON did not parse: %v", err)
	}
	if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
		t.Errorf("response = %q, want %q", text, want)
	}
	if mood != "confused" {
		t.Errorf("mood = %q, want confused", mood)
	}
}

// Every mood the grammar permits is one the contract knows, and all five are there.
func TestGrammarMoodEnumMatchesTheContract(t *testing.T) {
	for _, m := range []string{"neutral", "happy", "thinking", "tired", "confused"} {
		if !strings.Contains(responseGrammar, `"\"`+m+`\""`) {
			t.Errorf("mood %q missing from the grammar", m)
		}
	}
	// No sixth mood: the enum line lists exactly five alternatives.
	for _, line := range strings.Split(responseGrammar, "\n") {
		if strings.HasPrefix(line, "mood") {
			if n := strings.Count(line, "|") + 1; n != 5 {
				t.Errorf("mood rule lists %d alternatives, want 5: %s", n, line)
			}
		}
	}
}
