package agent

import (
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/kubestellar/hive/v2/pkg/config"
)

// TestAgentCopilotConfigPaths_PerUIDFirst locks down the ORDERING contract of
// agentCopilotConfigPaths: the per-UID files must be probed before the shared
// path, because the per-UID file is the one the agent's own CLI writes. If the
// shared path came first, a stale shared credential would mask a fresh per-agent
// login and the dashboard would report the wrong auth state.
func TestAgentCopilotConfigPaths_PerUIDFirst(t *testing.T) {
	emptySharedPaths(t)

	// A gateway backend routes AgentHome to the per-agent inference home, which
	// is the case where per-UID paths actually differ from the shared one.
	paths := agentCopilotConfigPaths("ci-maintainer", 2007, "watsonx")
	if len(paths) < 2 {
		t.Fatalf("want per-UID paths plus the shared path, got %v", paths)
	}
	home := AgentHome("ci-maintainer", 2007, "watsonx")
	if home == "" {
		t.Fatal("AgentHome returned empty for a per-UID gateway agent")
	}
	want := []string{
		filepath.Join(home, ".copilot", "config.json"),
		filepath.Join(home, ".config", "github-copilot", "apps.json"),
		filepath.Join(home, ".config", "github-copilot", "hosts.json"),
	}
	for i, w := range want {
		if paths[i] != w {
			t.Errorf("paths[%d] = %q, want %q", i, paths[i], w)
		}
	}
	// The shared path must be present, and must come LAST.
	if got := paths[len(paths)-1]; got != sharedCopilotConfigPath {
		t.Errorf("last path = %q, want the shared path %q", got, sharedCopilotConfigPath)
	}
}

// TestAgentCopilotConfigPaths_NoDuplicateSharedPath: when AgentHome resolves to
// the same place the shared credential lives, the shared path must not be
// appended twice. containsPath exists precisely to prevent that.
func TestAgentCopilotConfigPaths_NoDuplicateSharedPath(t *testing.T) {
	emptySharedPaths(t)
	for _, backend := range []string{"claude", "copilot", "watsonx", ""} {
		paths := agentCopilotConfigPaths("ci-maintainer", 2007, backend)
		seen := map[string]int{}
		for _, p := range paths {
			seen[p]++
		}
		for p, n := range seen {
			if n > 1 {
				t.Errorf("backend %q: path %q appears %d times, want once", backend, p, n)
			}
		}
	}
}

// TestAgentAuthAvailable_UnknownAgent: the dashboard registers
// AgentAuthAvailable as a per-agent provider and calls it by name alone. An
// agent the manager has never heard of must return known=false ("no opinion")
// so the UI renders NO badge, rather than a false "not authenticated" badge.
func TestAgentAuthAvailable_UnknownAgent(t *testing.T) {
	m := &Manager{agents: map[string]*AgentProcess{}}
	avail, known := m.AgentAuthAvailable("no-such-agent")
	if known {
		t.Errorf("known = true for an unknown agent, want false (no badge)")
	}
	if avail {
		t.Errorf("available = true for an unknown agent, want false")
	}
}

// TestAgentAuthAvailable_UsesBackendOverride is the substantive behaviour of
// AgentAuthAvailable: it resolves the agent's EFFECTIVE backend itself. An agent
// configured for an interactive CLI but overridden onto watsonx authenticates by
// IAM, so it must read as authenticated — if the override were ignored, a
// switched agent would show a spurious login badge forever.
func TestAgentAuthAvailable_UsesBackendOverride(t *testing.T) {
	emptySharedPaths(t)
	proc := &AgentProcess{
		Name:            "ci-maintainer",
		UID:             2007,
		State:           StateRunning,
		BackendOverride: "watsonx",
	}
	proc.Config.Backend = "claude"
	proc.NeedsLogin = true // pane looks like a login prompt; must be ignored

	m := &Manager{agents: map[string]*AgentProcess{"ci-maintainer": proc}}

	avail, known := m.AgentAuthAvailable("ci-maintainer")
	if !known || !avail {
		t.Fatalf("watsonx-overridden agent: got (avail=%v, known=%v), want authenticated+known", avail, known)
	}

	// Sanity: the same result must come from the lower-level call with the
	// override applied, proving AgentAuthAvailable resolved the override and
	// not the configured backend.
	wantAvail, wantKnown := m.AgentAuthState("ci-maintainer", proc.UID, "watsonx", true, true)
	if avail != wantAvail || known != wantKnown {
		t.Errorf("AgentAuthAvailable = (%v,%v), AgentAuthState(watsonx) = (%v,%v); override was not applied",
			avail, known, wantAvail, wantKnown)
	}
}

// TestCheckBlockedThrash_TripsOnlyAtThreshold exercises the breaker end to end
// through checkBlockedThrash (the marker-matching + state-bookkeeping wrapper),
// not just the pure sliding-window helper. Below the threshold the agent must
// NOT be paused; the breaker only fires once the loop is established.
func TestCheckBlockedThrash_TripsOnlyAtThreshold(t *testing.T) {
	m := &Manager{
		agents: map[string]*AgentProcess{},
		logger: discardLogger(),
	}

	// A line containing no blocked-action marker must be ignored entirely — it
	// must not even allocate thrash state for the agent.
	m.checkBlockedThrash("ci-maintainer", "everything is fine, pushing now")
	if len(m.thrash) != 0 {
		t.Errorf("a non-blocked line recorded thrash state: %v", m.thrash)
	}

	// Feed threshold-1 real blocked lines: recorded, but not yet tripped.
	marker := blockedActionMarkers[0]
	for i := 0; i < thrashThreshold-1; i++ {
		m.checkBlockedThrash("ci-maintainer", marker+" refusing to push to main")
	}
	st := m.thrash["ci-maintainer"]
	if st == nil {
		t.Fatal("blocked lines did not record thrash state")
	}
	if len(st.times) != thrashThreshold-1 {
		t.Errorf("recorded %d blocked lines, want %d", len(st.times), thrashThreshold-1)
	}
	if !st.lastTrip.IsZero() {
		t.Error("breaker tripped before reaching the threshold")
	}
}

// TestCheckBlockedThrash_MatchesEveryMarker: every marker in
// blockedActionMarkers must actually be recognised. A marker that silently
// stopped matching would disable the breaker for that whole class of block.
func TestCheckBlockedThrash_MatchesEveryMarker(t *testing.T) {
	for _, marker := range blockedActionMarkers {
		m := &Manager{agents: map[string]*AgentProcess{}, logger: discardLogger()}
		m.checkBlockedThrash("a", "prefix text "+marker+" suffix text")
		if st := m.thrash["a"]; st == nil || len(st.times) != 1 {
			t.Errorf("marker %q was not recognised as a blocked-action line", marker)
		}
	}
}

// TestRecordBlockedAndCheck_WindowAndCooldown pins the pure decision function:
// stale entries fall out of the sliding window, and after a trip the cooldown
// suppresses a second trip so one bad loop does not pause an agent repeatedly.
func TestRecordBlockedAndCheck_WindowAndCooldown(t *testing.T) {
	now := time.Now()

	// Entries older than the window must not count toward the threshold.
	st := &thrashState{}
	for i := 0; i < thrashThreshold-1; i++ {
		st.times = append(st.times, now.Add(-2*thrashWindow))
	}
	if recordBlockedAndCheck(st, now, thrashWindow, thrashThreshold, thrashCooldown) {
		t.Error("tripped on stale entries that should have aged out of the window")
	}
	if len(st.times) != 1 {
		t.Errorf("stale entries were not pruned: %d remain, want 1", len(st.times))
	}

	// Threshold reached inside the window: must trip.
	st = &thrashState{}
	var tripped bool
	for i := 0; i < thrashThreshold; i++ {
		tripped = recordBlockedAndCheck(st, now, thrashWindow, thrashThreshold, thrashCooldown)
	}
	if !tripped {
		t.Fatalf("did not trip after %d blocked lines inside the window", thrashThreshold)
	}

	// Immediately after a trip, the cooldown must suppress another trip.
	if recordBlockedAndCheck(st, now, thrashWindow, thrashThreshold, thrashCooldown) {
		t.Error("tripped again inside the cooldown")
	}
	// Past the cooldown, it may trip again.
	later := now.Add(thrashCooldown + time.Second)
	st.times = nil
	for i := 0; i < thrashThreshold-1; i++ {
		st.times = append(st.times, later)
	}
	if !recordBlockedAndCheck(st, later, thrashWindow, thrashThreshold, thrashCooldown) {
		t.Error("did not trip after the cooldown expired")
	}
}

// TestBackendBinary_GatewayBackendsAllLaunchClaude pins the derivation added by
// this PR: every model-gateway backend launches the SAME claude CLI, because the
// backend name selects the upstream route (via ANTHROPIC_BASE_URL), not the
// binary. This is what makes adding a backend to config.InferenceBackends
// sufficient — no second edit in the launcher.
func TestBackendBinary_GatewayBackendsAllLaunchClaude(t *testing.T) {
	// backendBinary may return a resolved absolute path (CI installs stub
	// binaries on PATH), so compare the basename — the identity of the CLI is
	// what matters here, not where it was found.
	for _, b := range config.InferenceBackends {
		got, err := backendBinary(b)
		if err != nil {
			t.Errorf("backendBinary(%q) err = %v, want nil", b, err)
			continue
		}
		if filepath.Base(got) != "claude" {
			t.Errorf("backendBinary(%q) = %q, want the claude CLI (gateways share one binary)", b, got)
		}
	}
	// The agentic CLIs must still map to their own binaries.
	for backend, want := range map[string]string{
		"claude": "claude", "copilot": "copilot", "gemini": "gemini",
		"goose": "goose", "pi": "goose", "bob": "bob",
	} {
		got, err := backendBinary(backend)
		if err != nil || filepath.Base(got) != want {
			t.Errorf("backendBinary(%q) = (%q, %v), want basename %q with nil error", backend, got, err, want)
		}
	}
}

// TestSetBackendOverride_RejectsUndispatchableBackend covers the door this PR
// closed: /switch/{backend} used to accept ANY string and set it, so the agent
// was restarted into a backend the launcher could not start and died with
// "unknown backend" — with no signal at the moment of the change. The override
// must be rejected AND left unmodified.
func TestSetBackendOverride_RejectsUndispatchableBackend(t *testing.T) {
	proc := &AgentProcess{Name: "ci-maintainer", BackendOverride: "claude"}
	m := &Manager{
		agents: map[string]*AgentProcess{"ci-maintainer": proc},
		logger: discardLogger(),
	}

	err := m.SetBackendOverride("ci-maintainer", "totally-made-up")
	if err == nil {
		t.Fatal("SetBackendOverride accepted a backend the launcher cannot dispatch")
	}
	if !strings.Contains(err.Error(), "totally-made-up") {
		t.Errorf("error %q should name the rejected backend", err.Error())
	}
	if proc.BackendOverride != "claude" {
		t.Errorf("a rejected override mutated state: BackendOverride = %q, want %q unchanged",
			proc.BackendOverride, "claude")
	}

	// watsonx — the backend this PR registered — must be ACCEPTED and applied.
	if err := m.SetBackendOverride("ci-maintainer", "watsonx"); err != nil {
		t.Fatalf("SetBackendOverride(watsonx) = %v, want nil", err)
	}
	if proc.BackendOverride != "watsonx" {
		t.Errorf("BackendOverride = %q, want \"watsonx\"", proc.BackendOverride)
	}

	// A missing agent must still be reported as not found, not as a bad backend.
	if err := m.SetBackendOverride("no-such-agent", "claude"); err == nil ||
		!strings.Contains(err.Error(), "not found") {
		t.Errorf("SetBackendOverride on a missing agent = %v, want a 'not found' error", err)
	}
}
