package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/webauthn"
)

// fakeModelCore is a core that supports the two model methods. It records what
// the page asked for, so the tests can assert the gate rather than the HTML.
type fakeModelCore struct {
	ipc.UnimplementedCoreAPI

	status    ipc.ModelStatusResp
	statusErr error

	swapResp ipc.SwapModelResp
	swapErr  error
	swapped  []ipc.SwapModelReq
}

func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
	return f.status, f.statusErr
}

func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
	f.swapped = append(f.swapped, req)
	return f.swapResp, f.swapErr
}

func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
	t.Helper()
	w := httptest.NewRecorder()
	handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
	return w
}

func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
	t.Helper()
	r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	w := httptest.NewRecorder()
	handleModels(w, r, core, nil, session, requireStepUp)
	return w
}

func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
	core := &fakeModelCore{status: ipc.ModelStatusResp{
		Model:     "Qwen3-1.7B-UD-Q4_K_XL",
		ModelPath: "/opt/maven/models/llm/qwen3.gguf",
		BaseURL:   "http://127.0.0.1:18099",
		NCtx:      4096,
		Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
	}}
	w := modelsGET(t, core)
	if w.Code != http.StatusOK {
		t.Fatalf("GET /models = %d; want 200", w.Code)
	}
	body := w.Body.String()
	for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
		if !strings.Contains(body, want) {
			t.Errorf("page does not mention %q", want)
		}
	}
	if len(core.swapped) != 0 {
		t.Errorf("a GET swapped the model: %v", core.swapped)
	}
}

func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
	// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
	// like POST /tools. Nothing reaches core.
	core := &fakeModelCore{}
	w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
	if w.Code != http.StatusForbidden {
		t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
	}
	if len(core.swapped) != 0 {
		t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
	}
}

func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
	core := &fakeModelCore{
		swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
		status:   ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
	}
	w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
	if w.Code != http.StatusOK {
		t.Fatalf("POST /models = %d; want 200", w.Code)
	}
	if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
		t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
	}
	if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
		t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
	}
}

func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
	core := &fakeModelCore{
		swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
		swapErr:  errBrokenModel{},
		status:   ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
	}
	w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
	if w.Code != http.StatusOK {
		t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
	}
	body := w.Body.String()
	if !strings.Contains(body, "rolled back to qwen3") {
		t.Errorf("page does not say it rolled back:\n%s", body)
	}
}

func TestModels_RefusedPathIs403(t *testing.T) {
	core := &fakeModelCore{swapErr: ipc.ErrForbidden}
	w := modelsPOST(t, core, nil, false, "/etc/passwd")
	if w.Code != http.StatusForbidden {
		t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
	}
}

func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
	core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
	w := modelsGET(t, core)
	if w.Code != http.StatusOK {
		t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
	}
	if !strings.Contains(w.Body.String(), "swap not configured") {
		t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
	}
}

func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
	// An in-process CoreAPI (no swap methods) must not 500 the page.
	w := modelsGET(t, ipc.UnimplementedCoreAPI{})
	if w.Code != http.StatusServiceUnavailable {
		t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
	}
}

type errBrokenModel struct{}

func (errBrokenModel) Error() string { return "llm: server did not start" }

func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
	// The load failed and so did the rollback: nothing is loaded. The page used
	// to branch on RolledBack first and render "rolled back to  — she is still
	// answering, with the old model" over an empty model name.
	core := &fakeModelCore{
		swapResp: ipc.SwapModelResp{NoBackend: true},
		swapErr:  errBrokenModel{},
		status:   ipc.ModelStatusResp{Model: "unknown"},
	}
	w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
	if w.Code != http.StatusOK {
		t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
	}
	body := w.Body.String()
	if strings.Contains(body, "still answering") {
		t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
	}
	if !strings.Contains(body, "no model is loaded") {
		t.Errorf("the page does not name the state the operator is in:\n%s", body)
	}
}

func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
	// n_ctx was read off a form that renders no such input, so only a
	// hand-crafted POST could set it. The resident model is a Thinking variant
	// whose window is sized for reasoning tokens; shrinking it from the wire is
	// not a capability this page offers.
	core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
	r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	w := httptest.NewRecorder()
	handleModels(w, r, core, nil, nil, false)
	if len(core.swapped) != 1 {
		t.Fatalf("SwapModel calls = %v; want one", core.swapped)
	}
	if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
		t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
	}
}

func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
	// A swap is a multi-minute IPC call and ipc.Client serialises everything on
	// one mutex, so it must not run on the connection every other page shares.
	shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
	swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
	r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
	if len(shared.swapped) != 0 {
		t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
	}
	if len(swapConn.swapped) != 1 {
		t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
	}
}
