package game

import (
	"math"
	"testing"
	"time"

	"github.com/denisovdennis/autohero/internal/model"
	"github.com/denisovdennis/autohero/internal/tuning"
)

// testGraph builds a minimal two-town road graph for movement tests.
func testGraph() *RoadGraph {
	townA := &model.Town{ID: 1, Name: "TownA", WorldX: 0, WorldY: 0, Radius: 0.5}
	townB := &model.Town{ID: 2, Name: "TownB", WorldX: 100, WorldY: 0, Radius: 0.5}
	road := &Road{
		ID: 1, FromTownID: 1, ToTownID: 2,
		Waypoints: []Point{{0, 0}, {50, 0}, {100, 0}},
		Distance:  100,
	}
	roadBack := &Road{
		ID: 2, FromTownID: 2, ToTownID: 1,
		Waypoints: []Point{{100, 0}, {50, 0}, {0, 0}},
		Distance:  100,
	}
	return &RoadGraph{
		Roads:         map[int64]*Road{1: road, 2: roadBack},
		TownRoads:     map[int64][]*Road{1: {road}, 2: {roadBack}},
		Towns:         map[int64]*model.Town{1: townA, 2: townB},
		TownOrder:     []int64{1, 2},
		TownNPCs:      map[int64][]TownNPC{},
		NPCByID:       map[int64]TownNPC{},
		TownBuildings: map[int64][]TownBuilding{},
	}
}

// TestLeaveTown_ProjectsPlazaOntoRoad ensures we do not snap the hero to waypoint 0 when leaving
// town from a plaza offset from the graph origin (regression: assignRoad(..., true) teleported).
func TestLeaveTown_ProjectsPlazaOntoRoad(t *testing.T) {
	graph := testGraph()
	town1 := int64(1)
	hero := &model.Hero{
		ID: 1, State: model.StateInTown, HP: 10, MaxHP: 10, Level: 1,
		CurrentTownID: &town1,
		PositionX:     2,
		PositionY:     1,
	}
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	if hm.State != model.StateInTown {
		t.Fatalf("expected StateInTown from NewHeroMovement, got %v", hm.State)
	}
	hm.CurrentTownID = 1
	hm.CurrentX = 2
	hm.CurrentY = 1
	hm.Road = nil

	hm.LeaveTown(graph, now)

	if hm.State != model.StateWalking {
		t.Fatalf("expected StateWalking after LeaveTown, got %v", hm.State)
	}
	if hm.Road == nil {
		t.Fatal("expected Road assigned after LeaveTown")
	}
	// Waypoint 0 is (0,0); (2,1) should project closer to the outbound polyline than to the origin.
	dFromPlaza := math.Hypot(hm.CurrentX-2, hm.CurrentY-1)
	dFromOrigin := math.Hypot(2, 1)
	if dFromPlaza >= dFromOrigin-0.05 {
		t.Fatalf("expected position projected toward plaza (2,1), got (%g,%g) distPlaza=%g distOrigin=%g",
			hm.CurrentX, hm.CurrentY, dFromPlaza, dFromOrigin)
	}
}

func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero {
	townID := int64(1)
	destID := int64(2)
	return &model.Hero{
		ID: id, Level: 5,
		MaxHP: maxHP, HP: hp,
		Attack: 50, Defense: 30, Speed: 1.0,
		Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
		State:             model.StateWalking,
		CurrentTownID:     &townID,
		DestinationTownID: &destID,
		PositionX:         50, PositionY: 0,
	}
}

// --- Roadside rest ---

func TestRoadsideRest_TriggersOnLowHP(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	threshold := cfg.LowHpThreshold
	maxHP := 1000
	lowHP := int(float64(maxHP)*threshold) - 1

	hero := testHeroOnRoad(1, lowHP, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking

	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateResting {
		t.Fatalf("expected StateResting, got %s", hm.State)
	}
	if hm.ActiveRestKind != model.RestKindRoadside {
		t.Fatalf("expected RestKindRoadside, got %s", hm.ActiveRestKind)
	}
	if hm.RestUntil.IsZero() {
		t.Fatal("expected RestUntil to be set for roadside rest")
	}
}

func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 1000
	safeHP := int(float64(maxHP)*cfg.LowHpThreshold) + 10

	hero := testHeroOnRoad(1, safeHP, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking
	hm.LastEncounterAt = now

	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)

	if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
		t.Fatal("should not trigger roadside rest above threshold")
	}
}

func TestRoadsideRest_HealsHP(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 10000
	hero := testHeroOnRoad(1, 100, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.LastMoveTick = now

	hpBefore := hm.Hero.HP
	tick := now.Add(10 * time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)

	if hm.Hero.HP <= hpBefore {
		t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
	}
	expectedGain := int(float64(maxHP) * cfg.RoadsideRestHpPerS * 10)
	actualGain := hm.Hero.HP - hpBefore
	if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
		t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
	}
}

func TestRoadsideRest_ExitsByTimer(t *testing.T) {
	graph := testGraph()
	maxHP := 10000
	hero := testHeroOnRoad(1, 1, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.LastMoveTick = now

	pastTimer := hm.RestUntil.Add(time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil, nil)

	if hm.Excursion.Phase != model.ExcursionReturn {
		t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
	}

	hm.CurrentX = hm.Excursion.StartX
	hm.CurrentY = hm.Excursion.StartY
	hm.LastMoveTick = pastTimer
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateWalking {
		t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
	}
}

func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 1000
	hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.LastMoveTick = now
	tick := now.Add(time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)

	if hm.Excursion.Phase != model.ExcursionReturn {
		t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase)
	}
}

func TestRoadsideRest_AttractorWorldMovement(t *testing.T) {
	graph := testGraph()
	maxHP := 1000
	hero := testHeroOnRoad(1, 100, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)

	x0, y0 := hm.CurrentX, hm.CurrentY
	hm.LastMoveTick = now
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil, nil)
	if hm.CurrentX == x0 && hm.CurrentY == y0 {
		t.Fatal("expected hero world position to move toward forest attractor during out phase")
	}
	ox, oy := hm.displayOffset(now)
	if ox != 0 || oy != 0 {
		t.Fatal("attractor-mode excursion should not use perpendicular display offset")
	}
}

// --- Adventure inline rest ---

func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 1000
	lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1

	hero := testHeroOnRoad(1, lowHP, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking
	hm.beginExcursion(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.LastMoveTick = now
	tick := now.Add(time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateResting {
		t.Fatalf("expected StateResting, got %s", hm.State)
	}
	if hm.ActiveRestKind != model.RestKindAdventureInline {
		t.Fatalf("expected RestKindAdventureInline, got %s", hm.ActiveRestKind)
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should remain active during inline rest")
	}
}

func TestAdventureInlineRest_HealsHP(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 10000
	hero := testHeroOnRoad(1, 100, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.beginAdventureInlineRest(now)

	hpBefore := hm.Hero.HP
	tick := now.Add(10 * time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)

	if hm.Hero.HP <= hpBefore {
		t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
	}
	expectedGain := int(float64(maxHP) * cfg.AdventureRestHpPerS * 10)
	actualGain := hm.Hero.HP - hpBefore
	if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
		t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
	}
}

func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 1000
	targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp)
	hero := testHeroOnRoad(1, targetHP, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.beginAdventureInlineRest(now)

	tick := now.Add(time.Second)
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateWalking {
		t.Fatalf("expected StateWalking after HP target, got %s", hm.State)
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should still be active after inline rest exits by HP")
	}
}

func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) {
	graph := testGraph()
	maxHP := 10000
	hero := testHeroOnRoad(1, 500, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.Excursion.Phase = model.ExcursionReturn
	hm.enterAdventureReturnToRoad()
	hm.CurrentX = hm.Excursion.AttractorX
	hm.CurrentY = hm.Excursion.AttractorY
	hm.LastMoveTick = now
	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateWalking {
		t.Fatalf("expected StateWalking after return completes, got %s", hm.State)
	}
	if hm.Excursion.Active() {
		t.Fatal("excursion should be cleared after return phase reached road attractor")
	}
}

func TestAdventureInlineRest_NoTimerFieldSet(t *testing.T) {
	hero := testHeroOnRoad(1, 100, 1000)
	now := time.Now()
	graph := testGraph()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.beginAdventureInlineRest(now)

	if !hm.RestUntil.IsZero() {
		t.Fatal("adventure inline rest should not set RestUntil (HP-based only)")
	}
}

// --- Persistence ---

func TestRoadsideRest_Persistence(t *testing.T) {
	graph := testGraph()
	maxHP := 1000
	hero := testHeroOnRoad(1, 100, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)
	hm.SyncToHero()

	if hero.RestKind != model.RestKindRoadside {
		t.Fatalf("expected hero.RestKind = roadside, got %s", hero.RestKind)
	}
	if hero.TownPause == nil {
		t.Fatal("expected TownPause to be set")
	}
	if hero.TownPause.RestKind != model.RestKindRoadside {
		t.Fatalf("expected TownPause.RestKind = roadside, got %s", hero.TownPause.RestKind)
	}
	if hero.TownPause.RestUntil == nil || hero.TownPause.RestUntil.IsZero() {
		t.Fatal("expected TownPause.RestUntil to be set for roadside rest")
	}

	hero2 := *hero
	hm2 := NewHeroMovement(&hero2, graph, now)
	if hm2.State != model.StateResting {
		t.Fatalf("expected restored state = resting, got %s", hm2.State)
	}
	if hm2.ActiveRestKind != model.RestKindRoadside {
		t.Fatalf("expected restored rest kind = roadside, got %s", hm2.ActiveRestKind)
	}
	if hm2.RestUntil.IsZero() {
		t.Fatal("expected restored RestUntil to be non-zero")
	}
	if hm2.Road == nil {
		t.Fatal("expected road to be assigned for roadside rest reconnect")
	}
}

func TestAdventureInlineRest_Persistence(t *testing.T) {
	graph := testGraph()
	maxHP := 1000
	hero := testHeroOnRoad(1, 100, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.beginAdventureInlineRest(now)
	hm.SyncToHero()

	if hero.RestKind != model.RestKindAdventureInline {
		t.Fatalf("expected hero.RestKind = adventure_inline, got %s", hero.RestKind)
	}
	if hero.TownPause == nil {
		t.Fatal("expected TownPause to be set")
	}
	if hero.TownPause.RestKind != model.RestKindAdventureInline {
		t.Fatalf("expected TownPause.RestKind = adventure_inline, got %s", hero.TownPause.RestKind)
	}
	if hero.TownPause.Excursion == nil {
		t.Fatal("expected TownPause.Excursion to be set")
	}

	hero2 := *hero
	hm2 := NewHeroMovement(&hero2, graph, now)
	if hm2.State != model.StateResting {
		t.Fatalf("expected restored state = resting, got %s", hm2.State)
	}
	if hm2.ActiveRestKind != model.RestKindAdventureInline {
		t.Fatalf("expected restored rest kind = adventure_inline, got %s", hm2.ActiveRestKind)
	}
	if !hm2.Excursion.Active() {
		t.Fatal("expected excursion to be restored")
	}
	if hm2.Road == nil {
		t.Fatal("expected road to be assigned for adventure inline rest reconnect")
	}
}

// --- Admin ---

func TestAdminStartRoadsideRest(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)

	if !hm.AdminStartRoadsideRest(now) {
		t.Fatal("AdminStartRoadsideRest should succeed")
	}
	if hm.State != model.StateResting {
		t.Fatalf("expected resting, got %s", hm.State)
	}
	if hm.ActiveRestKind != model.RestKindRoadside {
		t.Fatalf("expected roadside, got %s", hm.ActiveRestKind)
	}
}

func TestAdminStartRoadsideRest_RejectsDead(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 0, 1000)
	hero.State = model.StateDead
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)

	if hm.AdminStartRoadsideRest(now) {
		t.Fatal("AdminStartRoadsideRest should reject dead hero")
	}
}

func TestAdminStopRest_Roadside(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 100, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)

	if !hm.AdminStopRest(now) {
		t.Fatal("AdminStopRest should succeed for roadside rest")
	}
	if hm.State != model.StateWalking {
		t.Fatalf("expected walking, got %s", hm.State)
	}
	if hm.ActiveRestKind != model.RestKindNone {
		t.Fatalf("expected rest kind none, got %s", hm.ActiveRestKind)
	}
}

func TestAdminStopRest_AdventureInline(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 100, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.beginAdventureInlineRest(now)

	if !hm.AdminStopRest(now) {
		t.Fatal("AdminStopRest should succeed for adventure inline rest")
	}
	if hm.State != model.StateWalking {
		t.Fatalf("expected walking, got %s", hm.State)
	}
	if hm.Excursion.Active() {
		t.Fatal("excursion should be cleared after admin stop rest")
	}
}

func TestAdminStopRest_RejectsTownRest(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.AdminStartRest(now, graph)

	if hm.AdminStopRest(now) {
		t.Fatal("AdminStopRest should reject town rest")
	}
}

func TestAdminStopRest_RejectsWalking(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)

	if hm.AdminStopRest(now) {
		t.Fatal("AdminStopRest should reject walking hero")
	}
}

func TestAdminStartExcursion(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)

	if !hm.AdminStartExcursion(now) {
		t.Fatal("AdminStartExcursion should succeed")
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should be active")
	}
	if hm.Excursion.Phase != model.ExcursionOut {
		t.Fatalf("expected phase out, got %s", hm.Excursion.Phase)
	}
}

func TestAdminStartExcursion_RejectsWhenAlreadyActive(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)

	if hm.AdminStartExcursion(now) {
		t.Fatal("AdminStartExcursion should reject when excursion already active")
	}
}

func TestAdminStartExcursion_RejectsNotWalking(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)

	if hm.AdminStartExcursion(now) {
		t.Fatal("AdminStartExcursion should reject when not walking")
	}
}

func TestAdminStopExcursion_WhileWalking(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)

	if !hm.AdminStopExcursion(now) {
		t.Fatal("AdminStopExcursion should succeed")
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should stay active during return leg")
	}
	if hm.Excursion.Phase != model.ExcursionReturn {
		t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
	}
	if hm.State != model.StateWalking {
		t.Fatalf("expected walking, got %s", hm.State)
	}
}

func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 100, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginExcursion(now)
	hm.beginAdventureInlineRest(now)

	if !hm.AdminStopExcursion(now) {
		t.Fatal("AdminStopExcursion should succeed from inline rest")
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should stay active during return leg")
	}
	if hm.Excursion.Phase != model.ExcursionReturn {
		t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
	}
	if hm.State != model.StateWalking {
		t.Fatalf("expected walking, got %s", hm.State)
	}
}

func TestAdminStopExcursion_RoadsideStartsReturn(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 100, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.beginRoadsideRest(now)
	hm.Excursion.Phase = model.ExcursionWild
	hm.LastMoveTick = now

	if !hm.AdminStopExcursion(now) {
		t.Fatal("AdminStopExcursion should succeed for roadside excursion")
	}
	if !hm.Excursion.Active() {
		t.Fatal("excursion should stay active during return leg")
	}
	if hm.Excursion.Phase != model.ExcursionReturn {
		t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
	}
	if hm.State != model.StateResting || hm.ActiveRestKind != model.RestKindRoadside {
		t.Fatalf("expected roadside rest, got state=%s kind=%s", hm.State, hm.ActiveRestKind)
	}
}

func TestAdminStopExcursion_RejectsNone(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)

	if hm.AdminStopExcursion(now) {
		t.Fatal("AdminStopExcursion should reject when no excursion")
	}
}

// --- FSM: road freeze + no rest in combat ---

// TestExcursion_FreezesRoadWaypointDuringSession asserts AdvanceTick does not advance the spine
// while an excursion is active (waypoint index/fraction stay at freeze snapshot).
func TestExcursion_FreezesRoadWaypointDuringSession(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 500, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking

	hm.beginExcursion(now)
	freezeIdx := hm.Excursion.RoadFreezeWaypoint
	freezeFr := hm.Excursion.RoadFreezeFraction

	// Mid wild phase: several movement ticks should not move along the road polyline.
	wildMid := hm.Excursion.OutUntil.Add(hm.Excursion.WildUntil.Sub(hm.Excursion.OutUntil) / 2)
	for i := 0; i < 5; i++ {
		tick := wildMid.Add(time.Duration(i) * time.Second)
		ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
		if hm.Excursion.Phase == model.ExcursionNone {
			t.Fatalf("excursion ended unexpectedly at tick %v", tick)
		}
		if hm.WaypointIndex != freezeIdx || hm.WaypointFraction != freezeFr {
			t.Fatalf("road spine should stay frozen: want idx=%d fr=%v, got idx=%d fr=%v",
				freezeIdx, freezeFr, hm.WaypointIndex, hm.WaypointFraction)
		}
	}
}

// TestLowHP_DoesNotStartRestWhileFighting ensures ProcessSingleHeroMovementTick does not
// transition to roadside or inline rest when the hero is in combat state.
func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) {
	graph := testGraph()
	cfg := tuning.Get()
	maxHP := 1000
	lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1

	hero := testHeroOnRoad(1, lowHP, maxHP)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	hm.State = model.StateFighting
	hm.Hero.State = model.StateFighting

	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateFighting {
		t.Fatalf("expected StateFighting unchanged, got %s", hm.State)
	}
	if hm.ActiveRestKind != model.RestKindNone {
		t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind)
	}
}

// TestProcessMovementTick_DeadHeroIgnoresWalkingFSM ensures HP 0 never runs the walking
// tick (no hero_move) even if hm.State was incorrectly left as Walking.
func TestProcessMovementTick_DeadHeroIgnoresWalkingFSM(t *testing.T) {
	graph := testGraph()
	hero := testHeroOnRoad(1, 0, 1000)
	now := time.Now()
	hm := NewHeroMovement(hero, graph, now)
	if hm.State != model.StateDead {
		t.Fatalf("NewHeroMovement should set dead for HP=0, got %s", hm.State)
	}
	hm.State = model.StateWalking // simulate FSM / snapshot desync

	ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)

	if hm.State != model.StateDead {
		t.Fatalf("expected StateDead after tick, got %s", hm.State)
	}
}
