package phraser

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/kami/maven/internal/llm"
)

// The drain has to cover every holder of the base URL, not only the phrasing
// paths in this package. The LLM router, the replier, the mail extractor and the
// memory evaluator all reach llama-server through llm.Client, and a swap that
// does not count them kills the server mid-turn.

// blockingLLM — a completion endpoint that does not answer until the test says
// so. It stands in for a router call that is generating when the swap arrives.
func blockingLLM(t *testing.T, release <-chan struct{}) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-release
		w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
	}))
	t.Cleanup(srv.Close)
	return srv
}

func (p *LLMPhraser) inflightCount() int {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.inflight
}

func TestSwap_WaitsForARouterCallThatWentThroughLLMClient(t *testing.T) {
	fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
	p := newSwapPhraser(t, fl, "/m/old.gguf")

	release := make(chan struct{})
	c := llm.New(blockingLLM(t, release).URL, 5*time.Second)
	c.SetSwapGate(p)

	completed := make(chan error, 1)
	go func() {
		_, err := c.Complete(context.Background(), llm.Req{System: "s", User: "u"})
		completed <- err
	}()
	deadline := time.Now().Add(2 * time.Second)
	for p.inflightCount() == 0 {
		if time.Now().After(deadline) {
			t.Fatal("the llm.Client request never registered with the phraser gate")
		}
		time.Sleep(5 * time.Millisecond)
	}

	swapped := make(chan error, 1)
	go func() { _, e := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); swapped <- e }()

	select {
	case e := <-swapped:
		t.Fatalf("the swap finished while a router call was still generating (%v); the old server was killed under it", e)
	case <-time.After(200 * time.Millisecond):
	}

	close(release)
	if e := <-completed; e != nil {
		t.Fatalf("the in-flight call did not finish on the old model: %v", e)
	}
	if e := <-swapped; e != nil {
		t.Fatalf("Swap after the drain: %v", e)
	}
}

func TestSwap_RefusesARouterCallThatArrivesMidSwap(t *testing.T) {
	fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
	p := newSwapPhraser(t, fl, "/m/old.gguf")

	// An open server: the refusal has to come from the gate, not from a stall.
	open := make(chan struct{})
	close(open)
	c := llm.New(blockingLLM(t, open).URL, 5*time.Second)
	c.SetSwapGate(p)

	// Hold the door shut the way quiesce does.
	_, held, err := p.acquire()
	if err != nil {
		t.Fatal(err)
	}
	defer held()
	go p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"})

	deadline := time.Now().Add(2 * time.Second)
	for {
		_, err := c.Complete(context.Background(), llm.Req{User: "u"})
		if errors.Is(err, ErrSwapping) {
			return
		}
		if time.Now().After(deadline) {
			t.Fatalf("a router call during a swap was not refused (last error: %v)", err)
		}
		time.Sleep(10 * time.Millisecond)
	}
}

func TestSwap_TotalFailureIsNotReportedAsARollback(t *testing.T) {
	fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
	p := newSwapPhraser(t, fl, "/m/old.gguf")
	fl.mu.Lock()
	delete(fl.models, "/m/old.gguf")
	fl.mu.Unlock()

	res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
	if err == nil {
		t.Fatal("Swap returned nil when both the load and the rollback failed")
	}
	if res.RolledBack {
		t.Error("a total failure set RolledBack; the page then says she is still answering with the old model, and she is not answering at all")
	}
	if !res.NoBackend {
		t.Error("a total failure did not set NoBackend, so nothing distinguishes it from a rolled-back swap")
	}
	if path, _, _ := p.LiveModel(); path != "" {
		t.Errorf("LiveModel = %q after a total failure; nothing is loaded, and naming a gguf makes the page read as half-working", path)
	}
}
