// Package ollama provides an Adapter that calls a local Ollama server.
package ollama

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"go.uber.org/zap"

	"iop/apps/node/internal/runtime"
	"iop/packages/go/config"
)

const Name = "ollama"

const runtimeMetadataOpenAIToolCalls = "openai_tool_calls"

type Ollama struct {
	instanceName     string
	baseURL          string
	contextSize      int
	capacity         int
	maxQueue         int
	queueTimeoutMS   int
	requestTimeoutMS int
	client           *http.Client
	logger           *zap.Logger
}

// New creates an Ollama adapter. The optional instanceName is the registry
// instance key used to disambiguate multiple Ollama instances on the same node.
func New(cfg config.OllamaConf, logger *zap.Logger, instanceName ...string) *Ollama {
	baseURL := strings.TrimRight(cfg.BaseURL, "/")
	if baseURL == "" {
		baseURL = "http://localhost:11434"
	}
	name := Name
	if len(instanceName) > 0 && instanceName[0] != "" {
		name = instanceName[0]
	}
	return &Ollama{
		instanceName:     name,
		baseURL:          baseURL,
		contextSize:      cfg.ContextSize,
		capacity:         cfg.Capacity,
		maxQueue:         cfg.MaxQueue,
		queueTimeoutMS:   cfg.QueueTimeoutMS,
		requestTimeoutMS: cfg.RequestTimeoutMS,
		client:           &http.Client{},
		logger:           logger,
	}
}

func (o *Ollama) Name() string { return Name }

func (o *Ollama) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
	probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	targets, err := o.fetchTargets(probeCtx)
	status := runtime.ProviderStatusAvailable
	if err != nil {
		status = runtime.ProviderStatusUnavailable
	}
	return runtime.Capabilities{
		AdapterName:      Name,
		InstanceKey:      o.instanceName,
		Targets:          targets,
		MaxConcurrency:   effectiveCapacity(o.capacity, 4),
		MaxQueue:         o.maxQueue,
		QueueTimeoutMS:   o.queueTimeoutMS,
		RequestTimeoutMS: o.requestTimeoutMS,
		ProviderStatus:   runtime.NormalizeProviderStatus(status),
	}, nil
}

func effectiveCapacity(capacity, defaultVal int) int {
	if capacity <= 0 {
		return defaultVal
	}
	return capacity
}

func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
	model := strings.TrimSpace(spec.Target)
	if model == "" {
		model = stringInput(spec.Input, "model")
	}
	if model == "" {
		return fmt.Errorf("ollama adapter: target/model is required")
	}
	messages := ollamaMessagesFromInput(spec.Input)
	if len(messages) == 0 {
		return fmt.Errorf("ollama adapter: prompt/messages are required")
	}

	if err := sink.Emit(ctx, runtime.RuntimeEvent{
		RunID:     spec.RunID,
		Type:      runtime.EventTypeStart,
		Timestamp: time.Now(),
	}); err != nil {
		return err
	}

	chatReq := ollamaChatRequest{
		Model:    model,
		Messages: messages,
		Stream:   true,
	}
	if options := ollamaOptionsFromInput(spec.Input, o.contextSize); len(options) > 0 {
		chatReq.Options = options
	}
	if v, ok := spec.Input["format"]; ok {
		chatReq.Format = v
	}
	if v, ok := spec.Input["keep_alive"]; ok {
		chatReq.KeepAlive = v
	}
	if v, ok := spec.Input["think"]; ok {
		chatReq.Think = v
	}
	if v, ok := spec.Input["tools"]; ok {
		chatReq.Tools = v
	}
	body, err := json.Marshal(chatReq)
	if err != nil {
		return fmt.Errorf("ollama adapter: marshal request: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(o.baseURL, "/api/chat"), bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("ollama adapter: build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	o.logger.Info("ollama adapter executing",
		zap.String("run_id", spec.RunID),
		zap.String("target", model),
		zap.String("base_url", o.baseURL),
		zap.Int("context_size", o.contextSize),
	)
	resp, err := o.client.Do(req)
	if err != nil {
		_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama request failed: %v", err))
		return fmt.Errorf("ollama adapter: request: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		msg := readLimited(resp.Body, 4096)
		if msg == "" {
			msg = resp.Status
		}
		_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama returned %s: %s", resp.Status, msg))
		return fmt.Errorf("ollama adapter: non-2xx response: %s", resp.Status)
	}

	decoder := json.NewDecoder(resp.Body)
	outputTokens := 0
	var toolCalls []any
	for {
		var chunk ollamaChatChunk
		if err := decoder.Decode(&chunk); err != nil {
			if err == io.EOF {
				break
			}
			_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama stream decode failed: %v", err))
			return fmt.Errorf("ollama adapter: decode stream: %w", err)
		}
		if chunk.Error != "" {
			_ = emitError(ctx, sink, spec.RunID, chunk.Error)
			return fmt.Errorf("ollama adapter: %s", chunk.Error)
		}
		if chunk.Message.Thinking != "" {
			if err := sink.Emit(ctx, runtime.RuntimeEvent{
				RunID:     spec.RunID,
				Type:      runtime.EventTypeReasoningDelta,
				Delta:     chunk.Message.Thinking,
				Timestamp: time.Now(),
			}); err != nil {
				return err
			}
		}
		if chunk.Message.Content != "" {
			outputTokens += len(strings.Fields(chunk.Message.Content))
			if err := sink.Emit(ctx, runtime.RuntimeEvent{
				RunID:     spec.RunID,
				Type:      runtime.EventTypeDelta,
				Delta:     chunk.Message.Content,
				Timestamp: time.Now(),
			}); err != nil {
				return err
			}
		}
		if len(chunk.Message.ToolCalls) > 0 {
			toolCalls = append(toolCalls, chunk.Message.ToolCalls...)
		}
		if chunk.Done {
			inputTokens := chunk.PromptEvalCount
			if inputTokens == 0 {
				inputTokens = countMessageWords(messages)
			}
			if chunk.EvalCount > 0 {
				outputTokens = chunk.EvalCount
			}
			var metadata map[string]string
			if len(toolCalls) > 0 {
				if encoded, err := json.Marshal(toolCalls); err == nil {
					metadata = map[string]string{
						"finish_reason":                "tool_calls",
						runtimeMetadataOpenAIToolCalls: string(encoded),
					}
				}
			}
			return sink.Emit(ctx, runtime.RuntimeEvent{
				RunID:   spec.RunID,
				Type:    runtime.EventTypeComplete,
				Message: "ollama chat complete",
				Usage: &runtime.UsageStats{
					InputTokens:  inputTokens,
					OutputTokens: outputTokens,
				},
				Metadata:  metadata,
				Timestamp: time.Now(),
			})
		}
	}
	_ = emitError(ctx, sink, spec.RunID, "ollama stream ended without done=true")
	return fmt.Errorf("ollama adapter: stream ended without done=true")
}

func (o *Ollama) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
	probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	targets, err := o.fetchTargets(probeCtx)

	result := runtime.ProviderProbeResult{
		AdapterName: Name,
		InstanceKey: o.instanceName,
		Target:      target,
	}

	if err != nil {
		result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
		result.Detail = err.Error()
		return result, nil
	}

	result.Targets = targets

	if target == "" {
		result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
		return result, nil
	}

	found := false
	for _, t := range targets {
		if t == target || (strings.Index(target, ":") == -1 && t == target+":latest") {
			found = true
			break
		}
	}

	if found {
		result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
	} else {
		result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
		result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
	}

	return result, nil
}

func (o *Ollama) fetchTargets(ctx context.Context) ([]string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(o.baseURL, "/api/tags"), nil)
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	resp, err := o.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("status code %d", resp.StatusCode)
	}
	var tags ollamaTagsResponse
	if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	out := make([]string, 0, len(tags.Models))
	for _, model := range tags.Models {
		if model.Name != "" {
			out = append(out, model.Name)
		}
	}
	return out, nil
}

func (o *Ollama) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
	if req.Type != runtime.CommandTypeOllamaAPI {
		return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported command %q", req.Type)
	}
	method := strings.ToUpper(strings.TrimSpace(req.Metadata["ollama_method"]))
	if method == "" {
		method = http.MethodGet
	}
	if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete {
		return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported method %q", method)
	}
	path := strings.TrimSpace(req.Metadata["ollama_path"])
	if !strings.HasPrefix(path, "/api/") {
		return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported path %q", path)
	}
	body := req.Metadata["ollama_body"]
	var reader io.Reader
	if body != "" {
		reader = strings.NewReader(body)
	}
	httpReq, err := http.NewRequestWithContext(ctx, method, joinURLRequestURI(o.baseURL, path), reader)
	if err != nil {
		return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: build passthrough request: %w", err)
	}
	if body != "" {
		httpReq.Header.Set("Content-Type", "application/json")
	}
	resp, err := o.client.Do(httpReq)
	if err != nil {
		return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: passthrough request: %w", err)
	}
	defer resp.Body.Close()
	respBody := readLimited(resp.Body, 16<<20)
	return runtime.CommandResponse{
		RequestID: req.RequestID,
		Type:      req.Type,
		Adapter:   req.Adapter,
		Target:    req.Target,
		SessionID: req.SessionID,
		Result: map[string]string{
			"status_code":  fmt.Sprintf("%d", resp.StatusCode),
			"content_type": resp.Header.Get("Content-Type"),
			"body":         respBody,
		},
	}, nil
}

func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
	if raw, ok := input["messages"].([]any); ok {
		out := make([]ollamaMessage, 0, len(raw))
		for _, item := range raw {
			m, ok := item.(map[string]any)
			if !ok {
				continue
			}
			role, _ := m["role"].(string)
			content, _ := m["content"].(string)
			thinking, _ := m["thinking"].(string)
			toolName, _ := m["tool_name"].(string)
			role = strings.TrimSpace(role)
			content = strings.TrimSpace(content)
			if role == "" || (content == "" && thinking == "") {
				continue
			}
			out = append(out, ollamaMessage{
				Role:      role,
				Content:   content,
				Thinking:  thinking,
				Images:    anySlice(m["images"]),
				ToolCalls: anySlice(m["tool_calls"]),
				ToolName:  toolName,
			})
		}
		if len(out) > 0 {
			return out
		}
	}
	prompt := strings.TrimSpace(stringInput(input, "prompt"))
	if prompt == "" {
		return nil
	}
	return []ollamaMessage{{Role: "user", Content: prompt}}
}

func ollamaOptionsFromInput(input map[string]any, contextSize int) map[string]any {
	options := map[string]any{}
	if raw, ok := input["options"].(map[string]any); ok {
		for k, v := range raw {
			options[k] = v
		}
	}
	if contextSize > 0 {
		options["num_ctx"] = contextSize // Edge-owned: always overrides request
	}
	if len(options) == 0 {
		return nil
	}
	return options
}

func anySlice(v any) []any {
	if items, ok := v.([]any); ok {
		return items
	}
	return nil
}

func stringInput(input map[string]any, key string) string {
	if input == nil {
		return ""
	}
	if v, ok := input[key].(string); ok {
		return v
	}
	return ""
}

func countMessageWords(messages []ollamaMessage) int {
	n := 0
	for _, msg := range messages {
		n += len(strings.Fields(msg.Content))
	}
	return n
}

func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
	return sink.Emit(ctx, runtime.RuntimeEvent{
		RunID:     runID,
		Type:      runtime.EventTypeError,
		Error:     msg,
		Timestamp: time.Now(),
	})
}

func joinURL(baseURL, path string) string {
	u, err := url.Parse(baseURL)
	if err != nil {
		return strings.TrimRight(baseURL, "/") + path
	}
	u.Path = strings.TrimRight(u.Path, "/") + path
	return u.String()
}

func joinURLRequestURI(baseURL, requestURI string) string {
	u, err := url.Parse(baseURL)
	if err != nil {
		return strings.TrimRight(baseURL, "/") + requestURI
	}
	rel, err := url.Parse(requestURI)
	if err != nil {
		u.Path = strings.TrimRight(u.Path, "/") + requestURI
		return u.String()
	}
	u.Path = strings.TrimRight(u.Path, "/") + rel.Path
	u.RawQuery = rel.RawQuery
	return u.String()
}

func readLimited(r io.Reader, limit int64) string {
	b, _ := io.ReadAll(io.LimitReader(r, limit))
	return strings.TrimSpace(string(b))
}

type ollamaChatRequest struct {
	Model     string          `json:"model"`
	Messages  []ollamaMessage `json:"messages"`
	Stream    bool            `json:"stream"`
	Options   map[string]any  `json:"options,omitempty"`
	Format    any             `json:"format,omitempty"`
	KeepAlive any             `json:"keep_alive,omitempty"`
	Think     any             `json:"think,omitempty"`
	Tools     any             `json:"tools,omitempty"`
}

type ollamaMessage struct {
	Role      string `json:"role"`
	Content   string `json:"content"`
	Thinking  string `json:"thinking,omitempty"`
	Images    []any  `json:"images,omitempty"`
	ToolCalls []any  `json:"tool_calls,omitempty"`
	ToolName  string `json:"tool_name,omitempty"`
}

type ollamaChatChunk struct {
	Message         ollamaMessage `json:"message"`
	Done            bool          `json:"done"`
	PromptEvalCount int           `json:"prompt_eval_count"`
	EvalCount       int           `json:"eval_count"`
	Error           string        `json:"error"`
}

type ollamaTagsResponse struct {
	Models []struct {
		Name string `json:"name"`
	} `json:"models"`
}
