package api

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/fasthttp/websocket"
	"github.com/gofiber/fiber/v2"

	"fiko-ai/internal/agent"
	"fiko-ai/internal/api/ws"
	"fiko-ai/internal/conversation"
	"fiko-ai/internal/event"
	"fiko-ai/internal/llm"
	"fiko-ai/internal/prompt"
	"fiko-ai/internal/protocol"
)

// ---- sahte bağımlılıklar ----

// fakeProvider model listesi sabit bir hata/sonuç döndürür.
type fakeProvider struct {
	models []llm.ModelInfo
	err    error
}

var _ llm.Provider = (*fakeProvider)(nil)

func (f *fakeProvider) Name() string { return "fake" }
func (f *fakeProvider) Generate(context.Context, llm.Request) (llm.Result, error) {
	return llm.Result{}, nil
}

func (f *fakeProvider) GenerateStream(_ context.Context, _ llm.Request, onDelta llm.DeltaFunc) (llm.Result, error) {
	if onDelta != nil {
		_ = onDelta("ok")
	}
	return llm.Result{Content: "ok", Model: "fake"}, f.err
}

func (f *fakeProvider) ListModels(context.Context) ([]llm.ModelInfo, error) {
	return f.models, f.err
}

// testServer tam bir API sunucusu kurar (hub ve bus dahil).
func testServer(t *testing.T, p llm.Provider) (*Server, *event.MemoryBus) {
	t.Helper()
	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	repo := conversation.NewMemoryRepo()
	svc := conversation.NewService(repo)
	bus := event.NewMemoryBus(nil, 16)
	ag := agent.New(svc, p, prompt.Default(), bus, log, agent.Config{})
	hub := ws.NewHub(ag, log, ws.Options{PingInterval: time.Hour, PongWait: 2 * time.Hour, WriteWait: time.Second})
	s := New(context.Background(), Config{
		Env: "test", Version: "test", AllowedOrigins: []string{"http://localhost:4200"},
		DefaultUserID: "local", DefaultPageSize: 2, MaxPageSize: 5,
	}, Deps{Conversations: svc, LLM: p, Hub: hub, Bus: bus}, log)
	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_ = ag.Close(ctx)
		_ = hub.Close(ctx)
		bus.Close()
	})
	return s, bus
}

// do isteği uygulama üzerinde çalıştırır ve yanıtı okunmuş halde döndürür.
// NOT: Fiber'ın app.Test() altyapısı Ctx'i havuzdan ödünç alır ve gövde baytlarını
// ikinci çalıştırmada bozabilir. Bu yüzden gövde HEMEN kopyalanır (yoksa decode bozulur).
func do(t *testing.T, s *Server, method, path string, body any, headers map[string]string) (*http.Response, []byte) {
	t.Helper()
	var rdr io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			t.Fatalf("gövde serileştirilemedi: %v", err)
		}
		rdr = bytes.NewReader(raw)
	}
	req := httptest.NewRequest(method, path, rdr)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	resp, err := s.App().Test(req, 5000)
	if err != nil {
		t.Fatalf("%s %s: %v", method, path, err)
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("gövde okunamadı: %v", err)
	}
	raw = append([]byte(nil), raw...) // Ctx havuzundan bağımsız kopya
	return resp, raw
}

func decode[T any](t *testing.T, raw []byte) T {
	t.Helper()
	var v T
	if err := json.Unmarshal(raw, &v); err != nil {
		t.Fatalf("JSON çözümlenemedi: %v (gövde: %s)", err, raw)
	}
	return v
}

// ---- testler ----

func TestHealth(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	resp, raw := do(t, s, http.MethodGet, "/api/health", nil, nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("durum = %d, gövde = %s", resp.StatusCode, raw)
	}
	got := decode[healthResponse](t, raw)
	if got.Status != "ok" {
		t.Errorf("status = %q, beklenen ok", got.Status)
	}
	if got.WSPath != WSPath || got.LLMProvider != "fake" {
		t.Errorf("wsPath/llmProvider = %q/%q", got.WSPath, got.LLMProvider)
	}
}

func TestConversationCRUD(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})

	// Oluştur
	resp, raw := do(t, s, http.MethodPost, "/api/conversations", nil, nil)
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("create durum = %d, gövde = %s", resp.StatusCode, raw)
	}
	created := decode[conversationResponse](t, raw).Conversation
	if created.ID == "" {
		t.Fatal("konuşma kimliği boş")
	}
	if created.Title != conversation.DefaultTitle {
		t.Errorf("varsayılan başlık = %q", created.Title)
	}

	// Başlıklı oluştur
	_, raw = do(t, s, http.MethodPost, "/api/conversations", createConversationRequest{Title: "  Test sohbeti  "}, nil)
	if got := decode[conversationResponse](t, raw).Conversation.Title; got != "Test sohbeti" {
		t.Errorf("başlık = %q, beklenen 'Test sohbeti'", got)
	}

	// Oku
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+created.ID, nil, nil)
	if got := decode[conversationResponse](t, raw).Conversation.ID; got != created.ID {
		t.Errorf("get id = %q", got)
	}

	// Listele
	_, raw = do(t, s, http.MethodGet, "/api/conversations", nil, nil)
	list := decode[conversationsResponse](t, raw)
	if list.Count != 2 || list.Limit != 2 {
		t.Errorf("list count/limit = %d/%d, beklenen 2/2", list.Count, list.Limit)
	}

	// Sil
	resp, _ = do(t, s, http.MethodDelete, "/api/conversations/"+created.ID, nil, nil)
	if resp.StatusCode != http.StatusNoContent {
		t.Errorf("delete durum = %d, beklenen 204", resp.StatusCode)
	}
	// Silinen konuşma 404 vermeli
	resp, raw = do(t, s, http.MethodGet, "/api/conversations/"+created.ID, nil, nil)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("silinen konuşma durum = %d", resp.StatusCode)
	}
	if e := decode[errorBody](t, raw).Error; e.Code != "not_found" {
		t.Errorf("hata kodu = %q, beklenen not_found", e.Code)
	}
	// İkinci kez silme de 404
	if resp, _ = do(t, s, http.MethodDelete, "/api/conversations/"+created.ID, nil, nil); resp.StatusCode != http.StatusNotFound {
		t.Errorf("çift silme durum = %d, beklenen 404", resp.StatusCode)
	}
}

func TestConversationIsolation(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	_, raw := do(t, s, http.MethodPost, "/api/conversations", nil, map[string]string{UserHeader: "ali"})
	id := decode[conversationResponse](t, raw).Conversation.ID

	// Başka kullanıcı göremez ve silemez (404, varlık sızdırılmaz).
	other := map[string]string{UserHeader: "veli"}
	if resp, _ := do(t, s, http.MethodGet, "/api/conversations/"+id, nil, other); resp.StatusCode != http.StatusNotFound {
		t.Errorf("yabancı okuma durum = %d, beklenen 404", resp.StatusCode)
	}
	// Yabancının listesi boş (sahiplik sızıntısı olmamalı).
	_, raw = do(t, s, http.MethodGet, "/api/conversations", nil, other)
	if n := decode[conversationsResponse](t, raw).Count; n != 0 {
		t.Fatalf("yabancı liste = %d, beklenen 0", n)
	}
	// Yabancı silemez: 404 döner VE kayıt durmalıdır.
	if resp, _ := do(t, s, http.MethodDelete, "/api/conversations/"+id, nil, other); resp.StatusCode != http.StatusNotFound {
		t.Errorf("yabancı silme durum = %d, beklenen 404", resp.StatusCode)
	}
	// Orijinal sahibi hâlâ görebiliyor (yabancı silme kaydı bozmamalı).
	resp, body := do(t, s, http.MethodGet, "/api/conversations/"+id, nil, map[string]string{UserHeader: "ali"})
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("sahibi okuma durum = %d, gövde = %s", resp.StatusCode, body)
	}
}

func TestRenameConversation(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	ali := map[string]string{UserHeader: "ali"}
	_, raw := do(t, s, http.MethodPost, "/api/conversations", nil, ali)
	id := decode[conversationResponse](t, raw).Conversation.ID

	// Başarılı: boşluklar kırpılır, yanıt yeni başlığı taşır, kalıcıdır.
	resp, raw := do(t, s, http.MethodPut, "/api/conversations/"+id, renameConversationRequest{Title: "  Yeni ad  "}, ali)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("rename durum = %d, gövde = %s", resp.StatusCode, raw)
	}
	if got := decode[conversationResponse](t, raw).Conversation.Title; got != "Yeni ad" {
		t.Errorf("başlık = %q", got)
	}
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+id, nil, ali)
	if got := decode[conversationResponse](t, raw).Conversation.Title; got != "Yeni ad" {
		t.Errorf("kalıcı başlık = %q", got)
	}

	// Boş ve çok uzun başlık 400; başlık değişmez.
	for name, title := range map[string]string{"bos": "   ", "uzun": strings.Repeat("ş", maxRenameRunes+1)} {
		resp, raw = do(t, s, http.MethodPut, "/api/conversations/"+id, renameConversationRequest{Title: title}, ali)
		if resp.StatusCode != http.StatusBadRequest {
			t.Errorf("%s: durum = %d, beklenen 400 (%s)", name, resp.StatusCode, raw)
		}
	}
	// Bozuk gövde 400.
	if resp, _ = do(t, s, http.MethodPut, "/api/conversations/"+id, "{bozuk", ali); resp.StatusCode != http.StatusBadRequest {
		t.Errorf("bozuk gövde durum = %d, beklenen 400", resp.StatusCode)
	}

	// Yabancı kullanıcı 404 alır VE başlık değişmez.
	veli := map[string]string{UserHeader: "veli"}
	if resp, _ = do(t, s, http.MethodPut, "/api/conversations/"+id, renameConversationRequest{Title: "ele geçirildi"}, veli); resp.StatusCode != http.StatusNotFound {
		t.Errorf("yabancı rename durum = %d, beklenen 404", resp.StatusCode)
	}
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+id, nil, ali)
	if got := decode[conversationResponse](t, raw).Conversation.Title; got != "Yeni ad" {
		t.Errorf("yabancı rename başlığı değiştirdi: %q", got)
	}

	// Olmayan konuşma 404.
	if resp, _ = do(t, s, http.MethodPut, "/api/conversations/yok", renameConversationRequest{Title: "x"}, ali); resp.StatusCode != http.StatusNotFound {
		t.Errorf("olmayan konuşma durum = %d, beklenen 404", resp.StatusCode)
	}
}

func TestListPagingValidation(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	for i := 0; i < 4; i++ {
		do(t, s, http.MethodPost, "/api/conversations", nil, nil)
	}

	// limit üst sınırı MaxPageSize'a kırpılır.
	_, raw := do(t, s, http.MethodGet, "/api/conversations?limit=100", nil, nil)
	if got := decode[conversationsResponse](t, raw).Limit; got != 5 {
		t.Errorf("limit = %d, beklenen MaxPageSize 5", got)
	}

	// offset sayfalar.
	_, raw = do(t, s, http.MethodGet, "/api/conversations?limit=2&offset=2", nil, nil)
	page2 := decode[conversationsResponse](t, raw)
	if page2.Count != 2 || page2.Offset != 2 {
		t.Errorf("sayfa 2 = %d adet, offset %d", page2.Count, page2.Offset)
	}

	// Geçersiz değerler 400.
	for _, q := range []string{"limit=abc", "limit=-1", "offset=xyz", "offset=-5"} {
		resp, body := do(t, s, http.MethodGet, "/api/conversations?"+q, nil, nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Errorf("%s durum = %d, beklenen 400 (gövde %s)", q, resp.StatusCode, body)
		}
	}
}

func TestListMessages(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	_, raw := do(t, s, http.MethodPost, "/api/conversations", nil, nil)
	id := decode[conversationResponse](t, raw).Conversation.ID
	svc := s.deps.Conversations
	for _, text := range []string{"bir", "iki", "üç"} {
		if _, _, err := svc.AddUserMessage(context.Background(), "local", id, text); err != nil {
			t.Fatalf("mesaj eklenemedi: %v", err)
		}
	}

	// Varsayılan sayfa boyutu 2 → 2 mesaj + hasMore.
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+id+"/messages", nil, nil)
	page := decode[messagesResponse](t, raw)
	if page.Count != 2 || !page.HasMore {
		t.Errorf("mesaj sayfası = %d adet, hasMore=%v (beklenen 2/true)", page.Count, page.HasMore)
	}
	if page.Items[0].Content != "iki" {
		t.Errorf("ilk mesaj = %q, beklenen 'iki' (son N, eskiden yeniye)", page.Items[0].Content)
	}

	// limit=3 → hepsi, hasMore=false.
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+id+"/messages?limit=3", nil, nil)
	all := decode[messagesResponse](t, raw)
	if all.Count != 3 || all.HasMore {
		t.Errorf("tüm mesajlar = %d adet, hasMore=%v (beklenen 3/false)", all.Count, all.HasMore)
	}

	// Olmayan konuşma → 404.
	if resp, _ := do(t, s, http.MethodGet, "/api/conversations/conv_yok/messages", nil, nil); resp.StatusCode != http.StatusNotFound {
		t.Errorf("yok konuşma mesajları durum = %d, beklenen 404", resp.StatusCode)
	}
}

func TestListMessagesEmptyIsArray(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	_, raw := do(t, s, http.MethodPost, "/api/conversations", nil, nil)
	id := decode[conversationResponse](t, raw).Conversation.ID
	_, raw = do(t, s, http.MethodGet, "/api/conversations/"+id+"/messages", nil, nil)
	if !strings.Contains(string(raw), `"items":[]`) {
		t.Errorf("boş mesaj listesi [] olmalı, gövde = %s", raw)
	}
}

func TestListModels(t *testing.T) {
	modified := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
	p := &fakeProvider{models: []llm.ModelInfo{
		{Name: "qwen2.5:7b", SizeBytes: 4_000_000_000, ModifiedAt: modified},
		{Name: "gpt-oss:20b"},
	}}
	s, _ := testServer(t, p)
	_, raw := do(t, s, http.MethodGet, "/api/models", nil, nil)
	got := decode[modelsResponse](t, raw)
	if got.Provider != "fake" || len(got.Models) != 2 {
		t.Fatalf("models = %+v", got)
	}
	if got.Models[0].Name != "qwen2.5:7b" || got.Models[0].ModifiedAt == "" {
		t.Errorf("ilk model = %+v", got.Models[0])
	}
}

func TestListModelsProviderErrorIsBadGateway(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{err: &llm.ProviderError{Provider: "ollama", StatusCode: 500, Message: "patladı"}})
	resp, raw := do(t, s, http.MethodGet, "/api/models", nil, nil)
	if resp.StatusCode != http.StatusBadGateway {
		t.Fatalf("durum = %d, beklenen 502 (gövde %s)", resp.StatusCode, raw)
	}
	if e := decode[errorBody](t, raw).Error; e.Code != "llm_error" {
		t.Errorf("hata kodu = %q, beklenen llm_error", e.Code)
	}
}

func TestCORSHeaders(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	origin := "http://localhost:4200"
	resp, _ := do(t, s, http.MethodGet, "/api/health", nil, map[string]string{"Origin": origin})
	if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
		t.Errorf("Access-Control-Allow-Origin = %q, beklenen %q", got, origin)
	}
	// İzinli olmayan origin için header dönülmez.
	resp, _ = do(t, s, http.MethodGet, "/api/health", nil, map[string]string{"Origin": "http://kotu.example"})
	if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
		t.Errorf("izinli olmayan origin için ACAO = %q, boş olmalı", got)
	}
}

func TestUnknownRouteIsJSON404(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	resp, raw := do(t, s, http.MethodGet, "/api/olmayan", nil, nil)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("durum = %d, beklenen 404", resp.StatusCode)
	}
	if e := decode[errorBody](t, raw).Error; e.Code != "not_found" {
		t.Errorf("hata kodu = %q, beklenen not_found", e.Code)
	}
}

func TestMethodNotAllowed(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	resp, raw := do(t, s, http.MethodPut, "/api/health", nil, nil)
	if resp.StatusCode != http.StatusMethodNotAllowed {
		t.Fatalf("durum = %d, beklenen 405 (gövde %s)", resp.StatusCode, raw)
	}
	if e := decode[errorBody](t, raw).Error; e.Message == "" {
		t.Error("405 gövdesi mesaj içermeli")
	}
}

func TestPanicRecovery(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	s.App().Get("/api/patlat", func(*fiber.Ctx) error { panic("bilerek") })
	resp, raw := do(t, s, http.MethodGet, "/api/patlat", nil, nil)
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("durum = %d, beklenen 500 (gövde %s)", resp.StatusCode, raw)
	}
	if e := decode[errorBody](t, raw).Error; e.Code != "internal" {
		t.Errorf("hata kodu = %q, beklenen internal", e.Code)
	}
}

func TestWSRequiresUpgrade(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})
	// Upgrade başlıkları olmadan /ws → 426 (fiber.ErrUpgradeRequired)
	resp, _ := do(t, s, http.MethodGet, WSPath, nil, nil)
	if resp.StatusCode != fiber.StatusUpgradeRequired {
		t.Errorf("durum = %d, beklenen 426", resp.StatusCode)
	}
}

// Gerçek WS el sıkışması kurulabilmeli: tüm başlıklar doğruyken 101 beklenir.
// Bu test olmadan /ws uç noktası hiç denenmemiş olur (yalnızca 426 yolu ölçülürdü).
func TestWSUpgradeHandshake(t *testing.T) {
	s, _ := testServer(t, &fakeProvider{})

	// Fiber'ın kendi Test sunucusu yerine gerçek dinleyici kullanıyoruz:
	// WebSocket el sıkışması fasthttp'nin gerçek bağlantısında başlıklara bakar.
	addr := startTestServer(t, s)

	dialer := websocket.Dialer{HandshakeTimeout: 3 * time.Second}
	c, resp, err := dialer.Dial("ws://"+addr+WSPath, nil)
	if err != nil {
		status := 0
		if resp != nil {
			status = resp.StatusCode
		}
		t.Fatalf("WS el sıkışması başarısız: err=%v http=%d", err, status)
	}
	defer c.Close()

	// Sunucu ilk veriyi kendi PingInterval'ı sonrasında yollar; biz istemci ping'i gönderip
	// karşılığında pong bekliyoruz (uygulama düzeyi heartbeat).
	pong, err := json.Marshal(protocol.Inbound{Type: protocol.ClientPing, ID: "t1"})
	if err != nil {
		t.Fatalf("ping serileştirilemedi: %v", err)
	}
	if err := c.WriteMessage(websocket.TextMessage, pong); err != nil {
		t.Fatalf("ping yazılamadı: %v", err)
	}

	_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
	_, msg, err := c.ReadMessage()
	if err != nil {
		t.Fatalf("pong okunamadı: %v", err)
	}
	var ev protocol.Outbound
	if err := json.Unmarshal(msg, &ev); err != nil {
		t.Fatalf("olay çözümlenemedi: %v", err)
	}
	if ev.Type != protocol.ServerPong {
		t.Errorf("pong bekleniyordu, gelen %q", ev.Type)
	}
	if ev.ReplyTo != "t1" {
		t.Errorf("replyTo = %q, beklenen t1", ev.ReplyTo)
	}
	if s.deps.Hub.Count() != 1 {
		t.Errorf("oturum kaydedilmedi: %d", s.deps.Hub.Count())
	}
}

// startTestServer uygulamayı gerçek bir portta dinletir ve kapanmasını sağlar.
func startTestServer(t *testing.T, s *Server) string {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("dinleyici açılamadı: %v", err)
	}
	go func() { _ = s.App().Listener(ln) }()
	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer cancel()
		_ = s.Shutdown(ctx)
	})
	return ln.Addr().String()
}
