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

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"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"
)

// OpenAIResponsesClient implementa o cliente para a API /v1/responses
type OpenAIResponsesClient struct {
	provider    auth.TokenProvider
	model       string
	logger      *zap.Logger
	httpClient  *http.Client
	maxAttempts int
	backoff     time.Duration
	usageState  client.UsageState
}

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

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

// NewOpenAIResponsesClient cria uma nova instância de OpenAIResponsesClient.
// Agora sem fallback interno: usa apenas os params passados (vindos do manager/ENVs).
func NewOpenAIResponsesClient(provider auth.TokenProvider, model string, logger *zap.Logger, maxAttempts int, backoff time.Duration) *OpenAIResponsesClient {
	var httpClient *http.Client
	if provider.Mode() == auth.AuthModeOAuth {
		// OAuth requests go to chatgpt.com which requires a browser-like TLS fingerprint
		httpClient = utils.NewHTTPClientWithTransport(logger, 900*time.Second, utils.NewChromeTLSTransport())
	} else {
		httpClient = utils.NewHTTPClient(logger, 900*time.Second)
	}
	return &OpenAIResponsesClient{
		provider:    provider,
		model:       strings.ToLower(model),
		logger:      logger,
		httpClient:  httpClient,
		maxAttempts: maxAttempts,
		backoff:     backoff,
	}
}

func (c *OpenAIResponsesClient) GetModelName() string {
	return catalog.GetDisplayName(catalog.ProviderOpenAI, c.model)
}

func (c *OpenAIResponsesClient) 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)
}

// supportsReasoningEffort reports whether the given OpenAI model id accepts
// the `reasoning.effort` request field. The OpenAI /v1/responses API only
// honors this field on the o-series reasoning models (o1, o3, o4, GPT-5).
// Non-reasoning chat models (gpt-4o, gpt-4.1, etc.) reject unknown fields.
func supportsReasoningEffort(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 *OpenAIResponsesClient) 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 reqBody map[string]interface{}

	if isOAuth {
		// ChatGPT backend requires "instructions" and structured input
		instructions, conversationInput := buildOAuthPayload(history, prompt)
		reqBody = map[string]interface{}{
			"model":        c.model,
			"instructions": instructions,
			"input":        conversationInput,
			"store":        false,
			"stream":       true,
		}
	} else if historyHasImages(history) {
		// Vision: the Responses API cannot carry images in a flattened text
		// string, so switch to the structured role+parts input array (same
		// shape as the OAuth path). Only taken when an image is actually
		// attached, so text-only requests keep the byte-identical flat path.
		instructions, conversationInput := buildOAuthPayload(history, prompt)
		reqBody = map[string]interface{}{
			"model":             c.model,
			"instructions":      instructions,
			"input":             conversationInput,
			"max_output_tokens": effectiveMaxTokens,
		}
	} else {
		input := buildTextFromHistory(history, "")
		// Fallback: se o history não tem o último turno do user (edge-case),
		// anexa o prompt no final do input.
		if len(history) == 0 || history[len(history)-1].Role != "user" || history[len(history)-1].Content != prompt {
			if strings.TrimSpace(prompt) != "" {
				if strings.TrimSpace(input) != "" {
					input += "\n"
				}
				input += "User: " + prompt
			}
		}
		reqBody = map[string]interface{}{
			"model":             c.model,
			"input":             input,
			"max_output_tokens": effectiveMaxTokens,
		}
	}

	// Skill effort hint → reasoning.effort.
	// Applies to both OAuth and API key paths; the ChatGPT backend quietly
	// ignores the field for non-reasoning models, so this is safe.
	if eff := client.ReasoningEffortForOpenAI(client.EffortFromContext(ctx)); eff != "" && supportsReasoningEffort(c.model) {
		reqBody["reasoning"] = map[string]interface{}{
			"effort": eff,
		}
		c.logger.Debug("openai_responses: applying reasoning effort from skill hint",
			zap.String("effort", eff),
			zap.String("model", c.model))
	}

	jsonValue, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("%s: %w", i18n.T("llm.responses.prepare_payload"), err)
	}

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

	// Retry para encapsular a lógica de requisição e parsing
	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() }()
		if isOAuth {
			return c.processStreamResponse(resp)
		}
		return c.processResponse(resp)
	})

	if err != nil {
		client.LogRequestFinish(c.logger, "OPENAI", c.model, "error", time.Since(start),
			zap.String("path", "responses"),
		)
		c.logger.Error(i18n.T("llm.responses.get_response_error"), zap.Error(err))
		return "", err
	}

	client.LogRequestFinish(c.logger, "OPENAI", c.model, "success", time.Since(start),
		zap.String("path", "responses"),
		zap.Int("response_chars", len(response)),
	)
	return response, nil
}

func (c *OpenAIResponsesClient) sendRequest(ctx context.Context, body []byte, token string) (*http.Response, error) {
	// OAuth tokens use the ChatGPT backend; API keys use the platform API
	var apiURL string
	if c.provider.Mode() == auth.AuthModeOAuth {
		apiURL = utils.GetEnvOrDefault("OPENAI_RESPONSES_API_URL", config.OpenAIOAuthResponsesURL)
	} else {
		apiURL = utils.GetEnvOrDefault("OPENAI_RESPONSES_API_URL", config.OpenAIResponsesAPIURL)
	}

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

	return c.httpClient.Do(req)
}

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

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

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

	// Estrutura de decodificação mais detalhada para capturar todos os casos
	var response struct {
		Status            string `json:"status"`
		OutputText        string `json:"output_text"`
		IncompleteDetails *struct {
			Reason string `json:"reason"`
		} `json:"incomplete_details"`
		Output []struct {
			Type    string `json:"type"` // "message" ou "reasoning"
			Content []struct {
				Type string `json:"type"` // "output_text"
				Text string `json:"text"`
			} `json:"content"`
		} `json:"output"`
		Error *struct {
			Message string `json:"message"`
		} `json:"error"`
	}

	if err := json.Unmarshal(bodyBytes, &response); err != nil {
		return "", fmt.Errorf("%s: %w", i18n.T("llm.responses.decode_response"), err)
	}

	// Extract usage and stop reason from the Responses API format.
	// Note: Responses uses input_tokens/output_tokens (NOT prompt_/completion_),
	// so ParseOpenAIResponsesUsage is required — ParseOpenAIUsage silently
	// returns zeros on this schema and the envelope ends up rendering the
	// "no tokens" placeholder.
	if usage, parseErr := client.ParseOpenAIResponsesUsage(bodyBytes); parseErr != nil {
		c.logger.Debug("openai_responses: usage decode failed", zap.Error(parseErr))
	} else if usage != nil {
		c.usageState.StoreUsage(usage)
	}
	// Responses API uses "status" instead of choices[].finish_reason.
	// A second targeted decode is cheaper than dragging a map[string]any
	// through the public usage API just to read one field.
	var statusEnvelope struct {
		Status string `json:"status"`
	}
	if err := json.Unmarshal(bodyBytes, &statusEnvelope); err == nil && statusEnvelope.Status != "" {
		c.usageState.StoreStopReason(statusEnvelope.Status)
	}

	// Tentar extrair do caminho simples primeiro (comum em mocks e respostas diretas)
	if response.OutputText != "" {
		return response.OutputText, nil
	}

	// 1. Verificar se a API retornou um erro explícito no corpo
	if response.Error != nil && response.Error.Message != "" {
		c.logger.Error(i18n.T("llm.responses.api_error"), zap.String("error_message", response.Error.Message))
		return "", fmt.Errorf("%s", i18n.T("llm.responses.api_error_msg", response.Error.Message))
	}

	// 2. Verificar o status da resposta
	if response.Status == "incomplete" {
		if response.IncompleteDetails != nil && response.IncompleteDetails.Reason == "max_output_tokens" {
			c.logger.Warn(i18n.T("llm.responses.incomplete_max_tokens"), zap.Int("body_size", len(bodyBytes)))
			return "", errors.New(i18n.T("llm.responses.incomplete_max_tokens"))
		}
		return "", fmt.Errorf("%s", i18n.T("llm.responses.incomplete_unknown", response.Status))
	}

	// 3. Iterar para encontrar o texto apenas se o status for 'completed'
	var sb strings.Builder
	for _, item := range response.Output {
		// Procurar especificamente pelo item do tipo 'message'
		if item.Type == "message" {
			for _, content := range item.Content {
				// E dentro dele, pelo conteúdo do tipo 'output_text'
				if content.Type == "output_text" && strings.TrimSpace(content.Text) != "" {
					sb.WriteString(content.Text)
				}
			}
		}
	}

	if sb.Len() > 0 {
		return sb.String(), nil
	}

	// Se chegou aqui, não foi possível extrair
	c.logger.Warn(i18n.T("llm.warn.could_not_extract_text"), zap.Int("body_size", len(bodyBytes)))
	return "", errors.New(i18n.T("llm.warn.could_not_extract_text_error"))
}

// processStreamResponse handles SSE streaming responses from the ChatGPT backend.
func (c *OpenAIResponsesClient) processStreamResponse(resp *http.Response) (string, error) {
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		bodyBytes, readErr := io.ReadAll(resp.Body)
		msg := "(unable to read response body)"
		if readErr == nil {
			msg = utils.SanitizeSensitiveText(string(bodyBytes))
		}
		return "", &utils.APIError{StatusCode: resp.StatusCode, Message: msg}
	}

	var sb strings.Builder
	scanner := bufio.NewScanner(resp.Body)
	// Increase buffer for potentially large SSE lines
	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]" {
			break
		}

		var event struct {
			Type     string          `json:"type"`
			Delta    string          `json:"delta"`
			Response json.RawMessage `json:"response,omitempty"`
		}
		if err := json.Unmarshal([]byte(data), &event); err != nil {
			continue
		}

		if event.Type == "response.output_text.delta" && event.Delta != "" {
			sb.WriteString(event.Delta)
		}

		// Extract usage from the response.completed event. Responses API
		// schema (input_tokens / output_tokens) — must use the Responses
		// parser, not the Chat Completions one.
		if event.Type == "response.completed" && len(event.Response) > 0 {
			if usage, parseErr := client.ParseOpenAIResponsesUsage(event.Response); parseErr != nil {
				c.logger.Debug("openai_responses: stream usage decode failed", zap.Error(parseErr))
			} else if usage != nil {
				c.usageState.StoreUsage(usage)
			}
			var statusEnvelope struct {
				Status string `json:"status"`
			}
			if err := json.Unmarshal(event.Response, &statusEnvelope); err == nil && statusEnvelope.Status != "" {
				c.usageState.StoreStopReason(statusEnvelope.Status)
			}
		}
	}

	if err := scanner.Err(); err != nil {
		return "", fmt.Errorf("%s: %w", i18n.T("llm.error.read_sse_stream"), err)
	}

	if sb.Len() == 0 {
		return "", errors.New(i18n.T("llm.error.no_text_from_sse"))
	}

	return sb.String(), nil
}

// historyHasImages reports whether any message in the history carries a
// valid vision attachment, used to decide between the flat-text and the
// structured input shapes for the Responses API.
func historyHasImages(history []models.Message) bool {
	for _, m := range history {
		if visionwire.HasImages(m.Images) {
			return true
		}
	}
	return false
}

// buildOAuthPayload extracts system messages as instructions and builds
// structured conversation items for the ChatGPT backend Responses API.
func buildOAuthPayload(history []models.Message, prompt string) (string, []map[string]interface{}) {
	var instructions strings.Builder
	var input []map[string]interface{}

	for _, m := range history {
		role := strings.ToLower(strings.TrimSpace(m.Role))
		switch role {
		case "system":
			if instructions.Len() > 0 {
				instructions.WriteString("\n")
			}
			instructions.WriteString(m.Content)
		case "assistant":
			input = append(input, map[string]interface{}{"role": "assistant", "content": m.Content})
		default:
			input = append(input, map[string]interface{}{"role": "user", "content": visionwire.ResponsesUserContent(m.Content, m.Images)})
		}
	}

	// Append current prompt if not already the last user message
	if len(history) == 0 || history[len(history)-1].Role != "user" || history[len(history)-1].Content != prompt {
		if strings.TrimSpace(prompt) != "" {
			input = append(input, map[string]interface{}{"role": "user", "content": prompt})
		}
	}

	inst := instructions.String()
	if inst == "" {
		inst = "You are a helpful assistant."
	}

	return inst, input
}

// ListModels fetches available models from the OpenAI platform API.
// For OAuth tokens this returns nil — the ChatGPT backend model listing is
// not reliable for the Codex API (different slugs, plan-specific availability,
// Cloudflare challenges). The LLMManager merges with the static catalog, so
// OAuth users still see all known Codex-compatible models.
func (c *OpenAIResponsesClient) ListModels(ctx context.Context) ([]client.ModelInfo, error) {
	if c.provider.Mode() == auth.AuthModeOAuth {
		return nil, nil
	}
	return c.listModelsAPIKey(ctx)
}

// listModelsAPIKey fetches models from the standard OpenAI /v1/models endpoint.
func (c *OpenAIResponsesClient) listModelsAPIKey(ctx context.Context) ([]client.ModelInfo, error) {
	modelsURL := utils.GetEnvOrDefault("OPENAI_API_URL", config.OpenAIAPIURL)
	modelsURL = strings.TrimSuffix(modelsURL, "/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_for", "OpenAI"), err)
		}
		req.Header.Set("Authorization", "Bearer "+token)
		return c.httpClient.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("%s", i18n.T("llm.error.api_error_code", "OpenAI", resp.StatusCode, utils.SanitizeSensitiveText(body)))
	}

	var result struct {
		Data []struct {
			ID string `json:"id"`
		} `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)
	}

	modelList := make([]client.ModelInfo, 0, len(result.Data))
	for _, m := range result.Data {
		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
		}
		modelList = append(modelList, 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.APIResponses,
			})
		}
	}

	c.logger.Info(i18n.T("llm.responses.fetch_models_apikey"), zap.Int("count", len(modelList)))
	return modelList, nil
}

func buildTextFromHistory(history []models.Message, prompt string) string {
	var b strings.Builder
	// opcional: preservar alguma instrução de sistema (quando houver)
	for _, m := range history {
		role := strings.ToLower(strings.TrimSpace(m.Role))
		switch role {
		case "system":
			b.WriteString("System: ")
		case "assistant":
			b.WriteString("Assistant: ")
		default:
			b.WriteString("User: ")
		}
		b.WriteString(m.Content)
		b.WriteString("\n")
	}
	b.WriteString("User: ")
	b.WriteString(prompt)
	return b.String()
}
