/*
 * ChatCLI - Command Line Interface for LLM interaction
 * Copyright (c) 2024 Edilson Freitas
 * License: Apache-2.0
 */
package claudeai

import (
	"bufio"
	"bytes"
	"compress/flate"
	"compress/gzip"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/diillson/chatcli/auth"
	"github.com/diillson/chatcli/config"
	"github.com/diillson/chatcli/i18n"
	"github.com/diillson/chatcli/llm/catalog"
	"github.com/diillson/chatcli/llm/client"
	"github.com/diillson/chatcli/llm/internal/visionwire"
	"github.com/diillson/chatcli/models"
	"github.com/diillson/chatcli/utils"
	"go.uber.org/zap"
)

// ClaudeClient é uma estrutura que contém o cliente de ClaudeAI com suas configurações
type ClaudeClient struct {
	provider    auth.TokenProvider
	model       string
	logger      *zap.Logger
	client      *http.Client
	maxAttempts int
	backoff     time.Duration
	apiURL      string
}

const (
	oauthUserAgent         = "claude-cli/2.1.2 (external, cli)"
	oauthAnthropicBeta     = "oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219,fine-grained-tool-streaming-2025-05-14"
	oauthSonnet1MBeta      = "context-1m-2025-08-07"
	oauthBaseSystemPrompt  = "You are Claude Code, Anthropic's official CLI for Claude."
	oauthTitleModel        = "claude-haiku-4-5"
	oauthTitleMaxTokens    = 32000
	oauthTitleTemperature  = 0.5
	oauthTitleUserPrefix   = "Generate a title for this conversation:\n"
	oauthTitleUserWrapTmpl = "\n              The following is the text to summarize:\n              <text>\n              %s\n              </text>\n            "
	oauthMessagesBetaQuery = "beta=true"
	oauthContentType       = "application/json"
	oauthAcceptHeader      = "*/*"
	oauthAcceptEncoding    = "gzip, deflate, br, zstd"
	oauthConnectionHeader  = "keep-alive"
)

const oauthTitleSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.

You are a title generator. You output ONLY a thread title. Nothing else.

<task>
Generate a brief title that would help the user find this conversation later.

Follow all rules in <rules>
Use the <examples> so you know what a good title looks like.
Your output must be:
- A single line
- ≤50 characters
- No explanations
</task>

<rules>
- you MUST use the same language as the user message you are summarizing
- Title must be grammatically correct and read naturally - no word salad
- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
- Focus on the main topic or question the user needs to retrieve
- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
- Keep exact: technical terms, numbers, filenames, HTTP codes
- Remove: the, this, my, a, an
- Never assume tech stack
- Never use tools
- NEVER respond to questions, just generate a title for the conversation
- The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal.
- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
  → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules>

<examples>
"debug 500 errors in production" → Debugging production 500 errors
"refactor user service" → Refactoring user service
"why is app.js failing" → app.js failure investigation
"implement rate limiting" → Rate limiting implementation
"how do I connect postgres to my API" → Postgres API connection
"best practices for React hooks" → React hooks best practices
"@src/auth.ts can you add refresh token support" → Auth refresh token support
"@utils/parser.ts this is broken" → Parser bug fix
"look at @config.json" → Config review
"@App.tsx add dark mode toggle" → Dark mode toggle in App
</examples>
`

// NewClaudeClient cria um novo cliente ClaudeAI com configurações personalizáveis.
func NewClaudeClient(provider auth.TokenProvider, model string, logger *zap.Logger, maxAttempts int, backoff time.Duration) *ClaudeClient {
	var httpClient *http.Client
	if provider.Mode() == auth.AuthModeOAuth {
		// OAuth requests may be rejected by Cloudflare when using Go's standard TLS fingerprint.
		// Use Chrome-like TLS fingerprint via uTLS, matching the approach in openairesponses.
		httpClient = utils.NewHTTPClientWithTransport(logger, 900*time.Second, utils.NewChromeTLSTransport())
	} else {
		httpClient = utils.NewHTTPClient(logger, 900*time.Second)
	}
	// Allow overriding the API URL via ANTHROPIC_BASE_URL (e.g., for MiniMax Anthropic-compatible endpoint)
	apiURL := config.ClaudeAIAPIURL
	if baseURL := os.Getenv("ANTHROPIC_BASE_URL"); baseURL != "" {
		apiURL = strings.TrimRight(baseURL, "/") + "/v1/messages"
		logger.Info(i18n.T("llm.info.using_custom_base_url", "Anthropic", apiURL))
	}

	return &ClaudeClient{
		provider:    provider,
		model:       strings.ToLower(model),
		logger:      logger,
		client:      httpClient,
		maxAttempts: maxAttempts,
		backoff:     backoff,
		apiURL:      apiURL,
	}
}

// GetModelName retorna o nome amigável do modelo ClaudeAI
func (c *ClaudeClient) GetModelName() string {
	return catalog.GetDisplayName(catalog.ProviderClaudeAI, c.model)
}

// getMaxTokens obtém o limite de tokens configurado
func (c *ClaudeClient) getMaxTokens() int {
	if tokenStr := os.Getenv("ANTHROPIC_MAX_TOKENS"); tokenStr != "" {
		if parsedTokens, err := strconv.Atoi(tokenStr); err == nil && parsedTokens > 0 {
			c.logger.Debug("Usando max_tokens personalizado", zap.Int("max_tokens", parsedTokens))
			return parsedTokens
		}
	}
	return catalog.GetMaxTokens(catalog.ProviderClaudeAI, c.model, 0)
}

// supportsExtendedThinking reports whether the given Anthropic model id
// advertises the extended-thinking feature. The check is substring-based so
// that dated variants (e.g. "claude-sonnet-4-5-20251001") and future 4.x
// releases keep working without a catalog update.
func supportsExtendedThinking(model string) bool {
	m := strings.ToLower(model)
	return strings.Contains(m, "opus-4") ||
		strings.Contains(m, "sonnet-4") ||
		strings.Contains(m, "3-7-sonnet") ||
		strings.Contains(m, "3.7-sonnet")
}

// usesAdaptiveThinkingOnly reports whether the model rejects budgeted
// extended thinking. Opus 4.7 and 4.8 advertise the "adaptive_thinking"
// capability in the catalog: passing `thinking:{type:"enabled",
// budget_tokens:N}` to those returns a 400, only `thinking:{type:"adaptive"}`
// is accepted. The capability flag is the source of truth so adding new
// models that share this constraint is a registry-only change.
func usesAdaptiveThinkingOnly(model string) bool {
	return catalog.HasCapability(catalog.ProviderClaudeAI, model, "adaptive_thinking")
}

// applyThinkingForEffort mutates reqBody to attach the right `thinking`
// block (and raise max_tokens when needed) for the per-turn effort hint.
// Routing:
//   - adaptive-only model + non-zero effort → thinking:{type:"adaptive"}
//   - extended-thinking model + non-zero effort → thinking:{enabled,budget}
//   - otherwise → no thinking block
//
// Returns true when a thinking block was attached.
func applyThinkingForEffort(reqBody map[string]interface{}, model string, ctx context.Context) bool {
	effort := client.EffortFromContext(ctx)
	if effort == client.EffortUnset {
		return false
	}
	if usesAdaptiveThinkingOnly(model) {
		reqBody["thinking"] = map[string]interface{}{"type": "adaptive"}
		return true
	}
	if budget := client.ThinkingBudgetForEffort(effort); budget > 0 && supportsExtendedThinking(model) {
		required := budget + 1024
		if v, ok := reqBody["max_tokens"].(int); ok && v < required {
			reqBody["max_tokens"] = required
		}
		reqBody["thinking"] = map[string]interface{}{
			"type":          "enabled",
			"budget_tokens": budget,
		}
		return true
	}
	return false
}

// applyFastModeIfRequested attaches `speed: "fast"` for models that
// advertise the fast_mode capability (currently Opus 4.8 only — research
// preview). Opt-in via ANTHROPIC_SPEED=fast; otherwise the request goes
// through standard mode at standard pricing. Silently ignored for models
// that don't advertise the capability.
func applyFastModeIfRequested(reqBody map[string]interface{}, model string) bool {
	speed := strings.ToLower(strings.TrimSpace(os.Getenv("ANTHROPIC_SPEED")))
	if speed != "fast" {
		return false
	}
	if !catalog.HasCapability(catalog.ProviderClaudeAI, model, "fast_mode") {
		return false
	}
	reqBody["speed"] = "fast"
	return true
}

// SendPrompt com exponential backoff usando utils.Retry
func (c *ClaudeClient) SendPrompt(ctx context.Context, prompt string, history []models.Message, maxTokens int) (string, error) {
	effectiveMaxTokens := maxTokens
	if effectiveMaxTokens <= 0 {
		effectiveMaxTokens = c.getMaxTokens()
	}

	isOAuth := c.provider.Mode() == auth.AuthModeOAuth
	var messages interface{}
	var systemObj interface{}
	if isOAuth {
		messages, systemObj = c.buildOAuthMessagesAndSystem(prompt, history)
	} else {
		messages, systemObj = c.buildMessagesAndSystem(prompt, history)
	}

	reqBody := map[string]interface{}{
		"model":      c.model,
		"max_tokens": effectiveMaxTokens,
		"messages":   messages,
	}

	if systemObj != nil {
		reqBody["system"] = systemObj
	}
	if isOAuth {
		reqBody["stream"] = true
	}

	// Skill effort hint → thinking. Opus 4.7+ only accepts adaptive
	// thinking; older 4.x and Sonnet 3.7 accept budgeted extended
	// thinking. applyThinkingForEffort routes by catalog capability.
	if applyThinkingForEffort(reqBody, c.model, ctx) {
		c.logger.Debug("claudeai: thinking enabled from skill effort hint",
			zap.Any("thinking", reqBody["thinking"]),
			zap.String("model", c.model))
	}

	// Opus 4.8 fast mode opt-in (research preview, premium pricing).
	if applyFastModeIfRequested(reqBody, c.model) {
		c.logger.Debug("claudeai: fast mode enabled (ANTHROPIC_SPEED=fast)",
			zap.String("model", c.model))
	}

	enforceCacheControlBudget(reqBody, anthropicMaxCacheBreakpoints)

	jsonValue, err := json.Marshal(reqBody)
	if err != nil {
		c.logger.Error(i18n.T("llm.error.marshal_payload"), zap.Error(err))
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.prepare_request"), err)
	}

	authMode := string(c.provider.Mode())
	start := time.Now()
	client.LogRequestStart(c.logger, "CLAUDEAI", c.model,
		zap.String("auth", authMode),
		zap.Int("payload_bytes", len(jsonValue)),
		zap.Int("history_len", len(history)),
		zap.Int("max_tokens", effectiveMaxTokens),
		zap.Int("cache_markers", client.CountAnthropicCacheMarkers(reqBody)),
	)

	if isOAuth {
		if err := c.sendOAuthTitleRequest(ctx, oauthTitleUserPrefix+prompt); err != nil {
			c.logger.Debug("Falha ao enviar request de titulo pre (OAuth)", zap.Error(err))
		}
	}

	responseText, err := utils.Retry(ctx, c.logger, c.maxAttempts, c.backoff, func(ctx context.Context) (string, error) {
		reqURL := c.apiURL
		if isOAuth {
			reqURL = withBetaQuery(reqURL)
		}
		reqCtx := context.WithValue(ctx, oauthModelKey{}, c.model)
		resp, err := auth.DoWithRefresh(reqCtx, c.provider, func(token string) (*http.Response, error) {
			req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, reqURL, bytes.NewReader(jsonValue))
			if err != nil {
				return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.create_request"), err)
			}
			req.Header.Add("Content-Type", oauthContentType)
			c.applyAuthHeaders(req, token)
			return c.client.Do(req)
		})
		if err != nil {
			return "", err
		}
		defer func() { _ = resp.Body.Close() }()
		if isOAuth {
			return c.processStreamResponse(resp)
		}
		return c.processResponse(resp)
	})

	if err != nil {
		client.LogRequestFinish(c.logger, "CLAUDEAI", c.model, "error", time.Since(start),
			zap.String("auth", authMode),
		)
		c.logger.Error(i18n.T("llm.error.get_response_after_retries", "Claude AI"), zap.Error(err))
		return "", err
	}
	client.LogRequestFinish(c.logger, "CLAUDEAI", c.model, "success", time.Since(start),
		zap.String("auth", authMode),
		zap.Int("response_chars", len(responseText)),
	)

	if isOAuth {
		if err := c.sendOAuthTitleRequest(ctx, fmt.Sprintf(oauthTitleUserWrapTmpl, prompt)); err != nil {
			c.logger.Debug("Falha ao enviar request de titulo pos (OAuth)", zap.Error(err))
		}
	}

	return responseText, nil
}

func (c *ClaudeClient) processResponse(resp *http.Response) (string, error) {
	defer func() { _ = resp.Body.Close() }()

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		c.logger.Error(i18n.T("llm.error.read_response_for", "ClaudeAI"), zap.Error(err))
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.read_response"), err)
	}

	if resp.StatusCode != http.StatusOK {
		return "", &utils.APIError{StatusCode: resp.StatusCode, Message: utils.SanitizeSensitiveText(string(bodyBytes))}
	}

	var result struct {
		Content []struct {
			Type string `json:"type"`
			Text string `json:"text"`
		} `json:"content"`
	}

	if err := json.Unmarshal(bodyBytes, &result); err != nil {
		c.logger.Error(i18n.T("llm.error.decode_response_for", "ClaudeAI"), zap.Error(err))
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.decode_response"), err)
	}

	var responseText string
	for _, content := range result.Content {
		if content.Type == "text" {
			responseText += content.Text
		}
	}

	if responseText == "" {
		c.logger.Error(i18n.T("llm.error.no_text_content", "ClaudeAI"))
		return "", fmt.Errorf("%s", i18n.T("llm.error.no_response", "ClaudeAI"))
	}

	return responseText, nil
}

// helper beta 1m tokens sonnet model
func isClaudeSonnet(model string) bool {
	var claudeSonnetRe = regexp.MustCompile(`^claude-.*sonnet.*$`)
	return claudeSonnetRe.MatchString(model)
}

func (c *ClaudeClient) processStreamResponse(resp *http.Response) (string, error) {
	decodedBody, err := decodeResponseBody(resp)
	if err != nil {
		_ = resp.Body.Close()
		return "", err
	}
	defer func() { _ = decodedBody.Close() }()

	if resp.StatusCode != http.StatusOK {
		raw, _ := io.ReadAll(decodedBody)
		return "", &utils.APIError{StatusCode: resp.StatusCode, Message: utils.SanitizeSensitiveText(string(raw))}
	}

	var out strings.Builder
	reader := bufio.NewReader(decodedBody)
	for {
		line, err := reader.ReadString('\n')
		if err != nil && err != io.EOF {
			return "", fmt.Errorf("%s: %w", i18n.T("llm.error.read_stream"), err)
		}
		if len(line) == 0 && err == io.EOF {
			break
		}
		line = strings.TrimRight(line, "\r\n")
		if line == "" || !strings.HasPrefix(line, "data:") {
			if err == io.EOF {
				break
			}
			continue
		}
		data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
		if data == "" {
			if err == io.EOF {
				break
			}
			continue
		}
		var evt struct {
			Type  string `json:"type"`
			Delta *struct {
				Type string `json:"type"`
				Text string `json:"text"`
			} `json:"delta"`
		}
		if jsonErr := json.Unmarshal([]byte(data), &evt); jsonErr != nil {
			if err == io.EOF {
				break
			}
			continue
		}
		if evt.Type == "content_block_delta" && evt.Delta != nil && evt.Delta.Type == "text_delta" {
			out.WriteString(evt.Delta.Text)
		}
		if evt.Type == "message_stop" && err == io.EOF {
			break
		}
		if err == io.EOF {
			break
		}
	}

	responseText := out.String()
	if responseText == "" {
		c.logger.Error(i18n.T("llm.error.no_text_content_stream", "ClaudeAI"))
		return "", fmt.Errorf("%s", i18n.T("llm.error.no_response", "ClaudeAI"))
	}

	return responseText, nil
}

func (c *ClaudeClient) buildMessagesAndSystem(prompt string, history []models.Message) ([]map[string]interface{}, interface{}) {
	// map[string]interface{} (não string) para que o conteúdo possa ser um
	// array de blocos (imagem + texto) em turnos com vision via visionwire.
	var messages []map[string]interface{}
	var systemBlocks []map[string]interface{}
	var plainSystemParts []string

	for _, msg := range history {
		switch strings.ToLower(strings.TrimSpace(msg.Role)) {
		case "assistant":
			messages = append(messages, map[string]interface{}{"role": "assistant", "content": visionwire.AnthropicContent(msg.Content, msg.Images)})
		case "system":
			// If structured SystemParts are available, use them for cache_control
			if len(msg.SystemParts) > 0 {
				for _, part := range msg.SystemParts {
					block := map[string]interface{}{
						"type": "text",
						"text": part.Text,
					}
					if part.CacheControl != nil {
						block["cache_control"] = map[string]string{
							"type": part.CacheControl.Type,
						}
					}
					systemBlocks = append(systemBlocks, block)
				}
			} else if msg.Content != "" {
				plainSystemParts = append(plainSystemParts, msg.Content)
			}
		default:
			messages = append(messages, map[string]interface{}{"role": "user", "content": visionwire.AnthropicContent(msg.Content, msg.Images)})
		}
	}

	if len(history) == 0 || history[len(history)-1].Role != "user" || history[len(history)-1].Content != prompt {
		if strings.TrimSpace(prompt) != "" {
			messages = append(messages, map[string]interface{}{"role": "user", "content": prompt})
		}
	}

	// Prefer structured blocks (with cache_control) over plain string
	if len(systemBlocks) > 0 {
		// Also add any plain system parts as blocks (without cache hint)
		for _, part := range plainSystemParts {
			systemBlocks = append(systemBlocks, map[string]interface{}{
				"type": "text",
				"text": part,
			})
		}
		return messages, coalesceCacheControl(systemBlocks, anthropicMaxCacheBreakpoints)
	}

	if len(plainSystemParts) > 0 {
		return messages, strings.Join(plainSystemParts, "\n\n")
	}

	return messages, nil
}

func (c *ClaudeClient) buildOAuthMessagesAndSystem(prompt string, history []models.Message) ([]map[string]interface{}, []interface{}) {
	var messages []map[string]interface{}
	var systemParts []string
	var structuredParts []map[string]interface{}

	for _, msg := range history {
		switch strings.ToLower(strings.TrimSpace(msg.Role)) {
		case "assistant":
			messages = append(messages, map[string]interface{}{
				"role":    "assistant",
				"content": visionwire.AnthropicOAuthContent(msg.Content, msg.Images),
			})
		case "system":
			// If structured SystemParts are available, use them for cache_control
			if len(msg.SystemParts) > 0 {
				for _, part := range msg.SystemParts {
					block := map[string]interface{}{
						"type": "text",
						"text": part.Text,
					}
					if part.CacheControl != nil {
						block["cache_control"] = map[string]string{
							"type": part.CacheControl.Type,
						}
					}
					structuredParts = append(structuredParts, block)
				}
			} else if msg.Content != "" {
				systemParts = append(systemParts, msg.Content)
			}
		default:
			messages = append(messages, map[string]interface{}{
				"role":    "user",
				"content": visionwire.AnthropicOAuthContent(msg.Content, msg.Images),
			})
		}
	}

	if len(history) == 0 || history[len(history)-1].Role != "user" || history[len(history)-1].Content != prompt {
		if strings.TrimSpace(prompt) != "" {
			messages = append(messages, map[string]interface{}{
				"role":    "user",
				"content": []interface{}{oauthTextBlock(prompt)},
			})
		}
	}

	systemObjs := []interface{}{
		oauthTextBlock(oauthBaseSystemPrompt),
	}
	// Prefer structured blocks with cache_control
	if len(structuredParts) > 0 {
		structuredParts = coalesceCacheControl(structuredParts, anthropicMaxCacheBreakpoints)
		for _, p := range structuredParts {
			systemObjs = append(systemObjs, p)
		}
	}
	if len(systemParts) > 0 {
		joined := strings.Join(systemParts, "\n\n")
		systemObjs = append(systemObjs, oauthTextBlock(joined))
	}

	return messages, systemObjs
}

func (c *ClaudeClient) sendOAuthTitleRequest(ctx context.Context, userText string) error {
	if strings.TrimSpace(userText) == "" {
		return nil
	}
	body := map[string]interface{}{
		"model":       oauthTitleModel,
		"max_tokens":  oauthTitleMaxTokens,
		"temperature": oauthTitleTemperature,
		"system": []interface{}{
			oauthTextBlock(oauthBaseSystemPrompt),
			oauthTextBlock(oauthTitleSystemPrompt),
		},
		"messages": []interface{}{
			map[string]interface{}{
				"role":    "user",
				"content": []interface{}{oauthTextBlock(userText)},
			},
		},
		"stream": true,
	}

	payload, err := json.Marshal(body)
	if err != nil {
		return err
	}

	reqURL := withBetaQuery(c.apiURL)
	reqCtx := context.WithValue(ctx, oauthModelKey{}, oauthTitleModel)
	resp, err := auth.DoWithRefresh(reqCtx, c.provider, func(token string) (*http.Response, error) {
		req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, reqURL, bytes.NewReader(payload))
		if err != nil {
			return nil, err
		}
		req.Header.Add("Content-Type", oauthContentType)
		applyOAuthHeaders(req, token)
		return c.client.Do(req)
	})
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	_, err = c.processStreamResponse(resp)
	return err
}

// applyAuthHeaders sets the Authorization-style headers required by the
// Anthropic Messages API for the active credential mode. The token argument
// is the raw access token (no "oauth:"/"token:"/"apikey:" prefix).
func (c *ClaudeClient) applyAuthHeaders(req *http.Request, token string) {
	switch c.provider.Mode() {
	case auth.AuthModeOAuth:
		applyOAuthHeaders(req, token)
	case auth.AuthModeToken:
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set("anthropic-version", catalog.GetAnthropicAPIVersion(c.model))
	default:
		req.Header.Set("x-api-key", token)
		req.Header.Set("anthropic-version", catalog.GetAnthropicAPIVersion(c.model))
	}
}

func oauthTextBlock(text string) map[string]interface{} {
	return map[string]interface{}{
		"type": "text",
		"text": text,
		//"cache_control": map[string]string{
		//	"type": "ephemeral",
		//},
	}
}

func applyOAuthHeaders(req *http.Request, token string) {
	req.Header.Set("Accept", oauthAcceptHeader)
	req.Header.Set("Accept-Encoding", oauthAcceptEncoding)
	req.Header.Set("Connection", oauthConnectionHeader)
	req.Header.Set("User-Agent", oauthUserAgent)
	betas := oauthAnthropicBeta
	if os.Getenv("ANTHROPIC_1MTOKENS_SONNET") == "true" {
		if m, ok := req.Context().Value(oauthModelKey{}).(string); ok && isClaudeSonnet(m) {
			betas = betas + "," + oauthSonnet1MBeta
		}
	}
	req.Header.Set("anthropic-beta", betas)
	req.Header.Set("anthropic-version", "2023-06-01")
	req.Header.Set("Authorization", "Bearer "+token)
}

type oauthModelKey struct{}

func withBetaQuery(baseURL string) string {
	if strings.Contains(baseURL, "?") {
		return baseURL + "&" + oauthMessagesBetaQuery
	}
	return baseURL + "?" + oauthMessagesBetaQuery
}

type multiCloser struct {
	reader io.Reader
	close  func() error
}

func (m *multiCloser) Read(p []byte) (int, error) { return m.reader.Read(p) }
func (m *multiCloser) Close() error               { return m.close() }

// ListModels fetches available models from the Anthropic /v1/models endpoint.
// Supports both API key and OAuth authentication modes.
func (c *ClaudeClient) ListModels(ctx context.Context) ([]client.ModelInfo, error) {
	isOAuth := c.provider.Mode() == auth.AuthModeOAuth

	// Derive models URL from the messages URL
	modelsURL := strings.TrimSuffix(c.apiURL, "/messages") + "/models"
	if isOAuth {
		modelsURL = withBetaQuery(modelsURL)
	}

	resp, err := auth.DoWithRefresh(ctx, c.provider, func(token string) (*http.Response, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.create_request"), err)
		}
		c.applyAuthHeaders(req, token)
		return c.client.Do(req)
	})
	if err != nil {
		return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.request_failed", "Anthropic"), err)
	}
	defer func() { _ = resp.Body.Close() }()

	var bodyReader io.Reader = resp.Body
	// OAuth responses may be compressed (same as SendPrompt)
	if isOAuth {
		decodedBody, err := decodeResponseBody(resp)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", i18n.T("llm.tool.error.decoding_response_body"), err)
		}
		defer func() { _ = decodedBody.Close() }()
		bodyReader = decodedBody
	}

	bodyBytes, err := io.ReadAll(bodyReader)
	if err != nil {
		return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.read_response_for", "Anthropic"), err)
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("Anthropic /models returned %d: %s", resp.StatusCode, utils.SanitizeSensitiveText(string(bodyBytes)))
	}

	var result struct {
		Data []struct {
			ID          string `json:"id"`
			DisplayName string `json:"display_name"`
			Type        string `json:"type"`
		} `json:"data"`
	}
	if err := json.Unmarshal(bodyBytes, &result); err != nil {
		return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.decode_response_for", "Anthropic"), err)
	}

	modelList := make([]client.ModelInfo, 0, len(result.Data))
	for _, m := range result.Data {
		displayName := m.DisplayName
		if displayName == "" {
			displayName = m.ID
		}
		modelList = append(modelList, client.ModelInfo{
			ID:          m.ID,
			DisplayName: displayName,
			Source:      client.ModelSourceAPI,
		})

		if _, ok := catalog.Resolve(catalog.ProviderClaudeAI, m.ID); !ok {
			catalog.Register(catalog.ModelMeta{
				ID:           m.ID,
				Aliases:      []string{m.ID},
				DisplayName:  displayName,
				Provider:     catalog.ProviderClaudeAI,
				PreferredAPI: catalog.APIAnthropicMessages,
				APIVersion:   config.ClaudeAIAPIVersionDefault,
			})
		}
	}

	c.logger.Info(i18n.T("llm.info.fetched_models", "Anthropic"), zap.Int("count", len(modelList)))
	return modelList, nil
}

func decodeResponseBody(resp *http.Response) (io.ReadCloser, error) {
	encoding := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Encoding")))
	switch encoding {
	case "", "identity":
		return resp.Body, nil
	case "gzip":
		gr, err := gzip.NewReader(resp.Body)
		if err != nil {
			return nil, err
		}
		return &multiCloser{
			reader: gr,
			close: func() error {
				_ = gr.Close()
				return resp.Body.Close()
			},
		}, nil
	case "deflate":
		fr := flate.NewReader(resp.Body)
		return &multiCloser{
			reader: fr,
			close: func() error {
				_ = fr.Close()
				return resp.Body.Close()
			},
		}, nil
	default:
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%s", i18n.T("llm.error.content_encoding_unsupported", encoding))
	}
}
