// Package ollama llm.Provider arayüzünün Ollama (/api/chat, NDJSON akışı) uygulamasıdır.
package ollama

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

	"fiko-ai/internal/llm"
)

const providerName = "ollama"

// Client Ollama'ya HTTP üzerinden bağlanır.
type Client struct {
	baseURL      string
	defaultModel string
	embedModel   string // boşsa Embed ErrNoEmbedModel döner (bkz. embed.go)
	keepAlive    string // boşsa keep_alive gönderilmez (Ollama varsayılanı)
	maxContext   int    // >0: istemci num_ctx göndermezse uygulanan tavan
	numGPU       int
	numBatch     int
	numThread    int
	http         *http.Client

	// Retry politikası (bkz. retry.go). Cloud modellerde ollama.com proxy'si
	// ara sıra 502 döndürdüğü için tek deneme yeterli değildir.
	maxAttempts int
	retryBase   time.Duration
}

var (
	_ llm.Provider     = (*Client)(nil)
	_ llm.Embedder     = (*Client)(nil)
	_ llm.ToolProvider = (*Client)(nil)
)

// Tunables, Ollama'ya gönderilecek performans ayarlarını taşır.
// Sıfır alanlar GÖNDERİLMEZ (Ollama varsayılanı geçerli olsun).
type Tunables struct {
	KeepAlive  string
	MaxContext int
	NumGPU     int
	NumBatch   int
	NumThread  int
}

// WithTunables performans ayarlarını uygular ve aynı Client'ı döndürür (zincirlenebilir).
func (c *Client) WithTunables(t Tunables) *Client {
	c.keepAlive = strings.TrimSpace(t.KeepAlive)
	c.maxContext = t.MaxContext
	c.numGPU = t.NumGPU
	c.numBatch = t.NumBatch
	c.numThread = t.NumThread
	return c
}

// New bir Client üretir. headerTimeout yalnızca yanıt başlıklarını bekleme süresidir;
// akış gövdesi için zaman aşımı yoktur (iptal context ile yapılır).
// Not: büyük bir model ilk kez belleğe yüklenirken başlıklar geç gelebilir; gerekirse
// OLLAMA_REQUEST_TIMEOUT değerini artır.
func New(baseURL, defaultModel string, headerTimeout time.Duration) *Client {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	// Varsayılan MaxIdleConnsPerHost YALNIZCA 2'dir. Konuşma akışı art arda
	// istekler (sohbet + tool turu + embedding) atarken bağlantı sürekli açılıp
	// kapanır; TCP el sıkışması ve port tükenmesi gecikme yaratır.
	tr.MaxIdleConns = 64
	tr.MaxIdleConnsPerHost = 64
	tr.IdleConnTimeout = 90 * time.Second
	tr.ForceAttemptHTTP2 = true
	if headerTimeout > 0 {
		tr.ResponseHeaderTimeout = headerTimeout
	}
	return &Client{
		baseURL:      strings.TrimRight(baseURL, "/"),
		defaultModel: defaultModel,
		http:         &http.Client{Transport: tr},
		maxAttempts:  defaultMaxAttempts,
		retryBase:    defaultRetryBaseDelay,
	}
}

// WithRetry tekrar deneme politikasını ayarlar ve aynı Client'ı döndürür (zincirlenebilir).
//
// maxAttempts TOPLAM deneme sayısıdır (1 = tekrar yok). 0 veya eksi değer
// "varsayılan" sayılır. retryBase ilk tekrar arasındaki beklemedir; sonrakiler
// ikiye katlanır (bkz. Client.retryDelay).
func (c *Client) WithRetry(maxAttempts int, retryBase time.Duration) *Client {
	if maxAttempts < 1 {
		maxAttempts = defaultMaxAttempts
	}
	if retryBase < 0 {
		retryBase = defaultRetryBaseDelay
	}
	c.maxAttempts = maxAttempts
	c.retryBase = retryBase
	return c
}

func (c *Client) Name() string { return providerName }

type chatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type chatRequest struct {
	Model    string         `json:"model"`
	Messages []chatMessage  `json:"messages"`
	Stream   bool           `json:"stream"`
	Options  map[string]any `json:"options,omitempty"`
	// KeepAlive modelin VRAM'de kalacağı süredir (bkz. Tunables.KeepAlive).
	// omitempty: boşsa alan hiç gönderilmez, Ollama varsayılanı geçerli olur.
	KeepAlive string `json:"keep_alive,omitempty"`
}

type chatResponse struct {
	Model           string      `json:"model"`
	Message         chatMessage `json:"message"`
	Done            bool        `json:"done"`
	DoneReason      string      `json:"done_reason"`
	PromptEvalCount int         `json:"prompt_eval_count"`
	EvalCount       int         `json:"eval_count"`
	Error           string      `json:"error"`
}

// buildRequest, istemcinin ayarlarıyla (keep_alive, num_ctx tavanı) gövdeyi kurar.
func (c *Client) buildRequest(model string, req llm.Request) chatRequest {
	msgs := make([]chatMessage, 0, len(req.Messages))
	for _, m := range req.Messages {
		msgs = append(msgs, chatMessage{Role: string(m.Role), Content: m.Content})
	}
	cr := chatRequest{
		Model:     model,
		Messages:  msgs,
		Stream:    true,
		Options:   c.optionMap(req.Options),
		KeepAlive: c.keepAlive,
	}
	return cr
}

// numCtx, gönderilecek bağlam penceresini belirler.
//
// Neden tavan var? Ollama num_ctx GÖNDERİLMEZSE model kartındaki değeri kullanır;
// büyük modellerde bu 131072 gibi devasa olur ve KV cache için onlarca GB VRAM ayırır.
// Bağlam yöneticisi (ctxmgr) zaten gönderilecek mesajları ~4k tokena kırptığı için
// devasa pencere hiç kullanılmaz — yalnızca bellek yer. Tavan (MaxContext) bunu engeller.
//
// Kullanıcı AÇIKÇA bir ContextSize verirse DOKUNULMAZ (ayar arayüzü hâlâ çalışır).
func (c *Client) numCtx(opts llm.Options) int {
	if opts.ContextSize > 0 {
		return opts.ContextSize
	}
	return c.maxContext
}

// optionMap istek seçeneklerini Ollama'nın beklediği biçime çevirir.
// Sıfır değerler GÖNDERİLMEZ (sağlayıcı varsayılanı geçerli olsun).
func (c *Client) optionMap(opts llm.Options) map[string]any {
	out := map[string]any{}
	if opts.Temperature != nil {
		out["temperature"] = *opts.Temperature
	}
	if opts.TopP != nil {
		out["top_p"] = *opts.TopP
	}
	if opts.MaxTokens > 0 {
		out["num_predict"] = opts.MaxTokens
	}
	if ctx := c.numCtx(opts); ctx > 0 {
		out["num_ctx"] = ctx
	}
	// GPU yığını: num_gpu = 99 tüm katmanları GPU'ya koyar (CPU'da kalan katman
	// her token'da RAM-GROUND kopuşu demektir ve hızı dramatik düşürür).
	if c.numGPU > 0 {
		out["num_gpu"] = c.numGPU
	}
	if c.numBatch > 0 {
		out["num_batch"] = c.numBatch
	}
	if c.numThread > 0 {
		out["num_thread"] = c.numThread
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// newJSONRequest gövdeli JSON POST isteği kurar.
func newJSONRequest(ctx context.Context, url string, body io.Reader) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	return req, nil
}

// streamReadError akış okuma hatasını anlamlı bir metne çevirir.
// io.EOF "sunucu kapandı" demektir; akış ortasında kesildiğinde BU HATADIR.
func streamReadError(err error) error {
	if errors.Is(err, io.EOF) {
		return fmt.Errorf("ollama: akış beklenmedik şekilde bitti: %w", io.ErrUnexpectedEOF)
	}
	return fmt.Errorf("ollama: akış okunamadı: %w", err)
}

// Generate tüm cevabı toplayıp tek seferde döndürür.
func (c *Client) Generate(ctx context.Context, req llm.Request) (llm.Result, error) {
	return c.GenerateStream(ctx, req, nil)
}

// GenerateStream cevabı parça parça onDelta'ya iletir. ctx iptal edilirse HTTP bağlantısı
// kapanır (Ollama üretimi durdurur) ve kısmi içerikle birlikte ctx.Err() döner.
func (c *Client) GenerateStream(ctx context.Context, req llm.Request, onDelta llm.DeltaFunc) (llm.Result, error) {
	model := req.Model
	if model == "" {
		model = c.defaultModel
	}
	if model == "" {
		return llm.Result{}, llm.ErrNoModel
	}
	res := llm.Result{Model: model}
	if len(req.Messages) == 0 {
		return res, llm.ErrEmptyMessages
	}

	body, err := json.Marshal(c.buildRequest(model, req))
	if err != nil {
		return res, fmt.Errorf("ollama: istek serileştirilemedi: %w", err)
	}
	// Retry HTTP BAŞLIKLARI gelene kadar uygulanır (bkz. doWithRetry). Akış başladıktan
	// sonra tekrar deneme YOKTUR: içerik kullanıcıya akmış olur, ikinci kez akması sohbeti bozar.
	resp, err := c.doWithRetry(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", "application/json")
		return req, nil
	})
	if err != nil {
		if ctx.Err() != nil {
			return res, ctx.Err()
		}
		return res, fmt.Errorf("ollama: istek başarısız: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return res, readHTTPError(resp)
	}

	var sb strings.Builder
	finish := func(err error) (llm.Result, error) {
		res.Content = sb.String()
		return res, err
	}

	r := bufio.NewReaderSize(resp.Body, 64*1024)
	sawDone := false
	for {
		line, rerr := r.ReadBytes('\n')
		if trimmed := bytes.TrimSpace(line); len(trimmed) > 0 {
			var cr chatResponse
			if err := json.Unmarshal(trimmed, &cr); err != nil {
				return finish(fmt.Errorf("ollama: geçersiz akış satırı: %w", err))
			}
			if cr.Error != "" {
				return finish(&llm.ProviderError{Provider: providerName, Message: cr.Error})
			}
			if d := cr.Message.Content; d != "" {
				sb.WriteString(d)
				if onDelta != nil {
					if err := onDelta(d); err != nil {
						return finish(err)
					}
				}
			}
			if cr.Done {
				sawDone = true
				res.DoneReason = cr.DoneReason
				res.PromptTokens = cr.PromptEvalCount
				res.CompletionTokens = cr.EvalCount
				// **Model adı istenen adla BİREBİR aynı olmalı.**
				//
				// Neden cevaptaki `cr.Model` kullanılmıyor? Ollama BULUT modellerinde
				// soneki düşer: "gemma4:31b-cloud" isteğine "gemma4:31b" döner. Bu,
				// mesaj kaydında ve arayüzde "başka bir model çalıştı" izlenimi
				// uyandırıyordu; ürün hatası sanılıyordu. İstenen ad zaten elimizde
				// ve üretim ONUNLA yapıldı, dolayısıyla doğru olan odur.
				res.Model = model
			}
		}
		if sawDone {
			return finish(nil)
		}
		if rerr != nil {
			if ctx.Err() != nil {
				return finish(ctx.Err())
			}
			if errors.Is(rerr, io.EOF) {
				return finish(fmt.Errorf("ollama: akış beklenmedik şekilde bitti: %w", io.ErrUnexpectedEOF))
			}
			return finish(fmt.Errorf("ollama: akış okunamadı: %w", rerr))
		}
	}
}

type tagsResponse struct {
	Models []struct {
		Name       string `json:"name"`
		Size       int64  `json:"size"`
		ModifiedAt string `json:"modified_at"`
	} `json:"models"`
}

// ListModels yerel (ve cloud) modellerin listesini döndürür.
func (c *Client) ListModels(ctx context.Context) ([]llm.ModelInfo, error) {
	resp, err := c.doWithRetry(ctx, func() (*http.Request, error) {
		return http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", nil)
	})
	if err != nil {
		return nil, fmt.Errorf("ollama: model listesi alınamadı: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, readHTTPError(resp)
	}
	var tr tagsResponse
	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
		return nil, fmt.Errorf("ollama: model listesi çözümlenemedi: %w", err)
	}
	out := make([]llm.ModelInfo, 0, len(tr.Models))
	for _, m := range tr.Models {
		mi := llm.ModelInfo{Name: m.Name, SizeBytes: m.Size}
		if t, err := time.Parse(time.RFC3339Nano, m.ModifiedAt); err == nil {
			mi.ModifiedAt = t
		}
		out = append(out, mi)
	}
	return out, nil
}

func readHTTPError(resp *http.Response) error {
	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
	msg := strings.TrimSpace(string(raw))
	var parsed struct {
		Error string `json:"error"`
	}
	if json.Unmarshal(raw, &parsed) == nil && parsed.Error != "" {
		msg = parsed.Error
	}
	if msg == "" {
		msg = http.StatusText(resp.StatusCode)
	}
	return &llm.ProviderError{Provider: providerName, StatusCode: resp.StatusCode, Message: msg}
}
