package engine

import (
	"testing"
	"time"

	"github.com/pulse-lets-go/internal/config"
	"github.com/pulse-lets-go/internal/models"
)

func defaultCfg() *config.Config {
	return &config.Config{
		StaleThresholdSec: 20,
		Scoring: config.ScoringConfig{
			IdleCPUMin:            5.0,
			CallFailureRateLethal: 15.0,
			Weights: config.ScoringWeights{
				CallScore: 0.40,
				LoadScore: 0.30,
				IdleScore: 0.20,
				FailScore: 0.10,
			},
		},
	}
}

func freshMetric(nodeID string) *models.NodeMetric {
	return &models.NodeMetric{
		NodeID:          nodeID,
		TS:              time.Now().Unix(),
		Status:          "ok",
		ActiveCalls:     42,
		MaxCalls:        250,
		IdleCPU:         35,
		LoadAvg:         0.85,
		CallFailureRate: 0.5,
	}
}

func TestNewEngine(t *testing.T) {
	eng := NewEngine(defaultCfg())
	if eng == nil {
		t.Fatal("ожидался engine, получен nil")
	}
}

func TestUpdateMetric_CreatesNode(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	ns := eng.UpdateMetric(m)

	if ns.NodeID != "pbx-01" {
		t.Errorf("ожидался node_id=pbx-01, получен %s", ns.NodeID)
	}
	if len(eng.nodes) != 1 {
		t.Errorf("ожидалась 1 нода, получено %d", len(eng.nodes))
	}
}

func TestUpdateMetric_PreservesDisabled(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	eng.UpdateMetric(m)

	// Отключаем ноду вручную
	eng.ToggleNode("pbx-01", true, "test")
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if !ns.Disabled {
		t.Error("ожидалось disabled=true")
	}
	if ns.DisabledReason != "test" {
		t.Errorf("ожидалась причина 'test', получена '%s'", ns.DisabledReason)
	}

	// Включаем обратно
	eng.ToggleNode("pbx-01", false, "")
	eng.UpdateMetric(m)

	ns, _ = eng.GetNodeInfo("pbx-01")
	if ns.Disabled {
		t.Error("ожидалось disabled=false")
	}
}

func TestScore_LethalDisabled(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	eng.UpdateMetric(m)
	eng.ToggleNode("pbx-01", true, "maintenance")

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("disabled нода должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_LethalStatusNotOk(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.Status = "down"
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("status==down нода должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_LethalStale(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	// Устанавливаем ts на 30 секунд назад (stale_threshold=20)
	m.TS = time.Now().Unix() - 30
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("stale нода должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_LethalMaxCalls(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.ActiveCalls = 250
	m.MaxCalls = 250
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("нода с исчерпанной ёмкостью должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_LethalIdleCPU(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.IdleCPU = 2.0
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("нода с idle_cpu < 5 должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_LethalFailureRate(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.CallFailureRate = 20.0
	eng.UpdateMetric(m)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("нода с call_failure_rate > 15 должна иметь score=-100, получен %.0f", ns.Score)
	}
}

func TestScore_Healthy(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	ns := eng.UpdateMetric(m)

	if ns.Score <= 0 {
		t.Errorf("здоровая нода должна иметь положительный score, получен %.0f", ns.Score)
	}
}

func TestScore_HealthyFormula(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.ActiveCalls = 0
	m.MaxCalls = 100
	m.IdleCPU = 100
	m.LoadAvg = 0
	m.CallFailureRate = 0

	ns := eng.UpdateMetric(m)

	// Все компоненты = 100
	// score = 100*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 100
	if ns.Score != 100.0 {
		t.Errorf("идеальная нода должна иметь score=100, получен %.1f", ns.Score)
	}
}

func TestScore_WeightedFormula(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	m.ActiveCalls = 50
	m.MaxCalls = 100
	m.IdleCPU = 50
	m.LoadAvg = 1.0
	m.CallFailureRate = 5.0

	ns := eng.UpdateMetric(m)

	// call_score  = 100 - (50/100 * 100) = 50
	// load_score  = 100 - (1.0 * 50) = 50
	// idle_score  = 50
	// fail_score  = 100 - 5 = 95
	// score = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 20+15+10+9.5 = 54.5
	expected := 54.5

	if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
		t.Errorf("ожидался score=%.1f, получен %.1f", expected, ns.Score)
	}
}

func TestGetBestNode_NoNodes(t *testing.T) {
	eng := NewEngine(defaultCfg())
	_, _, fallback := eng.GetBestNode()
	if !fallback {
		t.Error("без нод должен быть fallback")
	}
}

func TestGetBestNode_OneNode(t *testing.T) {
	eng := NewEngine(defaultCfg())
	eng.UpdateMetric(freshMetric("pbx-01"))

	id, score, fallback := eng.GetBestNode()
	if fallback {
		t.Error("не должно быть fallback с одной здоровой нодой")
	}
	if id != "pbx-01" {
		t.Errorf("ожидалась pbx-01, получена %s", id)
	}
	if score <= 0 {
		t.Errorf("ожидался положительный score, получен %.0f", score)
	}
}

func TestGetBestNode_AllUnhealthy(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m1 := freshMetric("pbx-01")
	m1.Status = "down"
	eng.UpdateMetric(m1)

	m2 := freshMetric("pbx-02")
	m2.ActiveCalls = 500
	m2.MaxCalls = 500
	eng.UpdateMetric(m2)

	_, _, fallback := eng.GetBestNode()
	if !fallback {
		t.Error("все ноды unhealthy — должен быть fallback")
	}
}

func TestGetBestNode_MultipleNodes(t *testing.T) {
	eng := NewEngine(defaultCfg())

	// pbx-01: 80% загружена (меньше score)
	m1 := freshMetric("pbx-01")
	m1.ActiveCalls = 200
	m1.MaxCalls = 250
	eng.UpdateMetric(m1)

	// pbx-02: 20% загружена (выше score)
	m2 := freshMetric("pbx-02")
	m2.ActiveCalls = 20
	m2.MaxCalls = 250
	eng.UpdateMetric(m2)

	id, _, _ := eng.GetBestNode()
	if id != "pbx-02" {
		t.Errorf("ожидалась pbx-02 (меньше загрузки), получена %s", id)
	}
}

func TestGetNodeHistory(t *testing.T) {
	eng := NewEngine(defaultCfg())
	eng.UpdateMetric(freshMetric("pbx-01"))

	// Повторяем обновление для накопления истории
	for i := 0; i < 5; i++ {
		m := freshMetric("pbx-01")
		m.ActiveCalls = i * 10
		eng.UpdateMetric(m)
	}

	history := eng.GetNodeHistory("pbx-01")
	if len(history) < 3 {
		t.Errorf("ожидалось минимум 3 снапшота, получено %d", len(history))
	}
}

func TestGetNodeHistory_NoNode(t *testing.T) {
	eng := NewEngine(defaultCfg())
	history := eng.GetNodeHistory("nonexistent")
	if history != nil {
		t.Error("для несуществующей ноды ожидался nil")
	}
}

func TestToggleNode_NotFound(t *testing.T) {
	eng := NewEngine(defaultCfg())
	err := eng.ToggleNode("nonexistent", true, "test")
	if err == nil {
		t.Error("ожидалась ошибка для несуществующей ноды")
	}
}

func TestUpdateConfig(t *testing.T) {
	eng := NewEngine(defaultCfg())
	m := freshMetric("pbx-01")
	eng.UpdateMetric(m)

	// Меняем конфиг на более строгий
	newCfg := defaultCfg()
	newCfg.Scoring.CallFailureRateLethal = 0.1
	newCfg.Scoring.IdleCPUMin = 50
	eng.UpdateConfig(newCfg)

	ns, _ := eng.GetNodeInfo("pbx-01")
	if ns.Score != -100 {
		t.Errorf("после ужесточения конфига нода должна стать unhealthy, получен score=%.0f", ns.Score)
	}
}

func TestClamp(t *testing.T) {
	tests := []struct {
		v, min, max, expected float64
	}{
		{50, 0, 100, 50},
		{-10, 0, 100, 0},
		{150, 0, 100, 100},
		{0, 0, 0, 0},
	}
	for _, tt := range tests {
		got := clamp(tt.v, tt.min, tt.max)
		if got != tt.expected {
			t.Errorf("clamp(%.0f, %.0f, %.0f) = %.0f, ожидалось %.0f",
				tt.v, tt.min, tt.max, got, tt.expected)
		}
	}
}

func cfgWithEWMA(smoothing float64) *config.Config {
	cfg := defaultCfg()
	cfg.Scoring.SmoothingFactor = smoothing
	cfg.Scoring.LoadAvgMultiplier = 50.0
	return cfg
}

func TestScore_EWMASmoothing(t *testing.T) {
	eng := NewEngine(cfgWithEWMA(0.3))

	// Первый замер: score = rawScore (без сглаживания)
	m := freshMetric("pbx-01")
	m.ActiveCalls = 0
	m.MaxCalls = 100
	m.IdleCPU = 100
	m.LoadAvg = 0
	m.CallFailureRate = 0

	ns1 := eng.UpdateMetric(m)
	if ns1.Score != 100.0 {
		t.Errorf("первый замер должен давать raw score=100 без EWMA, получен %.1f", ns1.Score)
	}

	// Второй замер: резкое падение качества
	m2 := freshMetric("pbx-01")
	m2.ActiveCalls = 50
	m2.MaxCalls = 100
	m2.IdleCPU = 50
	m2.LoadAvg = 1.0
	m2.CallFailureRate = 5.0
	// raw = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 54.5
	// EWMA: 100*(1-0.3) + 54.5*0.3 = 70 + 16.35 = 86.35

	ns2 := eng.UpdateMetric(m2)
	if ns2.Score < 86.0 || ns2.Score > 86.7 {
		t.Errorf("EWMA score должен быть ~86.35, получен %.1f", ns2.Score)
	}

	// Третий замер: возврат к идеальному состоянию
	m3 := freshMetric("pbx-01")
	m3.ActiveCalls = 0
	m3.MaxCalls = 100
	m3.IdleCPU = 100
	m3.LoadAvg = 0
	m3.CallFailureRate = 0
	// raw = 100
	// EWMA: 86.35*(1-0.3) + 100*0.3 = 60.445 + 30 = 90.445

	ns3 := eng.UpdateMetric(m3)
	if ns3.Score < 90.0 || ns3.Score > 90.9 {
		t.Errorf("EWMA score должен быть ~90.45, получен %.1f", ns3.Score)
	}
}

func TestScore_EWMADisabled(t *testing.T) {
	eng := NewEngine(cfgWithEWMA(0.0)) // smoothing=0 → без сглаживания

	m := freshMetric("pbx-01")
	m.ActiveCalls = 50
	m.MaxCalls = 100
	m.IdleCPU = 50
	m.LoadAvg = 1.0
	m.CallFailureRate = 5.0
	// raw = 54.5

	ns1 := eng.UpdateMetric(m)
	if ns1.Score < 54.4 || ns1.Score > 54.6 {
		t.Errorf("без EWMA score должен быть raw=54.5, получен %.1f", ns1.Score)
	}

	// Повторный замер — без EWMA = тот же raw
	ns2 := eng.UpdateMetric(m)
	if ns2.Score < 54.4 || ns2.Score > 54.6 {
		t.Errorf("без EWMA score не должен меняться, получен %.1f", ns2.Score)
	}
}

func TestScore_LoadAvgMultiplier(t *testing.T) {
	eng := NewEngine(cfgWithEWMA(0.0))

	m := freshMetric("pbx-01")
	m.LoadAvg = 2.0
	m.CallFailureRate = 0
	// load_score = 100 - (2.0 * 50) = 0

	ns := eng.UpdateMetric(m)
	loadScore := 0.0
	callScore := clamp(100.0-(float64(m.ActiveCalls)/float64(m.MaxCalls)*100.0), 0, 100)
	idleScore := clamp(m.IdleCPU, 0, 100)
	failScore := clamp(100.0-m.CallFailureRate, 0, 100)
	expected := callScore*0.40 + loadScore*0.30 + idleScore*0.20 + failScore*0.10

	if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
		t.Errorf("с load_avg_multiplier=50 при load_avg=2.0 load_score должен быть 0, получен score %.1f", ns.Score)
	}
}

func TestEvictStaleNodes(t *testing.T) {
	eng := NewEngine(defaultCfg())

	// Добавляем свежую ноду
	m := freshMetric("pbx-01")
	eng.UpdateMetric(m)

	// Добавляем старую ноду (метрика 10 минут назад)
	m2 := freshMetric("pbx-02")
	m2.TS = time.Now().Add(-10 * time.Minute).Unix()
	eng.UpdateMetric(m2)

	// До эвикции — 2 ноды
	if len(eng.nodes) != 2 {
		t.Errorf("ожидалось 2 ноды, получено %d", len(eng.nodes))
	}

	// Эвикция нод старше 5 минут
	eng.evictStaleNodes(5 * time.Minute)

	// После эвикции — только pbx-01 должна остаться
	if len(eng.nodes) != 1 {
		t.Errorf("после эвикции ожидалась 1 нода, получено %d", len(eng.nodes))
	}
	if _, exists := eng.nodes["pbx-02"]; exists {
		t.Error("pbx-02 должна быть удалена")
	}
	if _, exists := eng.rings["pbx-02"]; exists {
		t.Error("ring buffer pbx-02 должен быть удалён")
	}
}

func TestEvictAllNodes_RecalcBest(t *testing.T) {
	eng := NewEngine(defaultCfg())

	// Только старые ноды
	m := freshMetric("pbx-01")
	m.TS = time.Now().Add(-10 * time.Minute).Unix()
	eng.UpdateMetric(m)

	eng.evictStaleNodes(5 * time.Minute)

	// Все ноды удалены — best должен быть пустым
	id, _, fallback := eng.GetBestNode()
	if id != "" {
		t.Errorf("после эвикции всех нод bestNodeID должен быть пустым, получен %s", id)
	}
	if !fallback {
		t.Error("после эвикции всех нод должен быть fallback")
	}
}

func TestEvictionReturnsEvictedIDs(t *testing.T) {
	eng := NewEngine(defaultCfg())

	m1 := freshMetric("pbx-01")
	m1.TS = time.Now().Add(-10 * time.Minute).Unix()
	eng.UpdateMetric(m1)

	m2 := freshMetric("pbx-02")
	eng.UpdateMetric(m2)

	evicted := eng.evictStaleNodes(5 * time.Minute)
	if len(evicted) != 1 {
		t.Errorf("ожидался 1 evicted ID, получено %d: %v", len(evicted), evicted)
	}
	if evicted[0] != "pbx-01" {
		t.Errorf("ожидался pbx-01, получен %s", evicted[0])
	}
}

func TestSetOnNodeEvictedCallback(t *testing.T) {
	eng := NewEngine(defaultCfg())

	var evictedIDs []string
	eng.SetOnNodeEvicted(func(nodeID string) {
		evictedIDs = append(evictedIDs, nodeID)
	})

	m1 := freshMetric("pbx-01")
	m1.TS = time.Now().Add(-10 * time.Minute).Unix()
	eng.UpdateMetric(m1)

	m2 := freshMetric("pbx-02")
	m2.TS = time.Now().Add(-8 * time.Minute).Unix()
	eng.UpdateMetric(m2)

	m3 := freshMetric("pbx-03")
	eng.UpdateMetric(m3)

	evicted := eng.evictStaleNodes(5 * time.Minute)
	for _, id := range evicted {
		if eng.onNodeEvicted != nil {
			eng.onNodeEvicted(id)
		}
	}

	if len(evictedIDs) != 2 {
		t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), evictedIDs)
	}
	found01, found02 := false, false
	for _, id := range evictedIDs {
		if id == "pbx-01" { found01 = true }
		if id == "pbx-02" { found02 = true }
	}
	if !found01 || !found02 {
		t.Errorf("ожидались pbx-01 и pbx-02 в evicted, получены: %v", evictedIDs)
	}
	// pbx-03 должна остаться
	if _, exists := eng.nodes["pbx-03"]; !exists {
		t.Error("pbx-03 должна остаться после эвикции")
	}
}

func cfgWithMode(mode string) *config.Config {
	cfg := defaultCfg()
	cfg.Scoring.LoadAvgMultiplier = 50.0
	cfg.BalanceMode = mode
	return cfg
}

// --- PickWeightedRandom ---

func TestPickWeightedRandom_NoNodes(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")
	id, score, fallback := eng.PickNode()
	if id != "" {
		t.Errorf("без нод ожидался пустой id, получен %s", id)
	}
	if score != 0 {
		t.Errorf("без нод ожидался score=0, получен %.1f", score)
	}
	if !fallback {
		t.Error("без нод ожидался fallback")
	}
}

func TestPickWeightedRandom_OneHealthy(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")
	eng.UpdateMetric(freshMetric("pbx-01"))

	id, score, fallback := eng.PickNode()
	if id != "pbx-01" {
		t.Errorf("одна здоровая нода — ожидалась pbx-01, получена %s", id)
	}
	if score <= 0 {
		t.Errorf("здоровая нода должна иметь положительный score, получен %.1f", score)
	}
	if fallback {
		t.Error("одна здоровая нода — fallback не должен быть активен")
	}
}

func TestPickWeightedRandom_AllUnhealthy(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")

	m := freshMetric("pbx-01")
	m.Status = "down"
	eng.UpdateMetric(m)

	id, _, fallback := eng.PickNode()
	if id == "" {
		t.Error("все ноды unhealthy — id не должен быть пустым (возвращается best из кэша для fallback)")
	}
	if !fallback {
		t.Error("все ноды unhealthy — ожидался fallback")
	}
}

func TestPickWeightedRandom_ExcludesDisabled(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")
	eng.UpdateMetric(freshMetric("pbx-01"))
	eng.ToggleNode("pbx-01", true, "test")

	_, _, fallback := eng.PickNode()
	if !fallback {
		t.Error("единственная нода disabled — должен быть fallback")
	}

	// Добавляем здоровую ноду — disabled исключается
	eng.UpdateMetric(freshMetric("pbx-02"))
	found := false
	for i := 0; i < 20; i++ {
		id, _, fb := eng.PickNode()
		if fb {
			t.Error("есть здоровая нода — не должно быть fallback")
		}
		if id == "pbx-01" {
			t.Error("disabled нода не должна выбираться")
		}
		if id == "pbx-02" {
			found = true
		}
	}
	if !found {
		t.Error("здоровая нода pbx-02 должна выбираться")
	}
}

func TestPickWeightedRandom_ExcludesStale(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")

	m := freshMetric("pbx-01")
	m.TS = time.Now().Unix() - 30 // stale при threshold=20
	eng.UpdateMetric(m)

	id, _, fallback := eng.PickNode()
	if !fallback {
		t.Error("stale-нода должна приводить к fallback")
	}
	if id == "pbx-01" && !fallback {
		t.Error("stale-нода не должна выбираться")
	}
}

func TestPickWeightedRandom_MultipleCandidates(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")

	m1 := freshMetric("pbx-01")
	m1.ActiveCalls = 20
	m1.MaxCalls = 100
	eng.UpdateMetric(m1)

	m2 := freshMetric("pbx-02")
	m2.ActiveCalls = 80
	m2.MaxCalls = 100
	eng.UpdateMetric(m2)

	healthyNodes := map[string]bool{}
	for i := 0; i < 100; i++ {
		id, _, fallback := eng.PickNode()
		if fallback {
			t.Error("есть здоровые ноды — не должно быть fallback")
		}
		healthyNodes[id] = true
	}
	if !healthyNodes["pbx-01"] {
		t.Error("pbx-01 должна выбираться при weighted random")
	}
	if !healthyNodes["pbx-02"] {
		t.Error("pbx-02 должна выбираться при weighted random")
	}
}

func TestPickWeightedRandom_AllScoreZero(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.SetBalancingMode("weighted_random")

	// Добавляем ноду с score=0 (на грани capacity)
	m := freshMetric("pbx-01")
	m.ActiveCalls = 250
	m.MaxCalls = 250
	// Это lethal condition, score будет -100
	// Нужна нода с score=0 но не lethal — используем высокую нагрузку
	m.ActiveCalls = 0
	m.MaxCalls = 1       // не lethal (active < max)
	m.LoadAvg = 2.0      // load_score = 100 - 2*50 = 0
	m.CallFailureRate = 0
	m.IdleCPU = 0        // idle_score = 0
	// call_score = 100 - (0/1 * 100) = 100
	// load_score = 0
	// idle_score = 0
	// fail_score = 100
	// score = 100*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 50 ← not zero

	// Let's make load_avg very high to get score 0
	m.LoadAvg = 3.0      // load_score = 100 - 150 = -50 → clamped to 0
	m.ActiveCalls = 1
	m.MaxCalls = 1       // call_score = 0
	// So call_score=0, load_score=0, idle_score=0, fail_score=100
	// score = 0*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 10 ← still not zero since IdleCPU=0 is lethal!

	// Actually idle_cpu < IdleCPUMin(5) → lethal. So score = -100
	// To get score=0 without lethal: set IdleCPU=5 (just above threshold)
	m.IdleCPU = 5 // idle_score = 5
	m.ActiveCalls = 1
	m.MaxCalls = 1  // call_score = 0
	m.LoadAvg = 2.0 // load_score = 0
	m.CallFailureRate = 0 // fail_score = 100
	// score = 0*0.4 + 0*0.3 + 5*0.2 + 100*0.1 = 1 + 10 = 11 ← exercise already works

	// The "all score zero" edge case is theoretical — in practice scores are positive.
	// We test that PickNode doesn't panic when totalScore=0 scenario is somehow reached.
	// Let's just verify non-fatal path for very low load
	m.IdleCPU = 5
	m.LoadAvg = 1.95 // load_score = 100 - 97.5 = 2.5
	m.ActiveCalls = 99
	m.MaxCalls = 100 // call_score = 100 - 99 = 1
	eng.UpdateMetric(m)

	id, _, fallback := eng.PickNode()
	if id == "" || fallback {
		t.Errorf("с score>0 не должно быть fallback, id=%s, fallback=%v", id, fallback)
	}
}

// --- PickNode (facade) ---

func TestPickNode_BestMode(t *testing.T) {
	eng := NewEngine(cfgWithMode("best"))
	eng.UpdateMetric(freshMetric("pbx-01"))
	eng.UpdateMetric(freshMetric("pbx-02"))

	id, _, fallback := eng.PickNode()
	if fallback {
		t.Error("best mode с здоровыми нодами — не должно быть fallback")
	}
	if id == "" {
		t.Error("best mode должен вернуть идентификатор ноды")
	}
}

func TestPickNode_WeightedMode(t *testing.T) {
	eng := NewEngine(cfgWithMode("weighted_random"))
	eng.UpdateMetric(freshMetric("pbx-01"))

	id, _, fallback := eng.PickNode()
	if fallback {
		t.Error("weighted mode с здоровой нодой — не должно быть fallback")
	}
	if id != "pbx-01" {
		t.Errorf("weighted mode — ожидалась pbx-01, получена %s", id)
	}
}

// --- Balancing mode ---

func TestSetGetBalancingMode(t *testing.T) {
	eng := NewEngine(cfgWithMode("best"))
	if eng.GetBalancingMode() != "best" {
		t.Errorf("ожидался mode=best, получен %s", eng.GetBalancingMode())
	}

	eng.SetBalancingMode("weighted_random")
	if eng.GetBalancingMode() != "weighted_random" {
		t.Errorf("ожидался mode=weighted_random, получен %s", eng.GetBalancingMode())
	}
}

func TestNewEngine_BalanceModeDefault(t *testing.T) {
	// Без BalanceMode в конфиге (backward compat)
	cfg := defaultCfg()
	cfg.BalanceMode = ""
	eng := NewEngine(cfg)

	if eng.GetBalancingMode() != "weighted_random" {
		t.Errorf("пустой balance_mode должен дефолтиться на weighted_random, получен %s", eng.GetBalancingMode())
	}
}
