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

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"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"
)

// OpenAIClient implementa o cliente para interagir com a API da OpenAI
type OpenAIClient struct {
	provider    auth.TokenProvider
	model       string
	logger      *zap.Logger
	client      *http.Client
	maxAttempts int
	backoff     time.Duration
	usageState  client.UsageState
}

// LastUsage returns the token usage from the most recent API call.
func (c *OpenAIClient) LastUsage() *models.UsageInfo { return c.usageState.LastUsage() }

// LastStopReason returns the stop reason from the most recent API call.
func (c *OpenAIClient) LastStopReason() string { return c.usageState.LastStopReason() }

// NewOpenAIClient cria uma nova instância de OpenAIClient.
func NewOpenAIClient(provider auth.TokenProvider, model string, logger *zap.Logger, maxAttempts int, backoff time.Duration) *OpenAIClient {
	httpClient := utils.NewHTTPClient(logger, 900*time.Second)

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

// GetModelName retorna o nome amigável do modelo OpenAI via catálogo.
func (c *OpenAIClient) GetModelName() string {
	return catalog.GetDisplayName(catalog.ProviderOpenAI, c.model)
}

func (c *OpenAIClient) getMaxTokens() int {
	if tokenStr := os.Getenv("OPENAI_MAX_TOKENS"); tokenStr != "" {
		if parsedTokens, err := strconv.Atoi(tokenStr); err == nil && parsedTokens > 0 {
			c.logger.Debug("Usando OPENAI_MAX_TOKENS personalizado", zap.Int("max_tokens", parsedTokens))
			return parsedTokens
		}
	}
	// Fallback para catálogo
	return catalog.GetMaxTokens(catalog.ProviderOpenAI, c.model, 0)
}

// SendPrompt envia um prompt para o modelo de linguagem e retorna a resposta.
// supportsOpenAIReasoningEffort reports whether the chat-completions model id
// accepts the `reasoning_effort` field (o-series / GPT-5 reasoning models).
func supportsOpenAIReasoningEffort(model string) bool {
	m := strings.ToLower(model)
	return strings.HasPrefix(m, "o1") ||
		strings.HasPrefix(m, "o3") ||
		strings.HasPrefix(m, "o4") ||
		strings.HasPrefix(m, "gpt-5") ||
		strings.Contains(m, "-reasoning")
}

func (c *OpenAIClient) SendPrompt(ctx context.Context, prompt string, history []models.Message, maxTokens int) (string, error) {
	effectiveMaxTokens := maxTokens
	if effectiveMaxTokens <= 0 {
		effectiveMaxTokens = c.getMaxTokens() // valor padrão
	}

	// Construir o array de mensagens APENAS a partir do history.
	// map[string]interface{} (não string) para que turnos com imagem
	// possam carregar um array de parts multimodais via visionwire.
	messages := []map[string]interface{}{}

	for _, msg := range history {
		role := strings.ToLower(strings.TrimSpace(msg.Role))
		switch role {
		case "system", "user", "assistant":
			// válido
		default:
			role = "user"
		}
		messages = append(messages, map[string]interface{}{
			"role":    role,
			"content": visionwire.OpenAIContent(msg.Content, msg.Images),
		})
	}

	// Fallback: se history não tem o último prompt do user (edge-case),
	// adiciona o "prompt" como user aqui.
	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,
			})
		}
	}

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

	// Skill effort hint → reasoning_effort (only for reasoning-capable models).
	if eff := client.ReasoningEffortForOpenAI(client.EffortFromContext(ctx)); eff != "" && supportsOpenAIReasoningEffort(c.model) {
		payload["reasoning_effort"] = eff
		c.logger.Debug("openai: applying reasoning_effort from skill hint",
			zap.String("effort", eff), zap.String("model", c.model))
	}

	jsonValue, err := json.Marshal(payload)
	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)
	}

	start := time.Now()
	client.LogRequestStart(c.logger, "OPENAI", c.model,
		zap.Int("payload_bytes", len(jsonValue)),
		zap.Int("history_len", len(history)),
		zap.Int("max_tokens", effectiveMaxTokens),
	)

	response, err := utils.Retry(ctx, c.logger, c.maxAttempts, c.backoff, func(ctx context.Context) (string, error) {
		resp, err := auth.DoWithRefresh(ctx, c.provider, func(token string) (*http.Response, error) {
			return c.sendRequest(ctx, jsonValue, token)
		})
		if err != nil {
			return "", err
		}
		defer func() { _ = resp.Body.Close() }()
		return c.processResponse(resp)
	})
	if err != nil {
		client.LogRequestFinish(c.logger, "OPENAI", c.model, "error", time.Since(start))
		return response, err
	}
	client.LogRequestFinish(c.logger, "OPENAI", c.model, "success", time.Since(start),
		zap.Int("response_chars", len(response)),
	)
	return response, nil
}

// sendRequest envia a requisição para a API da OpenAI
func (c *OpenAIClient) sendRequest(ctx context.Context, jsonValue []byte, token string) (*http.Response, error) {
	apiURL := utils.GetEnvOrDefault("OPENAI_API_URL", config.OpenAIAPIURL)

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, utils.NewJSONReader(jsonValue))
	if err != nil {
		c.logger.Error(i18n.T("llm.error.create_request"), zap.Error(err))
		return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.create_request"), err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)

	return c.client.Do(req)
}

// processResponse processa a resposta da API da OpenAI
func (c *OpenAIClient) 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", "OpenAI"), zap.Error(err))
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.read_response_for", "OpenAI"), err)
	}

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

	var result map[string]interface{}
	if err := json.Unmarshal(bodyBytes, &result); err != nil {
		c.logger.Error(i18n.T("llm.error.decode_response_for", "OpenAI"), zap.Error(err))
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.decode_response_for", "OpenAI"), err)
	}

	// Extract usage and stop reason
	if usage := client.ParseOpenAIUsage(result); usage != nil {
		c.usageState.StoreUsage(usage)
	}
	c.usageState.StoreStopReason(client.ParseOpenAIFinishReason(result))

	choices, ok := result["choices"].([]interface{})
	if !ok || len(choices) == 0 {
		c.logger.Error(i18n.T("llm.error.no_response", "OpenAI"), zap.Any("resultado", result))
		return "", fmt.Errorf("%s", i18n.T("llm.error.no_response", "OpenAI"))
	}

	firstChoice, ok := choices[0].(map[string]interface{})
	if !ok {
		c.logger.Error(i18n.T("llm.error.unexpected_format", "OpenAI"), zap.Any("choice", choices[0]))
		return "", fmt.Errorf("%s", i18n.T("llm.error.unexpected_format", "OpenAI"))
	}

	message, ok := firstChoice["message"].(map[string]interface{})
	if !ok {
		c.logger.Error(i18n.T("llm.error.missing_message_field", "OpenAI"), zap.Any("choice", firstChoice))
		return "", fmt.Errorf("%s", i18n.T("llm.error.missing_message_field", "OpenAI"))
	}

	content, ok := message["content"].(string)
	if !ok {
		c.logger.Error(i18n.T("llm.error.invalid_message_content"), zap.Any("content", message["content"]))
		return "", fmt.Errorf("%s", i18n.T("llm.error.invalid_message_content"))
	}

	return content, nil
}

// ListModels fetches available models from the OpenAI /v1/models endpoint.
func (c *OpenAIClient) ListModels(ctx context.Context) ([]client.ModelInfo, error) {
	// Derive base URL from the chat completions URL
	apiURL := utils.GetEnvOrDefault("OPENAI_API_URL", config.OpenAIAPIURL)
	modelsURL := strings.TrimSuffix(apiURL, "/chat/completions") + "/models"

	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)
		}
		req.Header.Set("Authorization", "Bearer "+token)
		return c.client.Do(req)
	})
	if err != nil {
		return nil, fmt.Errorf("%s: %w", i18n.T("llm.error.request_failed", "OpenAI"), err)
	}
	defer func() { _ = resp.Body.Close() }()

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

	if resp.StatusCode != http.StatusOK {
		body := string(bodyBytes)
		// Detect missing scopes error and provide actionable message
		if resp.StatusCode == http.StatusForbidden && strings.Contains(body, "api.model.read") {
			c.logger.Warn(i18n.T("llm.responses.missing_scope"),
				zap.String("scope", "api.model.read"))
			return nil, fmt.Errorf("%s", i18n.T("llm.responses.missing_scope_detail"))
		}
		return nil, fmt.Errorf("OpenAI /models returned %d: %s", resp.StatusCode, utils.SanitizeSensitiveText(body))
	}

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

	models := make([]client.ModelInfo, 0, len(result.Data))
	for _, m := range result.Data {
		// Filter to chat-capable models (gpt, o1, o3, o4, chatgpt)
		id := strings.ToLower(m.ID)
		if !strings.HasPrefix(id, "gpt-") && !strings.HasPrefix(id, "o1") &&
			!strings.HasPrefix(id, "o3") && !strings.HasPrefix(id, "o4") &&
			!strings.HasPrefix(id, "chatgpt-") {
			continue
		}
		models = append(models, client.ModelInfo{
			ID:          m.ID,
			DisplayName: m.ID,
			Source:      client.ModelSourceAPI,
		})

		if _, ok := catalog.Resolve(catalog.ProviderOpenAI, m.ID); !ok {
			catalog.Register(catalog.ModelMeta{
				ID:           m.ID,
				Aliases:      []string{m.ID},
				DisplayName:  m.ID,
				Provider:     catalog.ProviderOpenAI,
				PreferredAPI: catalog.APIChatCompletions,
			})
		}
	}

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

// SupportsStreaming returns true — OpenAI supports SSE streaming.
func (c *OpenAIClient) SupportsStreaming() bool {
	return true
}

// SendPromptStream sends a prompt and returns a channel of streaming chunks.
func (c *OpenAIClient) SendPromptStream(ctx context.Context, prompt string, history []models.Message, maxTokens int) (<-chan client.StreamChunk, error) {
	effectiveMaxTokens := maxTokens
	if effectiveMaxTokens <= 0 {
		effectiveMaxTokens = c.getMaxTokens()
	}

	messages := []map[string]interface{}{}
	for _, msg := range history {
		role := strings.ToLower(strings.TrimSpace(msg.Role))
		switch role {
		case "system", "user", "assistant":
		default:
			role = "user"
		}
		messages = append(messages, map[string]interface{}{"role": role, "content": visionwire.OpenAIContent(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})
		}
	}

	payload := map[string]interface{}{
		"model":      c.model,
		"messages":   messages,
		"max_tokens": effectiveMaxTokens,
		"stream":     true,
		// Opt in to the terminal usage chunk. Without this, OpenAI Chat
		// Completions never emits the `usage` block over SSE — the chat
		// envelope then renders the "no tokens" placeholder for GPT
		// instead of the input/output arrows. The usage arrives in the
		// last chunk (the one with `choices: []`) before `data: [DONE]`.
		"stream_options": map[string]interface{}{"include_usage": true},
	}

	// Skill effort hint → reasoning_effort (reasoning-capable models only).
	if eff := client.ReasoningEffortForOpenAI(client.EffortFromContext(ctx)); eff != "" && supportsOpenAIReasoningEffort(c.model) {
		payload["reasoning_effort"] = eff
	}

	jsonValue, err := json.Marshal(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal stream request: %w", err)
	}

	start := time.Now()
	client.LogRequestStart(c.logger, "OPENAI", c.model,
		zap.Int("payload_bytes", len(jsonValue)),
		zap.Int("history_len", len(history)),
		zap.Int("max_tokens", effectiveMaxTokens),
		zap.String("kind", "stream"),
	)

	resp, err := c.openStream(ctx, jsonValue)
	if err != nil {
		client.LogRequestFinish(c.logger, "OPENAI", c.model, "error", time.Since(start),
			zap.String("kind", "stream"),
		)
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		raw, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()
		client.LogRequestFinish(c.logger, "OPENAI", c.model, "error", time.Since(start),
			zap.String("kind", "stream"),
			zap.Int("status_code", resp.StatusCode),
		)
		return nil, &utils.APIError{StatusCode: resp.StatusCode, Message: utils.SanitizeSensitiveText(string(raw))}
	}

	client.LogRequestFinish(c.logger, "OPENAI", c.model, "success", time.Since(start),
		zap.String("kind", "stream_started"),
	)

	return c.consumeStream(resp), nil
}

// openStream issues the streaming POST, retrying once on 401/403 when the
// provider is in OAuth mode. The returned response body belongs to the caller
// (typically passed to consumeStream which owns lifetime).
func (c *OpenAIClient) openStream(ctx context.Context, jsonValue []byte) (*http.Response, error) {
	token, err := c.provider.Token(ctx)
	if err != nil {
		return nil, err
	}
	resp, err := c.sendRequest(ctx, jsonValue, token)
	if err != nil {
		return nil, err
	}
	if c.provider.Mode() != auth.AuthModeOAuth {
		return resp, nil
	}
	if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
		return resp, nil
	}
	_, _ = io.Copy(io.Discard, resp.Body)
	_ = resp.Body.Close()
	c.provider.Invalidate()
	token, err = c.provider.Token(ctx)
	if err != nil {
		return nil, err
	}
	return c.sendRequest(ctx, jsonValue, token)
}

// consumeStream takes ownership of resp.Body and emits SSE chunks on the
// returned channel, closing the body when the stream ends.
func (c *OpenAIClient) consumeStream(resp *http.Response) <-chan client.StreamChunk {
	chunks := make(chan client.StreamChunk, 64)

	go func() {
		defer close(chunks)
		defer func() { _ = resp.Body.Close() }()

		scanner := bufio.NewScanner(resp.Body)
		scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

		for scanner.Scan() {
			line := scanner.Text()
			if !strings.HasPrefix(line, "data: ") {
				continue
			}
			data := strings.TrimPrefix(line, "data: ")
			if data == "[DONE]" {
				chunks <- client.StreamChunk{
					Done:       true,
					Usage:      c.usageState.LastUsage(),
					StopReason: c.usageState.LastStopReason(),
				}
				return
			}

			var evt map[string]interface{}
			if err := json.Unmarshal([]byte(data), &evt); err != nil {
				continue
			}

			// Extract text delta
			if choices, ok := evt["choices"].([]interface{}); ok && len(choices) > 0 {
				if choice, ok := choices[0].(map[string]interface{}); ok {
					if delta, ok := choice["delta"].(map[string]interface{}); ok {
						if content, ok := delta["content"].(string); ok && content != "" {
							chunks <- client.StreamChunk{Text: content}
						}
					}
					if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
						c.usageState.StoreStopReason(reason)
					}
				}
			}

			// Extract usage (some OpenAI models include it in the final chunk)
			if usage := client.ParseOpenAIUsage(evt); usage != nil {
				c.usageState.StoreUsage(usage)
			}
		}

		// Scanner finished without [DONE]
		chunks <- client.StreamChunk{
			Done:       true,
			Usage:      c.usageState.LastUsage(),
			StopReason: c.usageState.LastStopReason(),
		}
	}()

	return chunks
}
