package llm

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"math/rand"
	"net/http"
	"strconv"
	"time"

	"textmachine/backend/internal/obs"
)

// httpllm.go is the shared OpenAI-compatible Chat Completions transport: one
// HTTP+retry implementation reused by every OpenAI-compatible adapter
// (DeepSeek, GLM, Kimi, xAI, ru-агрегаторы, llama-server). Порт vojo
// httpllm.go с правками Фазы 0 (03-implementation-notes §2 п.2):
//
//   - все таймауты/ретраи — ПАРАМЕТРЫ клиента из профиля models.yaml, не
//     константы: захардкоженный 60-секундный per-attempt дедлайн донора убивал
//     бы реальные чанки 63–278 с и трижды бесплатно их ретраил;
//   - Retry-After на 429 читается и уважается (донор капил backoff на 8 с —
//     молоток по rate-limit'ам массового прогона глав), но сам капится
//     maxRetryAfterWait — иначе враждебный «Retry-After: 7200» вешает стадию
//     на часы с удержанным резервом;
//   - тело ответа читается через LimitReader (у донора кап был только в
//     gemini-native адаптере).
//
// Задокументированное отступление от донора: one-shot self-heal параметра
// reasoning_effort (модель, вернувшая 400, запоминалась и параметр срезался)
// НЕ портирован — у нас reasoning задаётся per-стадия в конфиге с fail-fast
// валидацией, и 400 от провайдера должен чиниться в конфиге, а не глотаться.

// RetryProfile bounds one client's retry loop. Zero fields fall back to
// defaults; the profile comes from models.yaml per provider (per-role
// overrides — Фаза 1).
type RetryProfile struct {
	AttemptTimeout time.Duration // deadline for ONE HTTP attempt
	MaxAttempts    int
	BackoffBase    time.Duration // first backoff; doubles per attempt
	BackoffCap     time.Duration
}

func (p RetryProfile) withDefaults() RetryProfile {
	if p.AttemptTimeout <= 0 {
		// Полигон намерил 63–278 с на чанк локальной моделью; облачные быстрее,
		// но дефолт обязан переживать длинный чанк. Провайдер-специфика — в
		// models.yaml, это только страховочный потолок.
		p.AttemptTimeout = 300 * time.Second
	}
	if p.MaxAttempts <= 0 {
		p.MaxAttempts = 3
	}
	if p.BackoffBase <= 0 {
		p.BackoffBase = 500 * time.Millisecond
	}
	if p.BackoffCap <= 0 {
		p.BackoffCap = 30 * time.Second
	}
	return p
}

// maxResponseBytes caps one completion body read. A translated chapter chunk
// is ~10–50 KiB; 16 MiB leaves two orders of magnitude of headroom while
// keeping a misbehaving endpoint from exhausting memory.
const maxResponseBytes = 16 << 20

// maxRetryAfterWait bounds an honoured Retry-After: the provider's hint wins
// over our backoff schedule, but never for longer than this — a stage holding
// a USD reservation must stay interruptible-by-timeout, not wedged for hours.
const maxRetryAfterWait = 5 * time.Minute

// retryLoop is the ONE retry engine shared by every transport (OpenAI-compat
// and the native Anthropic adapter): exponential backoff with cap, Retry-After
// override, jitter, ctx-done select. Один экземпляр политики — чтобы фикс
// ретраев не «уезжал» от одного провайдера, забыв другой.
//
// Логи — продукт для оператора (пакет №4, боли сняты smoke-прогоном): старт
// каждой попытки виден на DEBUG (иначе «висит на таймауте» неотличимо от
// «умер»), WARN несёт ФАКТИЧЕСКОЕ ожидание перед следующей попыткой (Retry-After
// может молча растянуть его до 5 мин против ожидаемого backoff_cap) и говорит
// «will retry» ТОЛЬКО когда попытка действительно остаётся — раньше последний
// провал тоже логировался как «will retry» прямо перед exhausted-ошибкой.
func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, log *slog.Logger, attempt func() (T, bool, error)) (T, error) {
	var zero T
	var lastErr error
	for att := 0; att < profile.MaxAttempts; att++ {
		if log != nil {
			log.DebugContext(ctx, name+" attempt start", "attempt", att+1, "max", profile.MaxAttempts,
				"attempt_timeout", profile.AttemptTimeout.String())
		}
		resp, retryable, err := attempt()
		if err == nil {
			return resp, nil
		}
		lastErr = err
		if ctx.Err() != nil {
			return zero, ctx.Err()
		}
		if !retryable {
			return zero, err
		}
		if att+1 >= profile.MaxAttempts {
			break // attempts exhausted — no retry remains, no sleep
		}
		backoff := nextBackoff(profile, att, lastErr)
		if log != nil {
			log.WarnContext(ctx, name+" attempt failed, will retry", "attempt", att+1, "max", profile.MaxAttempts,
				"retry_in", backoff.Round(time.Millisecond).String(), "err", err)
		}
		select {
		case <-ctx.Done():
			return zero, ctx.Err()
		case <-time.After(backoff):
		}
	}
	return zero, fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr)
}

// nextBackoff computes the wait before attempt att+1 (0-based att just failed):
// exponential base<<att with cap, overridden by an honoured Retry-After (bounded
// by maxRetryAfterWait), plus jitter. Extracted from the loop so the WARN above
// can log the REAL wait it is about to sleep.
func nextBackoff(profile RetryProfile, att int, lastErr error) time.Duration {
	shift := att
	if shift > 20 {
		shift = 20 // защита сдвига от переполнения при больших max_attempts
	}
	backoff := profile.BackoffBase << uint(shift)
	if backoff <= 0 || backoff > profile.BackoffCap {
		backoff = profile.BackoffCap
	}
	if ra := retryAfterOf(lastErr); ra > 0 {
		if ra > maxRetryAfterWait {
			ra = maxRetryAfterWait
		}
		backoff = ra
	}
	return backoff + time.Duration(rand.Intn(250))*time.Millisecond
}

// openAIClient performs OpenAI-compatible /chat/completions calls with retry.
type openAIClient struct {
	name    string // provider label for logs/errors ("deepseek", "glm", "local")
	base    string
	key     string
	http    *http.Client
	profile RetryProfile
	headers map[string]string // extra static headers (provider-specific), may be nil
	log     *slog.Logger
}

// newOpenAIClient builds the shared transport. httpc may be nil (default
// client); the local provider passes an explicit no-proxy client (прокси-грабли
// стенда: env-прокси перехватывает не-loopback локальные адреса вроде 172.x).
func newOpenAIClient(name, base, key string, profile RetryProfile, headers map[string]string, httpc *http.Client, logger *slog.Logger) *openAIClient {
	if httpc == nil {
		httpc = &http.Client{}
	}
	return &openAIClient{
		name:    name,
		base:    base,
		key:     key,
		http:    httpc,
		profile: profile.withDefaults(),
		headers: headers,
		log:     logger,
	}
}

// --- OpenAI-compatible wire types -------------------------------------------------

type openAIMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// openAIRequest is the neutral pre-wire request. Its body is assembled by
// MarshalJSON THROUGH the per-model Capability resolver (capability.go), not
// from fixed json tags: OpenAI-compatible providers share an endpoint but not a
// body — the budget key, whether temperature is emitted, and the reasoning
// switch all vary per model (D3.1). The Phase-0 fixed body (max_tokens +
// always-send temperature + reasoning_effort) 400s on Kimi / gpt-5 / Gemini;
// the resolver renders the correct shape and is itself folded into the job
// snapshot, so changing it invalidates checkpoints rather than false-hitting.
type openAIRequest struct {
	model       string
	messages    []openAIMessage
	maxTokens   int
	temperature float64
	stream      bool
	// reasoningEffort is the NEUTRAL effort ("" | off | low | medium | high);
	// cap decides how — and whether — it reaches the wire.
	reasoningEffort string
	responseFormat  any
	cap             Capability
	// extra carries per-model provider-specific fields from models.yaml. Merged
	// LAST and only into gaps — a resolved standard key always wins.
	extra map[string]any
}

// MarshalJSON assembles the wire body via the capability resolver. Resolved
// standard keys are authoritative; config-supplied extra_body fills only gaps.
func (r openAIRequest) MarshalJSON() ([]byte, error) {
	m := map[string]any{
		"model":    r.model,
		"messages": r.messages,
		"stream":   r.stream,
	}
	r.cap.applyToBody(m, r.maxTokens, r.temperature, r.reasoningEffort)
	if r.responseFormat != nil {
		m["response_format"] = r.responseFormat
	}
	mergeBody(m, r.extra)
	return json.Marshal(m)
}

type openAIUsage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	// TotalTokens is prompt+completion for spec providers, but for Gemini via the
	// OpenAI-compat layer it ALSO carries the mandatory thinking tokens, which do NOT
	// appear in completion_tokens and have no reasoning_tokens field (live-probe
	// 2026-07-10: completion=2, prompt=22, total=847 → 823 hidden thinking). The
	// ReasoningAdditiveTotal adapter derives thinking = total − prompt − completion.
	TotalTokens         int `json:"total_tokens"`
	PromptTokensDetails struct {
		CachedTokens int `json:"cached_tokens"`
	} `json:"prompt_tokens_details"`
	// xAI reports reasoning tokens here SEPARATELY from completion_tokens and
	// bills them at the output rate; OpenAI-spec providers count them inside
	// completion_tokens. The adapter decides which semantics apply.
	CompletionTokensDetails struct {
		ReasoningTokens int `json:"reasoning_tokens"`
	} `json:"completion_tokens_details"`
	// DeepSeek historically reports cache usage in its own top-level fields
	// instead of prompt_tokens_details. Parse both so the Phase-0 cache
	// experiment can't show a false zero (03-implementation-notes §3.5).
	PromptCacheHitTokens  int `json:"prompt_cache_hit_tokens"`
	PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}

// cacheRead returns the cache-hit token count whichever field the provider
// used.
func (u openAIUsage) cacheRead() int {
	if u.PromptTokensDetails.CachedTokens > 0 {
		return u.PromptTokensDetails.CachedTokens
	}
	return u.PromptCacheHitTokens
}

type openAIResponse struct {
	ID      string `json:"id"`
	Model   string `json:"model"`
	Choices []struct {
		Message struct {
			Content string `json:"content"`
		} `json:"message"`
		FinishReason string `json:"finish_reason"`
	} `json:"choices"`
	Usage openAIUsage `json:"usage"`
}

func (r *openAIResponse) Text() string {
	if len(r.Choices) == 0 {
		return ""
	}
	return r.Choices[0].Message.Content
}

func (r *openAIResponse) FinishReason() string {
	if len(r.Choices) == 0 {
		return ""
	}
	// OpenAI-compat wire values map 1:1 onto the neutral constants.
	return r.Choices[0].FinishReason
}

// complete calls Chat Completions with retry on transient failures (429 / 5xx /
// network, exponential backoff + jitter, Retry-After honoured). Non-retryable
// 4xx fail immediately. On exhaustion the caller releases the reservation, so
// a transient failure is never silently swallowed.
func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*openAIResponse, error) {
	payload, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}
	return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) {
		return c.attempt(ctx, payload)
	})
}

// attempt performs one HTTP call. Returns retryable=true for 429/5xx and
// network errors, false for other non-2xx (terminal 4xx). The per-attempt
// deadline bounds a single hung connection; the overall per-request deadline
// (set by the caller via ctx) bounds the whole retry loop.
func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResponse, bool, error) {
	attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
	if err != nil {
		return nil, false, err
	}
	req.Header.Set("Content-Type", "application/json")
	// A local backend (ollama / llama-server) usually runs without auth; an
	// empty key means "no Authorization header", not "Bearer " with an empty token.
	if c.key != "" {
		req.Header.Set("Authorization", "Bearer "+c.key)
	}
	for k, v := range c.headers {
		req.Header.Set(k, v)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		// Network error / timeout — retryable (unless the parent ctx is done). A
		// per-attempt deadline is annotated with the configured timeout: the bare
		// «context deadline exceeded» не говорит оператору, ЧЕЙ это дедлайн —
		// таймаут попытки (лечится timeouts.attempt_s) или отмена всего прогона.
		if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
			err = fmt.Errorf("attempt timed out after %s (timeouts.attempt_s): %w", c.profile.AttemptTimeout, err)
		}
		return nil, ctx.Err() == nil, err
	}
	defer resp.Body.Close()
	// Читаем на 1 байт больше лимита, чтобы ОТЛИЧИТЬ усечение от целого тела.
	data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
	truncated := len(data) > maxResponseBytes
	if truncated {
		data = data[:maxResponseBytes]
	}

	obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)

	if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
		return nil, true, &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))}
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, false, &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)}
	}

	var out openAIResponse
	if err := json.Unmarshal(data, &out); err != nil {
		// 2xx с нечитаемым телом: провайдер УЖЕ списал деньги. Типизируем, чтобы
		// раннер консервативно заселтлил оценку. Усечённое (>16 MiB) тело
		// детерминированно — ретраить бессмысленно (каждый ретрай — новый
		// оплаченный 2xx), поэтому терминально; обрыв/мусор — retryable.
		if truncated {
			return nil, false, &BilledDecodeError{Provider: c.name, Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)}
		}
		return nil, true, &BilledDecodeError{Provider: c.name, Err: err}
	}
	// A 2xx is a billed call even when the model returns empty content
	// (content filter, finish_reason=length with no text). Return it as a
	// success so the caller settles the real cost via the ledger instead of
	// releasing the reservation and losing the spend — which would let empty
	// replies bypass the book/day ceilings. Gates deal with the empty text.
	return &out, false, nil
}

// HTTPStatusError is a non-2xx completion failure, carrying the status code as
// a typed field so callers (the failover decorator) can classify it
// structurally — a terminal 4xx is a config/request error that must fail loud,
// not be masked by a fallback — instead of parsing the message.
type HTTPStatusError struct {
	Provider   string
	Status     int
	Body       string
	RetryAfter time.Duration // from the Retry-After header; 0 = absent
}

func (e *HTTPStatusError) Error() string {
	return fmt.Sprintf("%s http %d: %s", e.Provider, e.Status, e.Body)
}

// BilledDecodeError is a 2xx whose body could not be decoded: the provider has
// billed the call, but usage/text are unknown. The runner treats an exhausted
// retry chain ending in this error as BILLED (settle at the reservation
// estimate) rather than releasing the reservation.
type BilledDecodeError struct {
	Provider string
	Err      error
}

func (e *BilledDecodeError) Error() string {
	return fmt.Sprintf("%s: 2xx body decode failed (call IS billed): %v", e.Provider, e.Err)
}

func (e *BilledDecodeError) Unwrap() error { return e.Err }

func retryAfterOf(err error) time.Duration {
	var se *HTTPStatusError
	if errors.As(err, &se) {
		return se.RetryAfter
	}
	return 0
}

// toOpenAIMessages maps neutral messages onto the wire. CacheBoundary is
// meaningless here: OpenAI-compatible caches (DeepSeek: prefix-match с 0-го
// токена блоками по 64 токена) are automatic; the stable-prefix ORDER the
// assembler produced is all that matters.
func toOpenAIMessages(msgs []Message) []openAIMessage {
	out := make([]openAIMessage, len(msgs))
	for i, m := range msgs {
		out[i] = openAIMessage{Role: m.Role, Content: m.Content}
	}
	return out
}

// jsonResponseFormat returns the response_format value for JSONOnly requests
// (nil otherwise, so the field serializes away).
func jsonResponseFormat(jsonOnly bool) any {
	if jsonOnly {
		return map[string]string{"type": "json_object"}
	}
	return nil
}

func parseRetryAfter(v string) time.Duration {
	if v == "" {
		return 0
	}
	if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
		return time.Duration(secs) * time.Second
	}
	if t, err := http.ParseTime(v); err == nil {
		if d := time.Until(t); d > 0 {
			return d
		}
	}
	return 0
}

func snippet(b []byte) string {
	const max = 300
	if len(b) > max {
		return string(b[:max]) + "…"
	}
	return string(b)
}
