package pipeline

import (
	"strings"
	"testing"

	"textmachine/backend/internal/llm"
)

// classify: ORDER is the contract (D2.1/D2.4) — refusal/echo/deterministic finish
// signals are decided BEFORE length/empty, so a truncated refusal is not turned
// into a paid length retry.
func TestClassifyOrder(t *testing.T) {
	const ru = "ru"
	cases := []struct {
		name       string
		in         classifyInput
		wantReason FlagReason
	}{
		{"clean stop", classifyInput{Source: "静かな朝。", Output: "Тихое утро.", Finish: llm.FinishStop, TargetLang: ru}, reasonOK},
		{"empty at stop", classifyInput{Source: "静かな朝。", Output: "   ", Finish: llm.FinishStop, TargetLang: ru}, FlagEmpty},
		{"empty at length (thinking ate budget)", classifyInput{Source: "静かな朝。", Output: "", Finish: llm.FinishLength, TargetLang: ru}, FlagEmpty},
		{"genuine length cut", classifyInput{Source: strings.Repeat("текст ", 50), Output: "Начало предложения обрывается на", Finish: llm.FinishLength, TargetLang: ru}, FlagLength},
		{"length but repetition loop", classifyInput{Source: "x", Output: strings.Repeat("ло ", 60), Finish: llm.FinishLength, TargetLang: ru}, FlagLoopDegenerate},
		{"truncated soft refusal (NOT length)", classifyInput{Source: "静かな朝、彼女は本を開いた。長い一日の始まりだった。", Output: "Я не могу помочь с этим запросом.", Finish: llm.FinishLength, TargetLang: ru}, FlagSoftRefusal},
		{"english refusal short", classifyInput{Source: "長い長い長い長い文章です。", Output: "I'm sorry, but I cannot assist with that.", Finish: llm.FinishStop, TargetLang: ru}, FlagSoftRefusal},
		{"cjk echo (untranslated)", classifyInput{Source: "彼は言った。", Output: "彼は言った。とても静かな朝だった。", Finish: llm.FinishStop, TargetLang: ru}, FlagCJKArtifact},
		{"provider content_filter finish", classifyInput{Source: "x", Output: "partial", Finish: llm.FinishContentFilter, TargetLang: ru}, FlagContentFilter},
		{"provider refusal finish", classifyInput{Source: "x", Output: "", Finish: llm.FinishRefusal, TargetLang: ru}, FlagHardRefusal},
		{"resumed decode checkpoint", classifyInput{Source: "x", Output: "", Finish: decodeErrorFinish, TargetLang: ru}, FlagDecodeError},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := classify(tc.in)
			if got.Reason != tc.wantReason {
				t.Fatalf("classify reason = %q, want %q (detail %q)", got.Reason, tc.wantReason, got.Detail)
			}
			if (got.Reason == reasonOK) != got.ok() {
				t.Fatalf("ok() inconsistent for reason %q", got.Reason)
			}
		})
	}
}

// A refusal that is NOT anomalously short (a long faithful translation that
// merely quotes a refusal-like phrase as dialogue) must NOT flag.
func TestClassifyLongOutputWithRefusalPhraseIsOK(t *testing.T) {
	src := strings.Repeat("短", 100) // 100 CJK runes → threshold max(400, 50) = 400
	out := "«Я не могу помочь тебе», — тихо сказал он, отводя взгляд. " + strings.Repeat("Слова растворялись в утреннем тумане, и никто их не услышал. ", 12)
	got := classify(classifyInput{Source: src, Output: out, Finish: llm.FinishStop, TargetLang: "ru"})
	if !got.ok() {
		t.Fatalf("a long translation quoting a refusal phrase must be ok, got %q", got.Reason)
	}
}

// Determinism: identical input yields the identical verdict (pure function).
func TestClassifyDeterministic(t *testing.T) {
	in := classifyInput{Source: "彼は静かに歩いた。", Output: strings.Repeat("эхо ", 40), Finish: llm.FinishLength, TargetLang: "ru"}
	a, b := classify(in), classify(in)
	if a != b {
		t.Fatalf("classify is not deterministic: %+v vs %+v", a, b)
	}
}

func TestRetryableSubset(t *testing.T) {
	retry := map[FlagReason]bool{FlagLength: true, FlagEmpty: true}
	all := []FlagReason{
		reasonOK, FlagLength, FlagEmpty, FlagLoopDegenerate, FlagHardRefusal, FlagSoftRefusal,
		FlagContentFilter, FlagCJKArtifact, FlagDecodeError, FlagCoverageFail, FlagExcisionSuspect,
		FlagHardBlock, FlagUpstreamNotOK,
	}
	for _, r := range all {
		if got, want := r.retryable(), retry[r]; got != want {
			t.Fatalf("%q.retryable() = %v, want %v (only length/empty are retryable)", r, got, want)
		}
	}
	if reasonOK.disposition() != DispOK {
		t.Fatal("reasonOK must resolve to DispOK")
	}
	if FlagLength.disposition() != DispFlagged {
		t.Fatal("a flag reason must resolve to DispFlagged")
	}
}

// maxTokensForAttempt is pure and doubles per attempt (D2.3), which is what puts
// a DIFFERENT max_tokens (and thus request_hash) on each regeneration.
func TestMaxTokensForAttempt(t *testing.T) {
	base := 2048
	for attempt, want := range map[int]int{0: base, 1: 2 * base, 2: 4 * base, 3: 8 * base} {
		if got := maxTokensForAttempt(base, attempt); got != want {
			t.Fatalf("maxTokensForAttempt(%d, %d) = %d, want %d", base, attempt, got, want)
		}
	}
	// Overflow guard: an absurd attempt is clamped, never a negative shift.
	if got := maxTokensForAttempt(base, 999); got <= 0 {
		t.Fatalf("maxTokensForAttempt overflow guard failed: %d", got)
	}
}

func TestDegenerateLoop(t *testing.T) {
	if !degenerateLoop(strings.Repeat("одно и то же ", 40)) {
		t.Fatal("a repeated phrase must be a degenerate loop")
	}
	healthy := "Она открыла книгу и начала читать первую главу, где юный герой впервые покидал родную деревню навстречу далёким горам и неизвестной судьбе, полной опасностей."
	if degenerateLoop(healthy) {
		t.Fatal("healthy prose must NOT be a degenerate loop")
	}
	if degenerateLoop("короткий обрыв на пол") {
		t.Fatal("a short length cut is not enough evidence for a loop")
	}
}

func TestIsRefusalShortVsLong(t *testing.T) {
	if !isRefusal("Извините, я не могу перевести это.", "abc") {
		t.Fatal("a short refusal must be detected")
	}
	long := strings.Repeat("Это обычный длинный перевод без всякого отказа. ", 30)
	if isRefusal(long, "abc") {
		t.Fatal("a long non-refusal must not be flagged")
	}
}

func TestCJKShareAndTarget(t *testing.T) {
	if s := cjkShare("彼は言った"); s < 0.9 {
		t.Fatalf("all-CJK text share = %.2f, want ~1", s)
	}
	if s := cjkShare("Полностью русский текст"); s > 0.01 {
		t.Fatalf("russian text share = %.2f, want ~0", s)
	}
	if !isCJKTarget("ja") || !isCJKTarget("ZH") || isCJKTarget("ru") {
		t.Fatal("isCJKTarget classification wrong")
	}
	// Into a CJK target, CJK output is expected — no echo flag.
	got := classify(classifyInput{Source: "Утро.", Output: "静かな朝。", Finish: llm.FinishStop, TargetLang: "ja"})
	if got.Reason == FlagCJKArtifact {
		t.Fatal("CJK output must not be an echo artifact when the target IS CJK")
	}
}
