package openai

import (
	"encoding/json"
	"fmt"

	"github.com/mudler/LocalAI/core/backend"
	"github.com/mudler/LocalAI/core/schema"
	"github.com/mudler/LocalAI/pkg/functions"
)

// streamWorkerResult is what the streaming workers (process / processTools)
// hand back to the outer ChatEndpoint loop through the `ended` channel.
// Threading the final TokenUsage here, instead of piggy-backing it on the
// `responses` SSE channel, keeps the SSE channel single-purpose (wire chunks)
// and gives the trailer emitter a plain Go value to read after LOOP exits.
// Fix for issue #9927: the previous tools-path worker never surfaced the
// cumulative token counts at all, so the include_usage trailer reported zeros.
type streamWorkerResult struct {
	usage backend.TokenUsage
	err   error
}

// streamUsageFromTokenUsage converts the backend's cumulative TokenUsage into
// the OpenAI-spec OpenAIUsage shape used on the wire. `extraUsage` controls
// whether the non-standard timing fields are forwarded.
func streamUsageFromTokenUsage(usage backend.TokenUsage, extraUsage bool) schema.OpenAIUsage {
	out := schema.OpenAIUsage{
		PromptTokens:     usage.Prompt,
		CompletionTokens: usage.Completion,
		TotalTokens:      usage.Prompt + usage.Completion,
	}
	if extraUsage {
		out.TimingTokenGeneration = usage.TimingTokenGeneration
		out.TimingPromptProcessing = usage.TimingPromptProcessing
	}
	return out
}

// streamUsageTrailerJSON returns the bytes of the OpenAI-spec trailing usage
// chunk emitted in streaming completions when the request opts in via
// `stream_options.include_usage: true`. The shape is:
//
//	{"id":"...","object":"chat.completion.chunk","created":N,
//	 "model":"...","choices":[],"usage":{...}}
//
// `choices` is intentionally an empty array (not absent, not null) — that is
// what the OpenAI spec mandates, and what consumers like the official OpenAI
// SDK and Continue's openai-adapter look for to recognise this as the usage
// chunk rather than a content chunk. schema.OpenAIResponse has `omitempty`
// on Choices, so we cannot reuse it for the trailer.
func streamUsageTrailerJSON(id, model string, created int, usage schema.OpenAIUsage) []byte {
	trailer := struct {
		ID      string             `json:"id"`
		Created int                `json:"created"`
		Model   string             `json:"model"`
		Object  string             `json:"object"`
		Choices []schema.Choice    `json:"choices"`
		Usage   schema.OpenAIUsage `json:"usage"`
	}{
		ID:      id,
		Created: created,
		Model:   model,
		Object:  "chat.completion.chunk",
		Choices: []schema.Choice{},
		Usage:   usage,
	}
	b, _ := json.Marshal(trailer)
	return b
}

// hasRealCall reports whether functionResults contains at least one
// entry whose Name is something other than the noAction sentinel.
// Used by processTools to decide between the "answer the question"
// path and the real tool-call flush.
func hasRealCall(functionResults []functions.FuncCallResults, noAction string) bool {
	for _, fc := range functionResults {
		if fc.Name != noAction {
			return true
		}
	}
	return false
}

// buildNoActionFinalChunks produces the closing SSE chunks for the
// noActionToRun branch of processTools (i.e. the model chose the "answer"
// pseudo-function or emitted no tool calls at all).
//
// When content was already streamed (contentAlreadyStreamed=true) the
// helper emits a trailing reasoning chunk if any non-streamed reasoning
// remains, else nothing. When content was not streamed it emits a role
// chunk followed by a content (+reasoning) chunk — the "send everything
// at once" fallback.
//
// Reasoning re-emission is guarded by reasoningAlreadyStreamed, not by
// probing the extractor's Go-side state: the C++ autoparser delivers
// reasoning through ProcessChatDeltaReasoning which populates a
// separate accumulator that extractor.Reasoning() does not expose.
// Without this guard the callback would stream reasoning incrementally
// and the final chunk would duplicate it.
//
// The returned chunks intentionally do NOT carry a `usage` field. The
// usage trailer is emitted separately by the streaming handler when
// `stream_options.include_usage` is true, per OpenAI spec.
func buildNoActionFinalChunks(
	id, model string,
	created int,
	contentAlreadyStreamed bool,
	reasoningAlreadyStreamed bool,
	content string,
	reasoning string,
) []schema.OpenAIResponse {
	var out []schema.OpenAIResponse

	if contentAlreadyStreamed {
		if reasoning == "" || reasoningAlreadyStreamed {
			return nil
		}
		r := reasoning
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{Reasoning: &r},
				Index: 0,
			}},
			Object: "chat.completion.chunk",
		})
		return out
	}

	// Content was not streamed — send role, then content (+reasoning).
	out = append(out, schema.OpenAIResponse{
		ID: id, Created: created, Model: model,
		Choices: []schema.Choice{{
			Delta: &schema.Message{Role: "assistant"},
			Index: 0,
		}},
		Object: "chat.completion.chunk",
	})

	c := content
	delta := &schema.Message{Content: &c}
	if reasoning != "" && !reasoningAlreadyStreamed {
		r := reasoning
		delta.Reasoning = &r
	}
	out = append(out, schema.OpenAIResponse{
		ID: id, Created: created, Model: model,
		Choices: []schema.Choice{{Delta: delta, Index: 0}},
		Object:  "chat.completion.chunk",
	})
	return out
}

// buildDeferredToolCallChunks produces the SSE chunks for tool calls that
// were discovered only during final parsing (i.e. after the streaming
// callback finished). The caller forwards every returned chunk to the
// responses channel.
//
// Guarantees:
//   - tool calls with i < lastEmittedCount are skipped (already streamed)
//   - each emitted call yields two chunks: name-only, then args-only
//   - no chunk ever carries both non-empty Content and non-empty ToolCalls
//   - no chunk ever carries both non-empty Reasoning and non-empty ToolCalls
//   - if !reasoningAlreadyStreamed && reasoningContent != "",
//     a reasoning chunk is emitted first
//   - if !contentAlreadyStreamed && textContent != "",
//     a role chunk followed by a content chunk is emitted (after reasoning)
//   - chunks order: [reasoning?] [role+content?] (name, args)+
//   - fallback IDs for empty ss.ID are unique per index so a client can
//     match tool_result messages back to the right call
func buildDeferredToolCallChunks(
	id, model string,
	created int,
	functionResults []functions.FuncCallResults,
	lastEmittedCount int,
	contentAlreadyStreamed bool,
	textContent string,
	reasoningAlreadyStreamed bool,
	reasoningContent string,
) []schema.OpenAIResponse {
	// If every call was already emitted incrementally there's nothing to
	// flush — and no reason to emit a standalone reasoning/content chunk.
	hasDeferred := false
	for i := range functionResults {
		if i >= lastEmittedCount {
			hasDeferred = true
			break
		}
	}
	if !hasDeferred {
		return nil
	}

	var out []schema.OpenAIResponse

	// Reasoning first — the callback path at processTools emits reasoning
	// incrementally in its own chunks, but when the C++ autoparser only
	// surfaces reasoning as a final aggregate the callback never sees it.
	// Recover it here (no duplication: contentAlreadyStreamed and
	// reasoningAlreadyStreamed track what the callback already sent).
	if !reasoningAlreadyStreamed && reasoningContent != "" {
		r := reasoningContent
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{Reasoning: &r},
				Index: 0,
			}},
			Object: "chat.completion.chunk",
		})
	}

	// Then content, when it wasn't streamed via the callback. Emit role
	// and content in separate deltas — the OpenAI streaming contract
	// forbids bundling content alongside tool_calls in one delta.
	if !contentAlreadyStreamed && textContent != "" {
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{Role: "assistant"},
				Index: 0,
			}},
			Object: "chat.completion.chunk",
		})
		c := textContent
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{Content: &c},
				Index: 0,
			}},
			Object: "chat.completion.chunk",
		})
	}

	for i, ss := range functionResults {
		if i < lastEmittedCount {
			// Already streamed by the incremental JSON/XML parser during
			// the token callback — skip to avoid a duplicate emission.
			continue
		}

		toolCallID := ss.ID
		if toolCallID == "" {
			// Unique per-index fallback so multiple empty-ID calls don't
			// collide on the same request ID (clients match tool results
			// back by tool_call_id).
			toolCallID = fmt.Sprintf("%s-%d", id, i)
		}

		// Name chunk.
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{
					Role: "assistant",
					ToolCalls: []schema.ToolCall{{
						Index: i,
						ID:    toolCallID,
						Type:  "function",
						FunctionCall: schema.FunctionCall{
							Name: ss.Name,
						},
					}},
				},
				Index:        0,
				FinishReason: nil,
			}},
			Object: "chat.completion.chunk",
		})

		// Args chunk — no Content here. Either it was streamed through
		// the token callback earlier, or the role+content pair above
		// already delivered it.
		out = append(out, schema.OpenAIResponse{
			ID: id, Created: created, Model: model,
			Choices: []schema.Choice{{
				Delta: &schema.Message{
					Role: "assistant",
					ToolCalls: []schema.ToolCall{{
						Index: i,
						ID:    toolCallID,
						Type:  "function",
						FunctionCall: schema.FunctionCall{
							Arguments: ss.Arguments,
						},
					}},
				},
				Index:        0,
				FinishReason: nil,
			}},
			Object: "chat.completion.chunk",
		})
	}

	return out
}
