package llm

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"
)

// Сценарные тесты wire-контракта через httptest — стиль донора (реальные
// HTTP-раундтрипы, не моки транспорта).

func fastProfile() RetryProfile {
	return RetryProfile{AttemptTimeout: 2 * time.Second, MaxAttempts: 3, BackoffBase: time.Millisecond, BackoffCap: 5 * time.Millisecond}
}

func openAIOK(t *testing.T, w http.ResponseWriter, body string) {
	t.Helper()
	w.Header().Set("Content-Type", "application/json")
	if _, err := w.Write([]byte(body)); err != nil {
		t.Fatal(err)
	}
}

func TestOpenAICompatHappyPath(t *testing.T) {
	var gotBody map[string]any
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/chat/completions" {
			t.Errorf("path = %s", r.URL.Path)
		}
		if got := r.Header.Get("Authorization"); got != "Bearer k" {
			t.Errorf("auth = %q", got)
		}
		if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
			t.Fatal(err)
		}
		openAIOK(t, w, `{"id":"r1","model":"m-actual","choices":[{"message":{"content":"привет"},"finish_reason":"stop"}],
			"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":40}}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(),
		ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{
		Model:       "m",
		Messages:    []Message{{Role: "system", Content: "s"}, {Role: "user", Content: "u"}},
		MaxTokens:   256,
		Temperature: 0.3,
	})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Text != "привет" || resp.Model != "m-actual" || resp.FinishReason != FinishStop {
		t.Fatalf("resp = %+v", resp)
	}
	if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 40 || resp.Usage.CompletionTokens != 20 {
		t.Fatalf("usage = %+v", resp.Usage)
	}
	if resp.Usage.ReasoningTokens != 0 {
		t.Fatalf("subset semantics must keep reasoning 0, got %d", resp.Usage.ReasoningTokens)
	}
	// ExtraBody из models.yaml домержен в тело запроса.
	if _, ok := gotBody["thinking"]; !ok {
		t.Fatalf("extra_body not merged into wire body: %v", gotBody)
	}
	if gotBody["stream"] != false {
		t.Fatalf("stream must be false, got %v", gotBody["stream"])
	}
}

func TestDeepSeekCacheFieldsVariant(t *testing.T) {
	// DeepSeek-вариант usage: prompt_cache_hit_tokens вместо
	// prompt_tokens_details — иначе эксперимент по кэшу покажет ложный ноль.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		openAIOK(t, w, `{"id":"r2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
			"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "deepseek", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Usage.CachedTokens != 64 {
		t.Fatalf("deepseek cache-hit tokens not parsed: %+v", resp.Usage)
	}
}

func TestAdditiveReasoningSemantics(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		openAIOK(t, w, `{"id":"r3","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
			"usage":{"prompt_tokens":10,"completion_tokens":5,"completion_tokens_details":{"reasoning_tokens":50}}}`)
	}))
	defer srv.Close()

	// additive (xAI): reasoning поверх completion.
	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Usage.ReasoningTokens != 50 {
		t.Fatalf("additive semantics must surface reasoning tokens, got %+v", resp.Usage)
	}

	// subset: то же тело, но reasoning остаётся 0 (иначе двойной биллинг).
	c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "openai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningSubset}, nil)
	resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatal(err)
	}
	if resp2.Usage.ReasoningTokens != 0 {
		t.Fatalf("subset semantics must keep reasoning 0, got %+v", resp2.Usage)
	}
}

// TestAdditiveTotalReasoningSemantics covers the Gemini case (live-probe 2026-07-10): thinking
// tokens are billed as output but appear ONLY in total_tokens — completion_tokens excludes them
// and there is no reasoning_tokens field. The adapter must derive reasoning = total − prompt −
// completion so the mandatory-thinking spend is not invisible to the ledger. Reverting the
// ReasoningAdditiveTotal branch drops reasoning to 0 and fails this test.
func TestAdditiveTotalReasoningSemantics(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Mirrors the real Gemini shape: completion=2, prompt=22, total=847 → 823 hidden thinking,
		// NO completion_tokens_details.reasoning_tokens field at all.
		openAIOK(t, w, `{"id":"g1","model":"gemini-3.1-pro-preview","choices":[{"message":{"content":"391"},"finish_reason":"stop"}],
			"usage":{"prompt_tokens":22,"completion_tokens":2,"total_tokens":847}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 8192})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Usage.ReasoningTokens != 823 {
		t.Fatalf("additive_total must derive hidden thinking = total−prompt−completion = 823, got %+v", resp.Usage)
	}
	if resp.Usage.CompletionTokens != 2 {
		t.Fatalf("completion tokens must stay the visible 2 (thinking is separate reasoning), got %+v", resp.Usage)
	}

	// A spec provider whose total == prompt+completion must NOT derive a phantom reasoning count
	// (guard the >0 branch — never inflate the ceiling).
	srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		openAIOK(t, w, `{"id":"g2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
			"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
	}))
	defer srv2.Close()
	c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv2.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil)
	resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatal(err)
	}
	if resp2.Usage.ReasoningTokens != 0 {
		t.Fatalf("total==prompt+completion must derive 0 reasoning, got %+v", resp2.Usage)
	}
}

func TestRetryOn429HonoursRetryAfter(t *testing.T) {
	var calls atomic.Int32
	var firstRetryAt, secondCallAt time.Time
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := calls.Add(1)
		if n == 1 {
			firstRetryAt = time.Now()
			w.Header().Set("Retry-After", "1")
			w.WriteHeader(http.StatusTooManyRequests)
			return
		}
		secondCallAt = time.Now()
		openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Text != "ok" || calls.Load() != 2 {
		t.Fatalf("text=%q calls=%d", resp.Text, calls.Load())
	}
	// Retry-After: 1s должен перебить миллисекундный backoff профиля.
	if wait := secondCallAt.Sub(firstRetryAt); wait < 900*time.Millisecond {
		t.Fatalf("Retry-After not honoured: waited only %v", wait)
	}
}

func TestTerminal4xxNoRetry(t *testing.T) {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(`{"error":"bad model"}`))
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err == nil {
		t.Fatal("terminal 4xx must fail")
	}
	var se *HTTPStatusError
	if !errors.As(err, &se) || se.Status != 400 {
		t.Fatalf("want typed 400, got %v", err)
	}
	if calls.Load() != 1 {
		t.Fatalf("terminal 4xx must not retry, calls=%d", calls.Load())
	}
}

func TestEmptyContent2xxIsSuccess(t *testing.T) {
	// 2xx с пустым контентом — оплаченный вызов: транспорт возвращает успех,
	// деньги книжатся, гейты разбираются с пустым текстом.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		openAIOK(t, w, `{"id":"r","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil {
		t.Fatalf("empty-content 2xx must be a success (billed call): %v", err)
	}
	if resp.Text != "" || resp.Usage.PromptTokens != 10 {
		t.Fatalf("resp = %+v", resp)
	}
}

func TestRetryOn5xxThenSuccess(t *testing.T) {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if calls.Add(1) < 3 {
			w.WriteHeader(http.StatusBadGateway)
			return
		}
		openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err != nil || resp.Text != "ok" {
		t.Fatalf("resp=%v err=%v", resp, err)
	}
	if calls.Load() != 3 {
		t.Fatalf("calls = %d", calls.Load())
	}
}

// TestRetryLoopLogsWillRetryOnlyWhileAttemptsRemain pins the retryLoop logging contract
// (D23.4, httpllm.go:87-89): a "will retry" WARN is emitted ONLY when an attempt actually
// remains — carrying the REAL wait (retry_in), which Retry-After can silently stretch —
// and NEVER before the terminal exhausted error. Before the fix the last failure also
// logged "will retry" right before giving up. slog-capture, no live provider assertion.
func TestRetryLoopLogsWillRetryOnlyWhileAttemptsRemain(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		w.WriteHeader(http.StatusBadGateway) // always 5xx → retryable, runs to exhaustion
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "prov", BaseURL: srv.URL, Profile: fastProfile()}, logger)
	_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	if err == nil {
		t.Fatal("a persistent 5xx must exhaust to an error")
	}
	if calls.Load() != 3 { // fastProfile MaxAttempts=3
		t.Fatalf("want 3 attempts, got %d", calls.Load())
	}
	logs := buf.String()
	// Exactly MaxAttempts-1 (2) retry warnings: after attempts 1 and 2, never after the
	// last one (the loop breaks on att+1>=MaxAttempts before the WARN).
	if n := strings.Count(logs, "attempt failed, will retry"); n != 2 {
		t.Fatalf("want exactly 2 'will retry' logs (none on the terminal attempt), got %d:\n%s", n, logs)
	}
	// Each retry log carries the real wait it is about to sleep.
	if !strings.Contains(logs, "retry_in=") {
		t.Fatalf("a retry log must carry retry_in (the actual wait, Retry-After-aware):\n%s", logs)
	}
	// The final outcome is the exhausted error, not another "will retry".
	if !strings.Contains(err.Error(), "exhausted") {
		t.Fatalf("terminal error must say exhausted, got %v", err)
	}
}

func TestBilledDecodeErrorOn2xxGarbage(t *testing.T) {
	// 2xx с нечитаемым телом: ретраится, а на исчерпании отдаёт типизированную
	// billed-ошибку — раннер по ней селтлит оценку вместо возврата резерва.
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		w.Write([]byte(`{"truncated`)) // обрыв тела через прокси
	}))
	defer srv.Close()

	c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
	_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
	var bde *BilledDecodeError
	if err == nil || !errors.As(err, &bde) {
		t.Fatalf("want BilledDecodeError, got %v", err)
	}
	if calls.Load() != 3 {
		t.Fatalf("garbled 2xx must be retried to exhaustion, calls=%d", calls.Load())
	}
}
