package knowledge

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"log/slog"
	"path/filepath"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"fiko-ai/internal/event"
	"fiko-ai/internal/llm"
	"fiko-ai/pkg/id"
)

var (
	// ErrTooLarge belge metni MaxBytes'ı aştığında döner.
	ErrTooLarge = errors.New("knowledge: belge çok büyük")
	// ErrTooManyChunks belge MaxChunks'tan fazla parçaya bölünürse döner.
	ErrTooManyChunks = errors.New("knowledge: belge çok fazla parçaya bölündü")
	// ErrNoEmbedder embedding gerektiren bir işlem embedding sağlayıcısı yokken istenirse döner.
	ErrNoEmbedder = errors.New("knowledge: embedding sağlayıcısı yapılandırılmamış (OLLAMA_EMBED_MODEL ayarla)")
)

// DuplicateError aynı içeriğin zaten yüklü olduğunu, mevcut belgenin kimliğiyle bildirir.
// errors.Is(err, ErrDuplicate) doğrudur.
type DuplicateError struct{ DocumentID string }

func (e *DuplicateError) Error() string {
	return "knowledge: aynı içerik zaten yüklü (belge " + e.DocumentID + ")"
}
func (e *DuplicateError) Is(target error) bool { return target == ErrDuplicate }

// Options servis ayarlarıdır; sıfır değerler varsayılanla değiştirilir.
type Options struct {
	Chunk        ChunkOptions
	MaxBytes     int           // tek belgenin üst sınırı (bayt); varsayılan 5 MiB
	MaxChunks    int           // belge başına en çok parça; varsayılan 4000
	EmbedTimeout time.Duration // tek embedding çağrısının üst sınırı; varsayılan 30 sn
	Logger       *slog.Logger
}

const (
	defaultMaxBytes     = 5 << 20
	defaultMaxChunks    = 4000
	defaultEmbedTimeout = 30 * time.Second

	// embedFailLimit art arda bu kadar embedding hatasında belgenin işi bırakılır
	// (sağlayıcı çökmüşken yüzlerce parçayı tek tek denemenin anlamı yok).
	embedFailLimit = 3
	// progressEvery kaç parçada bir ilerleme kaydedilir (arayüz "12/40" gösterebilsin).
	progressEvery = 10
	// embedTitleRunes embedding metnine giren başlığın üst sınırı (num_ctx=512'yi korumak için).
	embedTitleRunes = 80
)

// Service Bilgi Tabanı iş kurallarıdır: belge ekleme/silme/listeleme, arka plan embedding
// ve arama. Eşzamanlı kullanıma güvenlidir.
type Service struct {
	repo Repository
	bus  event.Bus // nil olabilir
	log  *slog.Logger
	opt  Options

	embMu      sync.RWMutex
	embedder   llm.Embedder
	embedModel string

	// Arka plan işleri (embedding). Close iptal edip bitmelerini bekler.
	ctx    context.Context
	cancel context.CancelFunc
	mu     sync.Mutex // closed + wg.Add'i aynı kilitle korur (Add, Wait ile yarışmasın)
	closed bool
	wg     sync.WaitGroup
	sem    chan struct{} // embedding işlerini sıraya dizer: aynı GPU'da çekişme olmasın
}

// NewService servisi kurar. Embedding için SetEmbedder çağrılmalıdır; çağrılmazsa belgeler
// yalnızca anahtar kelimeyle aranır.
func NewService(repo Repository, bus event.Bus, opt Options) *Service {
	if opt.MaxBytes <= 0 {
		opt.MaxBytes = defaultMaxBytes
	}
	if opt.MaxChunks <= 0 {
		opt.MaxChunks = defaultMaxChunks
	}
	if opt.EmbedTimeout <= 0 {
		opt.EmbedTimeout = defaultEmbedTimeout
	}
	if opt.Chunk.Size <= 0 {
		opt.Chunk.Size = DefaultChunkSize
	}
	opt.Chunk = opt.Chunk.normalized()
	log := opt.Logger
	if log == nil {
		log = slog.Default()
	}
	ctx, cancel := context.WithCancel(context.Background())
	return &Service{
		repo: repo, bus: bus, log: log, opt: opt,
		ctx: ctx, cancel: cancel, sem: make(chan struct{}, 1),
	}
}

// SetEmbedder embedding sağlayıcısını ve MODEL ADINI bağlar; nil verilirse embedding kapanır.
// Model adı vektörlere damgalanır: model değişince eski vektörler "bayat" sayılır
// (aynı boyutta olsalar bile farklı uzaydadırlar). Kurulumda, işler başlamadan çağrılmalıdır.
func (s *Service) SetEmbedder(e llm.Embedder, model string) {
	s.embMu.Lock()
	defer s.embMu.Unlock()
	s.embedder, s.embedModel = e, model
}

func (s *Service) embedderSnapshot() (llm.Embedder, string) {
	s.embMu.RLock()
	defer s.embMu.RUnlock()
	return s.embedder, s.embedModel
}

// Close arka plan işlerini iptal eder ve bitmelerini bekler (ctx süresi dolana kadar).
// Yarım kalan belgeler "indexing" durumunda kalır; sonraki açılışta Resume sürdürür.
func (s *Service) Close(ctx context.Context) error {
	s.mu.Lock()
	s.closed = true
	s.mu.Unlock()
	s.cancel()
	done := make(chan struct{})
	go func() { s.wg.Wait(); close(done) }()
	select {
	case <-done:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// goJob arka plan işi başlatır; servis kapanmışsa false döner.
func (s *Service) goJob(fn func(ctx context.Context)) bool {
	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		return false
	}
	s.wg.Add(1)
	s.mu.Unlock()
	go func() {
		defer s.wg.Done()
		fn(s.ctx)
	}()
	return true
}

func (s *Service) now() time.Time { return time.Now().UTC().Truncate(time.Millisecond) }

func (s *Service) publish(ctx context.Context, t event.Type, userID string, data map[string]any) {
	if s.bus == nil {
		return
	}
	s.bus.Publish(ctx, event.Event{Type: t, UserID: userID, Data: data})
}

// IngestInput belge ekleme isteğidir. Metin DOĞRUDAN (Text) ya da dosya baytları (Data) olarak verilir.
type IngestInput struct {
	UserID    string
	ProjectID string

	Title       string
	Source      string
	Filename    string
	ContentType string
	Tags        []string

	Text string // doğrudan metin; doluysa Data yok sayılır
	Data []byte // dosya içeriği (Filename/ContentType biçimi belirler)
}

// Ingest belgeyi doğrular, metne çevirir, parçalar ve kaydeder. Embedding ARKA PLANDA üretilir:
// dönen belge "indexing" durumundadır (embedding kapalıysa "ready") ve anahtar kelimeyle
// hemen aranabilir. Uzun belgede HTTP isteği embedding'i beklemez.
func (s *Service) Ingest(ctx context.Context, in IngestInput) (*Document, error) {
	title, tags, verrs := s.validateIngest(&in)
	if len(verrs) > 0 {
		return nil, &ValidationError{Errors: verrs}
	}

	text := in.Text
	if strings.TrimSpace(text) == "" {
		if len(in.Data) == 0 {
			return nil, &ValidationError{Errors: []FieldError{{Field: "text", Message: "metin ya da dosya içeriği gerekli"}}}
		}
		if len(in.Data) > s.opt.MaxBytes {
			return nil, fmt.Errorf("%w: en fazla %d bayt", ErrTooLarge, s.opt.MaxBytes)
		}
		var err error
		if text, err = Parse(in.Filename, in.ContentType, in.Data); err != nil {
			return nil, err
		}
	} else {
		text = NormalizeText(text)
		if text == "" {
			return nil, ErrEmptyText
		}
	}
	if len(text) > s.opt.MaxBytes {
		return nil, fmt.Errorf("%w: en fazla %d bayt", ErrTooLarge, s.opt.MaxBytes)
	}
	if title == "" {
		title = defaultTitle(in.Filename, text)
	}

	sum := sha256.Sum256([]byte(text))
	hash := hex.EncodeToString(sum[:])
	if existing, err := s.repo.GetDocumentByHash(ctx, in.UserID, hash); err == nil {
		return nil, &DuplicateError{DocumentID: existing.ID}
	} else if !errors.Is(err, ErrNotFound) {
		return nil, err
	}

	pieces := Split(text, s.opt.Chunk)
	if len(pieces) == 0 {
		return nil, ErrEmptyText
	}
	if len(pieces) > s.opt.MaxChunks {
		return nil, fmt.Errorf("%w: %d parça (en fazla %d)", ErrTooManyChunks, len(pieces), s.opt.MaxChunks)
	}

	emb, _ := s.embedderSnapshot()
	now := s.now()
	doc := Document{
		ID: id.New("doc"), UserID: in.UserID, ProjectID: strings.TrimSpace(in.ProjectID),
		Title: title, Source: strings.TrimSpace(in.Source),
		Filename: filepath.Base(strings.TrimSpace(in.Filename)), ContentType: strings.TrimSpace(in.ContentType),
		Tags: tags, Hash: hash, SizeBytes: len(text),
		Status: StatusReady, ChunkCount: len(pieces),
		CreatedAt: now, UpdatedAt: now,
	}
	if doc.Filename == "." {
		doc.Filename = ""
	}
	if emb != nil {
		doc.Status = StatusIndexing
	}

	if err := s.repo.CreateDocument(ctx, &doc); err != nil {
		if errors.Is(err, ErrDuplicate) { // eşzamanlı aynı yükleme: benzersiz index kazananı belirledi
			if ex, gerr := s.repo.GetDocumentByHash(ctx, in.UserID, hash); gerr == nil {
				return nil, &DuplicateError{DocumentID: ex.ID}
			}
		}
		return nil, err
	}

	chunks := make([]Chunk, len(pieces))
	for i, p := range pieces {
		chunks[i] = Chunk{
			ID: id.New("chk"), DocumentID: doc.ID, UserID: doc.UserID, ProjectID: doc.ProjectID,
			Index: i, Text: p, Title: doc.Title, Source: doc.Source, Tags: cloneSlice(doc.Tags),
			CreatedAt: now,
		}
	}
	if err := s.repo.AddChunks(ctx, chunks); err != nil {
		s.cleanupFailedIngest(doc.ID)
		return nil, fmt.Errorf("knowledge: parçalar kaydedilemedi: %w", err)
	}
	// Kayıt sırasında belge silindiyse parçalar sahipsiz ama ARANABİLİR kalırdı: temizle.
	if _, err := s.repo.GetDocument(ctx, doc.ID); errors.Is(err, ErrNotFound) {
		_ = s.repo.DeleteChunks(context.WithoutCancel(ctx), doc.ID)
		return nil, ErrNotFound
	}

	s.publish(ctx, event.KnowledgeDocumentAdded, doc.UserID,
		map[string]any{"id": doc.ID, "title": doc.Title, "chunks": doc.ChunkCount})
	if emb != nil {
		id := doc.ID
		s.goJob(func(jctx context.Context) { s.runEmbedJob(jctx, []string{id}, false) })
	}
	out := doc.Clone()
	return &out, nil
}

// cleanupFailedIngest yarım kalan belgeyi (parça yazımı başarısız) siler; hata yalnızca loglanır.
func (s *Service) cleanupFailedIngest(docID string) {
	cctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := s.repo.DeleteDocument(cctx, docID); err != nil && !errors.Is(err, ErrNotFound) {
		s.log.Warn("yarım kalan belge temizlenemedi", "document", docID, "err", err)
	}
}

// validateIngest alanları doğrular ve normalize eder; başlığı ve etiketleri döndürür.
func (s *Service) validateIngest(in *IngestInput) (title string, tags []string, errs []FieldError) {
	if strings.TrimSpace(in.UserID) == "" {
		errs = append(errs, FieldError{Field: "userId", Message: "kullanıcı gerekli"})
	}
	title = strings.TrimSpace(in.Title)
	if utf8.RuneCountInString(title) > MaxTitleRunes {
		errs = append(errs, FieldError{Field: "title", Message: "başlık en fazla 200 karakter olabilir"})
	}
	if utf8.RuneCountInString(in.Source) > MaxSourceRunes {
		errs = append(errs, FieldError{Field: "source", Message: "kaynak en fazla 500 karakter olabilir"})
	}
	if utf8.RuneCountInString(in.ProjectID) > MaxProjectRunes {
		errs = append(errs, FieldError{Field: "projectId", Message: "projectId en fazla 100 karakter olabilir"})
	}
	var terrs []FieldError
	tags, terrs = normalizeTags(in.Tags)
	errs = append(errs, terrs...)
	return title, tags, errs
}

// defaultTitle başlık verilmediğinde dosya adından, o da yoksa metnin ilk satırından türetir.
func defaultTitle(filename, text string) string {
	if base := filepath.Base(strings.TrimSpace(filename)); base != "." && base != "" {
		if t := strings.TrimSpace(strings.TrimSuffix(base, filepath.Ext(base))); t != "" {
			return truncateRunes(t, MaxTitleRunes)
		}
	}
	for _, line := range strings.Split(text, "\n") {
		if line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#")); line != "" {
			return truncateRunes(line, 60)
		}
	}
	return "Başlıksız belge"
}

func truncateRunes(s string, n int) string {
	if utf8.RuneCountInString(s) <= n {
		return s
	}
	return string([]rune(s)[:n])
}

// Get belgeyi döndürür. Başkasının belgesi ErrNotFound'dır (varlığı bile sızdırılmaz).
func (s *Service) Get(ctx context.Context, userID, docID string) (*Document, error) {
	d, err := s.repo.GetDocument(ctx, docID)
	if err != nil {
		return nil, err
	}
	if d.UserID != userID {
		return nil, ErrNotFound
	}
	return d, nil
}

// List kullanıcının belgelerini döndürür (en yeni önce). f.UserID zorunludur.
func (s *Service) List(ctx context.Context, f DocumentFilter, limit, offset int) ([]Document, error) {
	f.Tag = strings.ToLower(strings.TrimSpace(f.Tag))
	return s.repo.ListDocuments(ctx, f, limit, offset)
}

// Chunks belgenin parçalarını döndürür (arayüzde "bu belge nasıl bölündü" için).
func (s *Service) Chunks(ctx context.Context, userID, docID string, limit, offset int) ([]Chunk, error) {
	if _, err := s.Get(ctx, userID, docID); err != nil {
		return nil, err
	}
	return s.repo.ChunksOfDocument(ctx, docID, limit, offset)
}

// Delete belgeyi ve parçalarını siler. Başkasının belgesi ErrNotFound'dır.
func (s *Service) Delete(ctx context.Context, userID, docID string) error {
	if _, err := s.Get(ctx, userID, docID); err != nil {
		return err
	}
	if err := s.repo.DeleteDocument(ctx, docID); err != nil {
		return err
	}
	s.publish(ctx, event.KnowledgeDocumentDeleted, userID, map[string]any{"id": docID})
	return nil
}

// HasDocuments kullanıcının en az bir belgesi var mı? Ajan bunu HER mesajda sorar: bilgi tabanı
// boşsa sorgu embedding'i hiç üretilmez (ses gecikmesine ek yük bindirmez).
func (s *Service) HasDocuments(ctx context.Context, userID string) bool {
	n, err := s.repo.CountDocuments(ctx, DocumentFilter{UserID: userID})
	return err == nil && n > 0
}

// ReindexReport Reindex sonucudur.
type ReindexReport struct {
	Documents int  `json:"documents"` // sıraya alınan belge sayısı
	Force     bool `json:"force"`
}

// Reindex kullanıcının belgelerinin embedding'ini ARKA PLANDA tamamlar/yeniler ve hemen döner.
// force=false: yalnızca eksik ya da bayat (başka modelle üretilmiş) vektörler. force=true: hepsi
// (embedding modeli değiştiğinde). İlerleme belgelerin embeddedCount alanından izlenir.
func (s *Service) Reindex(ctx context.Context, userID string, force bool) (ReindexReport, error) {
	rep := ReindexReport{Force: force}
	if emb, _ := s.embedderSnapshot(); emb == nil {
		return rep, ErrNoEmbedder
	}
	docs, err := s.repo.ListDocuments(ctx, DocumentFilter{UserID: userID}, 0, 0)
	if err != nil {
		return rep, err
	}
	ids := make([]string, 0, len(docs))
	for _, d := range docs {
		ids = append(ids, d.ID)
	}
	rep.Documents = len(ids)
	if len(ids) > 0 {
		s.goJob(func(jctx context.Context) { s.runEmbedJob(jctx, ids, force) })
	}
	return rep, nil
}

// Resume açılışta yarım kalmış ("indexing") belgelerin embedding işini sürdürür.
// Dönen sayı sıraya alınan belge sayısıdır.
func (s *Service) Resume(ctx context.Context) (int, error) {
	docs, err := s.repo.PendingDocuments(ctx)
	if err != nil {
		return 0, err
	}
	ids := make([]string, 0, len(docs))
	for _, d := range docs {
		ids = append(ids, d.ID)
	}
	if len(ids) > 0 {
		s.goJob(func(jctx context.Context) { s.runEmbedJob(jctx, ids, false) })
	}
	return len(ids), nil
}

// runEmbedJob belgeleri SIRAYLA işler; sem, farklı işlerin aynı anda çalışmasını engeller
// (Ollama + TTS + STT aynı GPU'yu paylaşıyor: eşzamanlı embedding diğerlerini kovar).
func (s *Service) runEmbedJob(ctx context.Context, docIDs []string, force bool) {
	select {
	case s.sem <- struct{}{}:
		defer func() { <-s.sem }()
	case <-ctx.Done():
		return
	}
	for _, id := range docIDs {
		if ctx.Err() != nil {
			return
		}
		s.embedDocument(ctx, id, force)
	}
}

// embedText parçanın vektöre çevrilen metnidir: başlık bağlam verir ("Fatura" belgesindeki
// "toplam 500" ile "Rapor"daki "toplam 500" ayrışır). Başlık kısaltılır: embedding
// modeli num_ctx=512 ile çalışır, parça + başlık o pencerede kalmalı.
func embedText(c Chunk) string {
	t := strings.TrimSpace(truncateRunes(c.Title, embedTitleRunes))
	if t == "" {
		return c.Text
	}
	return t + "\n\n" + c.Text
}

// embedDocument tek belgenin eksik/bayat parça vektörlerini üretir ve ilerlemeyi kaydeder.
//
//   - Belge silinirse (SetChunkEmbedding/UpdateDocument ErrNotFound) iş sessizce biter.
//   - İptal (kapanış) halinde belge "indexing" kalır → sonraki açılışta Resume sürdürür.
//   - Art arda embedFailLimit hata → bırakılır, belge "ready" (kısmi) olur; Reindex tamamlar.
func (s *Service) embedDocument(ctx context.Context, docID string, force bool) {
	emb, model := s.embedderSnapshot()
	chunks, err := s.repo.ChunksOfDocument(ctx, docID, 0, 0)
	if err != nil {
		if ctx.Err() == nil {
			s.log.Warn("belge parçaları okunamadı", "document", docID, "err", err)
		}
		return
	}

	// Embedding kapalıyken "indexing" kalmasın: belge anahtar kelimeyle aranır durumdadır.
	if emb == nil {
		s.finishDoc(ctx, docID, countEmbedded(chunks, ""))
		return
	}

	pending := make([]Chunk, 0, len(chunks))
	done := 0
	for _, c := range chunks {
		if !force && c.HasEmbedding(model) {
			done++
			continue
		}
		pending = append(pending, c)
	}
	if len(pending) == 0 {
		s.finishDoc(ctx, docID, done)
		return
	}
	if done == 0 || force {
		// İlerleme çubuğu baştan başlasın (force: eski vektörler sayılmaz).
		done = 0
		if err := s.setProgress(ctx, docID, 0, StatusIndexing); errors.Is(err, ErrNotFound) {
			return
		}
	}

	fails := 0
	for i, c := range pending {
		if ctx.Err() != nil {
			return
		}
		ectx, cancel := context.WithTimeout(ctx, s.opt.EmbedTimeout)
		vec, err := emb.Embed(ectx, embedText(c))
		cancel()
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			fails++
			s.log.Warn("parça embedding'i üretilemedi", "document", docID, "chunk", c.Index, "err", err)
			if fails >= embedFailLimit {
				s.log.Warn("art arda embedding hatası: belge kısmi bırakıldı (POST /api/knowledge/reindex ile tamamlanır)",
					"document", docID, "embedded", done, "total", len(chunks))
				break
			}
			continue
		}
		fails = 0
		if err := s.repo.SetChunkEmbedding(ctx, c.ID, vec, model); err != nil {
			if errors.Is(err, ErrNotFound) {
				return // belge silinmiş
			}
			if ctx.Err() != nil {
				return
			}
			s.log.Warn("embedding kaydedilemedi", "document", docID, "chunk", c.Index, "err", err)
			continue
		}
		done++
		if (i+1)%progressEvery == 0 {
			if err := s.setProgress(ctx, docID, done, StatusIndexing); errors.Is(err, ErrNotFound) {
				return
			}
		}
	}
	if ctx.Err() != nil {
		return
	}
	s.finishDoc(ctx, docID, done)
}

// countEmbedded model boşsa herhangi bir vektörü, doluysa o modelin vektörünü sayar.
func countEmbedded(chunks []Chunk, model string) int {
	n := 0
	for _, c := range chunks {
		if c.HasEmbedding(model) {
			n++
		}
	}
	return n
}

func (s *Service) finishDoc(ctx context.Context, docID string, embedded int) {
	if err := s.setProgress(ctx, docID, embedded, StatusReady); err != nil && !errors.Is(err, ErrNotFound) && ctx.Err() == nil {
		s.log.Warn("belge durumu güncellenemedi", "document", docID, "err", err)
	}
}

func (s *Service) setProgress(ctx context.Context, docID string, embedded int, status Status) error {
	d, err := s.repo.GetDocument(ctx, docID)
	if err != nil {
		return err
	}
	d.EmbeddedCount, d.Status, d.UpdatedAt = embedded, status, s.now()
	return s.repo.UpdateDocument(ctx, d)
}
