package eval

import (
	"context"
	"os"
	"path/filepath"
	"testing"

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

func TestLoadReachFixture(t *testing.T) {
	f, err := LoadReach()
	if err != nil {
		t.Fatalf("LoadReach: %v", err)
	}
	if _, err := f.Now(); err != nil {
		t.Fatalf("Now: %v", err)
	}
	valid := map[Service]bool{ServiceNone: true, ServicePraxis: true, ServiceHexis: true}
	seen := map[string]bool{}
	byService := map[Service]int{}
	for _, c := range f.Cases {
		if c.ID == "" || seen[c.ID] {
			t.Errorf("case %q: empty or duplicate id", c.ID)
		}
		seen[c.ID] = true
		if !valid[c.WantService] {
			t.Errorf("%s: want_service %q, want none|praxis|hexis", c.ID, c.WantService)
		}
		if c.Lang != "ru" && c.Lang != "en" {
			t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang)
		}
		// A capability only means something on the Praxis side, and it must
		// name an arm that exists — otherwise the case asserts a target the
		// dispatch can never hit.
		if c.WantCapability != "" {
			if c.WantService != ServicePraxis {
				t.Errorf("%s: want_capability on %q", c.ID, c.WantService)
			}
			if PraxisAliases[c.WantCapability] != c.WantCapability {
				t.Errorf("%s: capability %q is not a praxis arm", c.ID, c.WantCapability)
			}
		}
		byService[c.WantService]++
	}
	// Coverage floor. The negative cases matter most: without them a router
	// that sent everything to Hexis would score perfectly.
	for s := range valid {
		if byService[s] < 5 {
			t.Errorf("want_service %q has %d cases, want >= 5", s, byService[s])
		}
	}
}

// TestReachFixtureIsHeldOut — same rule as TestFixtureIsHeldOut. The classifier
// routes by similarity to frozen seeds, so a case copied from models/seeds
// would measure memorisation rather than reach.
func TestReachFixtureIsHeldOut(t *testing.T) {
	f, err := LoadReach()
	if err != nil {
		t.Fatalf("LoadReach: %v", err)
	}
	seeds := loadSeeds(t)
	for _, c := range f.Cases {
		if src, ok := seeds[normalize(c.Utterance)]; ok {
			t.Errorf("%s: %q is verbatim in %s — not held out", c.ID, c.Utterance, src)
		}
	}
}

// TestReachDerivation pins the gate order that ScoreReach depends on. These are
// the four branches of actionAct plus the clarify pre-empt, asserted directly
// so a change to cmd/mavend/actions_act.go that this package no longer mirrors
// fails here rather than silently moving the score.
func TestReachDerivation(t *testing.T) {
	m := router.DefaultActMatcher{Fns: actFns}
	cases := []struct {
		name       string
		dec        router.Decision
		want       Service
		capability string
	}{
		{
			name: "act with a praxis alias in the fn slot",
			dec:  router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "list_attention", HasFn: true}},
			want: ServicePraxis, capability: "list_attention",
		},
		{
			name: "act with a russian praxis alias",
			dec:  router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "готово", HasFn: true}},
			want: ServicePraxis, capability: "resolve_item",
		},
		{
			name: "act with an entity but no fn earns one from the matcher",
			dec:  router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: "выключи свет в спальне"}},
			want: ServiceHexis,
		},
		{
			name: "act with a non-praxis fn and text goes to hexis",
			dec:  router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти гитею"}},
			want: ServiceHexis,
		},
		{
			name: "act with a fn and no text stays local",
			dec:  router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true}},
			want: ServiceNone,
		},
		{
			name: "a clarified act with text still reaches hexis",
			dec:  router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}},
			want: ServiceHexis,
		},
		{
			name: "a clarified act that already has a fn does not",
			dec:  router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти"}},
			want: ServiceNone,
		},
		{
			name: "no intent but act reaches nothing",
			dec:  router.Decision{Intent: router.IntentQuery, Slots: router.Slots{Fn: "list_attention", HasFn: true, Text: "что требует внимания"}},
			want: ServiceNone,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, capability := Reach(tc.dec, m)
			if got != tc.want {
				t.Errorf("Reach = %q, want %q", got, tc.want)
			}
			if capability != tc.capability {
				t.Errorf("capability = %q, want %q", capability, tc.capability)
			}
		})
	}
}

// TestPraxisAliasesShape — the copy of praxisCapabilities in reach.go is a
// drift risk (see its comment). This does not close it, but it does catch the
// cheap half: an arm losing all its aliases, or an alias pointing at an arm
// that no longer has a canonical name.
func TestPraxisAliasesShape(t *testing.T) {
	arms := map[string]int{}
	for alias, capability := range PraxisAliases {
		if alias == "" || capability == "" {
			t.Errorf("empty alias or capability: %q → %q", alias, capability)
		}
		arms[capability]++
	}
	for _, want := range []string{
		"list_attention", "acknowledge_item", "resolve_item",
		"ignore_item", "pin_item", "list_changes", "entity_attention",
	} {
		if arms[want] == 0 {
			t.Errorf("praxis arm %q has no aliases", want)
		}
		// Every arm must be reachable by its own name, which is the alias the
		// LLM router's fn slot actually emits.
		if PraxisAliases[want] != want {
			t.Errorf("arm %q does not alias to itself", want)
		}
	}
}

// TestReachBaselineHash — the deterministic ratchet. Same deal as
// TestClassifierBaseline: the hash embedder never clears the confidence gate on
// paraphrases, so almost everything lands on clarify, and the number this
// asserts is the overreach count rather than the accuracy.
//
// Overreach is the direction worth a hard assertion. An utterance that should
// stay inside Maven and instead resolves an entity through Nexus is one Hexis
// capability away from executing something nobody asked for, and unlike a miss
// he never gets asked about it.
func TestReachBaselineHash(t *testing.T) {
	f, err := LoadReach()
	if err != nil {
		t.Fatalf("LoadReach: %v", err)
	}
	m := router.DefaultActMatcher{Fns: actFns}
	rep, err := ScoreReach(context.Background(), "reach: classifier+hash",
		newBaselineRouter(t, router.NewHashEmbedder(1024), nil), m, f)
	if err != nil {
		t.Fatalf("ScoreReach: %v", err)
	}
	t.Log("\n" + rep.String() + rep.Failures())

	if rep.Overreach > 4 {
		t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
			rep.Overreach, rep.Failures())
	}
}

// TestReachBaselineONNX — the deployed configuration: the cascade with the
// multilingual e5 embedder. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline,
// because deps/ is gitignored. `make eval-reach` points it at the vendored copy.
//
// Reports rather than asserts. This is the number Vikunja #405 asked for, and a
// threshold invented alongside the first measurement is not a ratchet, it is a
// guess written down twice.
func TestReachBaselineONNX(t *testing.T) {
	lib := os.Getenv("MAVEN_ONNX_LIB")
	if lib == "" {
		t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
	}
	model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx")
	tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json")
	for _, p := range []string{lib, model, tok} {
		if _, err := os.Stat(p); err != nil {
			t.Skipf("missing %s: %v", p, err)
		}
	}
	emb, err := router.NewONNXEmbedder(model, tok, lib)
	if err != nil {
		t.Skipf("onnx embedder unavailable: %v", err)
	}
	defer emb.Close()

	f, err := LoadReach()
	if err != nil {
		t.Fatalf("LoadReach: %v", err)
	}
	rep, err := ScoreReach(context.Background(), "reach: classifier+onnx",
		newBaselineRouter(t, emb, nil), router.DefaultActMatcher{Fns: actFns}, f)
	if err != nil {
		t.Fatalf("ScoreReach: %v", err)
	}
	t.Log("\n" + rep.String() + rep.Failures())
}
