package game

import (
	"fmt"
	"math"
	"math/rand"
	"time"

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

const (
	// townNPCVisitLogLines is how many log lines to emit per NPC visit.
	townNPCVisitLogLines = 6
)

func movementTickRate() time.Duration {
	ms := tuning.Get().MovementTickRateMs
	if ms <= 0 {
		ms = tuning.DefaultValues().MovementTickRateMs
	}
	return time.Duration(ms) * time.Millisecond
}

func positionSyncRate() time.Duration {
	ms := tuning.Get().PositionSyncRateMs
	if ms <= 0 {
		ms = tuning.DefaultValues().PositionSyncRateMs
	}
	return time.Duration(ms) * time.Millisecond
}

func townNPCLogInterval() time.Duration {
	ms := tuning.Get().TownNPCLogIntervalMs
	if ms <= 0 {
		ms = tuning.DefaultValues().TownNPCLogIntervalMs
	}
	return time.Duration(ms) * time.Millisecond
}

// AdventureLogWriter persists or pushes one adventure log line for a hero (optional).
type AdventureLogWriter func(heroID int64, message string)

// HeroMovement holds the live movement state for a single online hero.
type HeroMovement struct {
	HeroID            int64
	Hero              *model.Hero // live reference, owned by the engine
	CurrentX          float64
	CurrentY          float64
	Speed             float64 // effective world-units/sec
	State             model.GameState
	DestinationTownID int64
	CurrentTownID     int64
	Road              *Road
	WaypointIndex     int     // index of the waypoint we are heading toward
	WaypointFraction  float64 // 0..1 within the current segment
	LastEncounterAt   time.Time
	RestUntil         time.Time
	LastMoveTick      time.Time
	Direction         int // +1 forward along TownOrder, -1 backward

	// TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown.
	TownNPCQueue      []int64
	NextTownNPCRollAt time.Time

	// Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit.
	TownVisitNPCName     string
	TownVisitNPCType     string
	TownVisitStartedAt   time.Time
	TownVisitLogsEmitted int

	// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
	TownLeaveAt time.Time

	// Accumulates fractional town-rest regen between ticks.
	TownRestHealRemainder float64

	// WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only).
	WanderingMerchantDeadline time.Time

	// Excursion holds the live mini-adventure session state.
	// When Excursion.Phase == ExcursionNone the hero is on the road (normal walk / town / roadside rest).
	Excursion model.ExcursionSession

	// ActiveRestKind discriminates the current rest context when State == StateResting.
	ActiveRestKind model.RestKind

	// RestHealRemainder accumulates fractional HP between ticks for roadside / adventure-inline rest.
	RestHealRemainder float64

	// LastExcursionEndedAt is used for adventure cooldown (not persisted; resets on reconnect).
	LastExcursionEndedAt time.Time

	// spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad
	// instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
	spawnAtRoadStart bool
}

// NewHeroMovement creates a HeroMovement for a hero that just connected.
// It initializes position, state, and picks the first destination if needed.
func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement {
	// Randomize direction per hero so they don't all walk the same way.
	dir := 1
	if hero.ID%2 == 0 {
		dir = -1
	}

	// Persisted (x,y) already include any in-world offset from prior sessions; do not add
	// lateral jitter again on reconnect (that doubled the shift every reload).
	freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0
	var curX, curY float64
	if freshWorldSpawn {
		curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road
	} else {
		curX = hero.PositionX
		curY = hero.PositionY
	}

	hm := &HeroMovement{
		HeroID:           hero.ID,
		Hero:             hero,
		CurrentX:         curX,
		CurrentY:         curY,
		State:            hero.State,
		LastMoveTick:     now,
		Direction:        dir,
		spawnAtRoadStart: freshWorldSpawn,
	}

	// Restore persisted movement state.
	if hero.CurrentTownID != nil {
		hm.CurrentTownID = *hero.CurrentTownID
	}
	if hero.DestinationTownID != nil {
		hm.DestinationTownID = *hero.DestinationTownID
	}

	hm.refreshSpeed(now)

	// If the hero is dead, keep them dead.
	if hero.State == model.StateDead || hero.HP <= 0 {
		hm.State = model.StateDead
		return hm
	}

	// If fighting, leave as-is (engine combat system manages it).
	if hero.State == model.StateFighting {
		return hm
	}

	// Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause).
	if hero.State == model.StateResting || hero.State == model.StateInTown {
		hm.State = hero.State
		hm.applyTownPauseFromHero(hero, now)

		// For roadside / adventure-inline rest the hero needs a road for display offset calculations.
		if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline {
			if hm.DestinationTownID == 0 {
				hm.pickDestination(graph)
			}
			hm.assignRoad(graph)
			if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 {
				hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
				hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
				from := hm.Road.Waypoints[hm.WaypointIndex]
				to := hm.Road.Waypoints[hm.WaypointIndex+1]
				hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
				hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
			}
		}

		return hm
	}

	// Walking state: assign a road if we don't have a destination.
	if hm.DestinationTownID == 0 {
		hm.pickDestination(graph)
	}
	hm.assignRoad(graph)
	if hm.Road == nil {
		hm.pickDestination(graph)
		hm.assignRoad(graph)
	}
	hm.State = model.StateWalking

	// Restore excursion session from persisted blob (hero may have disconnected mid-adventure).
	if hero.TownPause != nil && hero.TownPause.Excursion != nil {
		hm.applyExcursionFromBlob(hero.TownPause.Excursion)
		if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 {
			hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
			hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
			from := hm.Road.Waypoints[hm.WaypointIndex]
			to := hm.Road.Waypoints[hm.WaypointIndex+1]
			hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
			hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
		}
	}

	return hm
}

// firstReachableOnRing returns the first town along TownOrder (stepping by Direction)
// that has a direct road from CurrentTownID, or 0 if none.
func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 {
	n := len(graph.TownOrder)
	if n < 2 || fromIdx < 0 {
		return 0
	}
	for step := 1; step < n; step++ {
		raw := fromIdx + hm.Direction*step
		nextIdx := ((raw % n) + n) % n
		candidate := graph.TownOrder[nextIdx]
		if candidate == hm.CurrentTownID {
			continue
		}
		if graph.FindRoad(hm.CurrentTownID, candidate) != nil {
			return candidate
		}
	}
	return 0
}

func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 {
	for _, r := range graph.TownRoads[hm.CurrentTownID] {
		if r != nil && r.ToTownID != hm.CurrentTownID {
			return r.ToTownID
		}
	}
	return 0
}

func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
	for _, tid := range graph.TownOrder {
		if tid == hm.CurrentTownID {
			continue
		}
		if graph.FindRoad(hm.CurrentTownID, tid) != nil {
			return tid
		}
	}
	return 0
}

// avoidSelfLoopDestination forces a different destination than CurrentTownID when the world
// has multiple towns. Roads are always between distinct towns; dest == current yields no road
// and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0).
func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) {
	if graph == nil || len(graph.TownOrder) <= 1 {
		return
	}
	if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID {
		return
	}
	if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID {
		hm.DestinationTownID = d
		return
	}
	if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID {
		hm.DestinationTownID = nxt
	}
}

// crossRoadChance is the probability of picking a cross-road instead of following the ring.
const crossRoadChance = 0.3

// pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
// When multiple outgoing roads exist, there's a chance the hero takes a cross-road.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
	defer hm.avoidSelfLoopDestination(graph)

	if hm.CurrentTownID == 0 {
		if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 {
			hm.CurrentTownID = graph.TownOrder[0]
		} else {
			hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
		}
	}

	n := len(graph.TownOrder)
	if n == 0 {
		hm.DestinationTownID = 0
		return
	}
	if n == 1 {
		hm.DestinationTownID = hm.CurrentTownID
		return
	}

	idx := graph.TownOrderIndex(hm.CurrentTownID)
	if idx < 0 {
		if d := hm.firstOutgoingDestination(graph); d != 0 {
			hm.DestinationTownID = d
			return
		}
		if d := hm.firstReachableAny(graph); d != 0 {
			hm.DestinationTownID = d
			return
		}
		if len(graph.TownOrder) > 0 {
			hm.DestinationTownID = graph.TownOrder[0]
		}
		return
	}

	// When multiple roads are available, sometimes take a cross-road for variety.
	outgoing := graph.TownRoads[hm.CurrentTownID]
	if len(outgoing) > 2 && rand.Float64() < crossRoadChance {
		pick := outgoing[rand.Intn(len(outgoing))]
		if pick != nil && pick.ToTownID != hm.CurrentTownID {
			hm.DestinationTownID = pick.ToTownID
			return
		}
	}

	if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
		hm.DestinationTownID = dest
		return
	}
	if d := hm.firstOutgoingDestination(graph); d != 0 {
		hm.DestinationTownID = d
		return
	}
	if d := hm.firstReachableAny(graph); d != 0 {
		hm.DestinationTownID = d
		return
	}
	hm.DestinationTownID = hm.CurrentTownID
}

// assignRoad finds and configures the road from CurrentTownID to DestinationTownID.
// If no road exists (hero is mid-road), it finds the nearest town and routes from there.
func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
	road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID)
	if road == nil {
		// Try finding a road from any nearby town.
		nearest := graph.NearestTown(hm.CurrentX, hm.CurrentY)
		hm.CurrentTownID = nearest
		road = graph.FindRoad(nearest, hm.DestinationTownID)
	}
	if road == nil {
		// No road available, will retry next tick.
		return
	}

	// Create a per-hero jittered copy of waypoints so heroes don't overlap on the same road.
	jitteredWaypoints := make([]Point, len(road.Waypoints))
	copy(jitteredWaypoints, road.Waypoints)
	heroSeed := float64(hm.HeroID)
	lateralJitter := (math.Sin(heroSeed*1.7) * 1.5) // ±1.5 tiles lateral offset
	for i := 1; i < len(jitteredWaypoints)-1; i++ {
		// Apply perpendicular offset (don't jitter start/end = town centers)
		dx := jitteredWaypoints[i].X - jitteredWaypoints[max(0, i-1)].X
		dy := jitteredWaypoints[i].Y - jitteredWaypoints[max(0, i-1)].Y
		segLen := math.Hypot(dx, dy)
		if segLen > 0.1 {
			perpX := -dy / segLen
			perpY := dx / segLen
			jitter := lateralJitter * (0.7 + 0.3*math.Sin(heroSeed*0.3+float64(i)*0.5))
			jitteredWaypoints[i].X += perpX * jitter
			jitteredWaypoints[i].Y += perpY * jitter
		}
	}

	jitteredRoad := &Road{
		ID:         road.ID,
		FromTownID: road.FromTownID,
		ToTownID:   road.ToTownID,
		Distance:   road.Distance,
		Waypoints:  jitteredWaypoints,
	}

	hm.Road = jitteredRoad
	if hm.spawnAtRoadStart {
		wp0 := jitteredRoad.Waypoints[0]
		hm.CurrentX = wp0.X
		hm.CurrentY = wp0.Y
		hm.WaypointIndex = 0
		hm.WaypointFraction = 0
		hm.spawnAtRoadStart = false
	} else {
		// Restore progress along this hero's jittered polyline from saved world position.
		hm.snapProgressToNearestPointOnRoad()
	}
}

// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y
// to the closest point on the current road polyline to the incoming position.
func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		hm.WaypointIndex = 0
		hm.WaypointFraction = 0
		return
	}
	hx, hy := hm.CurrentX, hm.CurrentY
	bestIdx := 0
	bestT := 0.0
	bestDistSq := math.MaxFloat64
	bestX, bestY := hx, hy
	for i := 0; i < len(hm.Road.Waypoints)-1; i++ {
		ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y
		bx, by := hm.Road.Waypoints[i+1].X, hm.Road.Waypoints[i+1].Y
		dx, dy := bx-ax, by-ay
		segLenSq := dx*dx + dy*dy
		var t float64
		if segLenSq < 1e-12 {
			t = 0
		} else {
			t = ((hx-ax)*dx + (hy-ay)*dy) / segLenSq
			if t < 0 {
				t = 0
			}
			if t > 1 {
				t = 1
			}
		}
		px := ax + t*dx
		py := ay + t*dy
		dSq := (hx-px)*(hx-px) + (hy-py)*(hy-py)
		if dSq < bestDistSq {
			bestDistSq = dSq
			bestIdx = i
			bestT = t
			bestX, bestY = px, py
		}
	}
	hm.WaypointIndex = bestIdx
	hm.WaypointFraction = bestT
	hm.CurrentX = bestX
	hm.CurrentY = bestY
}

// ShiftGameDeadlines advances movement-related deadlines by d (wall time spent paused) so
// simulation does not “catch up” after resume. LastMoveTick is set to now to avoid a huge dt on the next tick.
func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
	if d <= 0 {
		hm.LastMoveTick = now
		return
	}
	shift := func(t time.Time) time.Time {
		if t.IsZero() {
			return t
		}
		return t.Add(d)
	}
	hm.LastEncounterAt = shift(hm.LastEncounterAt)
	hm.RestUntil = shift(hm.RestUntil)
	hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
	hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
	hm.TownLeaveAt = shift(hm.TownLeaveAt)
	hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
	hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
	hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil)
	hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil)
	hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt)
	hm.LastMoveTick = now
}

// refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.
func (hm *HeroMovement) refreshSpeed(now time.Time) {
	// Per-hero speed variation: ±10% based on hero ID for natural spread.
	heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10
	hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
}

// AdvanceTick moves the hero along the road for one movement tick.
// Returns true if the hero reached the destination town this tick.
func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) {
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return false
	}

	if hm.Excursion.Active() {
		hm.LastMoveTick = now
		return false
	}

	dt := now.Sub(hm.LastMoveTick).Seconds()
	if dt <= 0 {
		dt = movementTickRate().Seconds()
	}
	hm.LastMoveTick = now

	hm.refreshSpeed(now)
	distThisTick := hm.Speed * dt

	for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
		from := hm.Road.Waypoints[hm.WaypointIndex]
		to := hm.Road.Waypoints[hm.WaypointIndex+1]
		segLen := math.Hypot(to.X-from.X, to.Y-from.Y)
		if segLen < 0.001 {
			hm.WaypointIndex++
			hm.WaypointFraction = 0
			continue
		}

		// How far along this segment we already are.
		currentDist := hm.WaypointFraction * segLen
		remaining := segLen - currentDist
		arrivalRadius := tuning.Get().TownArrivalRadius
		if arrivalRadius < 0 {
			arrivalRadius = 0
		}

		if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) {
			// Move to next waypoint.
			distThisTick -= remaining
			hm.WaypointIndex++
			hm.WaypointFraction = 0
			if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
				// Reached final waypoint = destination town.
				last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1]
				hm.CurrentX = last.X
				hm.CurrentY = last.Y
				return true
			}
		} else {
			// Partial advance within this segment.
			hm.WaypointFraction = (currentDist + distThisTick) / segLen
			hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
			hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
			distThisTick = 0
		}
	}

	// Update position to the current waypoint position.
	if hm.WaypointIndex < len(hm.Road.Waypoints) {
		wp := hm.Road.Waypoints[hm.WaypointIndex]
		if hm.WaypointFraction == 0 {
			hm.CurrentX = wp.X
			hm.CurrentY = wp.Y
		}
	}

	return false
}

// Heading returns the angle (radians) the hero is currently facing.
func (hm *HeroMovement) Heading() float64 {
	if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
		return 0
	}
	to := hm.Road.Waypoints[hm.WaypointIndex+1]
	return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX)
}

// TargetPoint returns the next waypoint the hero is heading toward.
func (hm *HeroMovement) TargetPoint() (float64, float64) {
	if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
		return hm.CurrentX, hm.CurrentY
	}
	wp := hm.Road.Waypoints[hm.WaypointIndex+1]
	return wp.X, wp.Y
}

func (hm *HeroMovement) applyTownRestHeal(dt float64) {
	if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
		return
	}
	cfg := tuning.Get()
	rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder
	gain := int(math.Floor(rawGain))
	hm.TownRestHealRemainder = rawGain - float64(gain)
	if gain <= 0 {
		return
	}
	hm.Hero.HP += gain
	if hm.Hero.HP > hm.Hero.MaxHP {
		hm.Hero.HP = hm.Hero.MaxHP
	}
}

// AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest).
func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error {
	if graph == nil || townID == 0 {
		return fmt.Errorf("invalid town")
	}
	if _, ok := graph.Towns[townID]; !ok {
		return fmt.Errorf("unknown town")
	}
	hm.Road = nil
	hm.WaypointIndex = 0
	hm.WaypointFraction = 0
	hm.DestinationTownID = townID
	hm.spawnAtRoadStart = false
	hm.WanderingMerchantDeadline = time.Time{}
	hm.TownVisitNPCName = ""
	hm.TownVisitNPCType = ""
	hm.TownVisitStartedAt = time.Time{}
	hm.TownVisitLogsEmitted = 0
	hm.TownRestHealRemainder = 0
	hm.Excursion = model.ExcursionSession{}
	hm.ActiveRestKind = model.RestKindNone
	hm.RestHealRemainder = 0
	t := graph.Towns[townID]
	hm.CurrentX = t.WorldX
	hm.CurrentY = t.WorldY
	hm.EnterTown(now, graph)
	return nil
}

// AdminStartRoadsideRest forces the hero into roadside rest on the current road segment.
func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
	if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
		return false
	}
	if hm.State == model.StateFighting {
		return false
	}
	hm.WanderingMerchantDeadline = time.Time{}
	hm.beginRoadsideRest(now)
	return true
}

// AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road.
// Cooldown and random roll are bypassed; the hero must not already be in an excursion.
func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
	if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
		return false
	}
	if hm.State == model.StateFighting {
		return false
	}
	if hm.State != model.StateWalking {
		return false
	}
	if hm.Excursion.Active() {
		return false
	}
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return false
	}
	hm.WanderingMerchantDeadline = time.Time{}
	hm.beginExcursion(now)
	return true
}

// AdminStopExcursion ends an active excursion immediately (hero back on the road spine).
// Works during walking phases or adventure-inline rest; rejects combat.
func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
	if !hm.Excursion.Active() {
		return false
	}
	if hm.State == model.StateFighting {
		return false
	}
	if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline {
		hm.ActiveRestKind = model.RestKindNone
		hm.RestUntil = time.Time{}
		hm.RestHealRemainder = 0
		hm.State = model.StateWalking
		hm.Hero.State = model.StateWalking
	}
	hm.endExcursion(now)
	hm.refreshSpeed(now)
	return true
}

// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
func (hm *HeroMovement) AdminStopRest(now time.Time) bool {
	if hm.State != model.StateResting {
		return false
	}
	if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline {
		return false
	}
	if hm.ActiveRestKind == model.RestKindAdventureInline && hm.Excursion.Active() {
		hm.endExcursion(now)
	}
	hm.ActiveRestKind = model.RestKindNone
	hm.RestUntil = time.Time{}
	hm.RestHealRemainder = 0
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking
	hm.refreshSpeed(now)
	return true
}

// AdminStartRest forces a resting period (same duration model as town rest).
func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
	if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
		return false
	}
	if hm.State == model.StateFighting {
		return false
	}
	hm.WanderingMerchantDeadline = time.Time{}
	hm.TownNPCQueue = nil
	hm.NextTownNPCRollAt = time.Time{}
	hm.TownVisitNPCName = ""
	hm.TownVisitNPCType = ""
	hm.TownVisitStartedAt = time.Time{}
	hm.TownVisitLogsEmitted = 0
	hm.TownRestHealRemainder = 0
	if graph != nil && hm.CurrentTownID == 0 {
		hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
	}
	hm.State = model.StateResting
	hm.Hero.State = model.StateResting
	hm.ActiveRestKind = model.RestKindTown
	hm.RestUntil = now.Add(randomRestDuration())
	return true
}

func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return 0, 1
	}
	idx := hm.WaypointIndex
	if idx >= len(hm.Road.Waypoints)-1 {
		idx = len(hm.Road.Waypoints) - 2
	}
	if idx < 0 {
		return 0, 1
	}
	from := hm.Road.Waypoints[idx]
	to := hm.Road.Waypoints[idx+1]
	dx := to.X - from.X
	dy := to.Y - from.Y
	L := math.Hypot(dx, dy)
	if L < 1e-6 {
		return 0, 1
	}
	return -dy / L, dx / L
}

// roadForwardUnit is the normalized tangent along the road toward the next waypoint.
func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return 1, 0
	}
	idx := hm.WaypointIndex
	if idx >= len(hm.Road.Waypoints)-1 {
		idx = len(hm.Road.Waypoints) - 2
	}
	if idx < 0 {
		return 1, 0
	}
	from := hm.Road.Waypoints[idx]
	to := hm.Road.Waypoints[idx+1]
	dx := to.X - from.X
	dy := to.Y - from.Y
	L := math.Hypot(dx, dy)
	if L < 1e-6 {
		return 1, 0
	}
	return dx / L, dy / L
}

func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
	exc := &hm.Excursion
	if exc.Active() {
		cfg := tuning.Get()
		perpX, perpY := hm.roadPerpendicularUnit()
		depth := exc.DepthWorldUnits
		var t float64
		switch exc.Phase {
		case model.ExcursionOut:
			outMs := float64(cfg.AdventureOutDurationMs)
			if outMs > 0 {
				elapsed := float64(now.Sub(exc.StartedAt).Milliseconds())
				t = smoothstep(clamp01(elapsed / outMs))
			}
		case model.ExcursionWild:
			t = 1.0
		case model.ExcursionReturn:
			retMs := float64(cfg.AdventureReturnDurationMs)
			if retMs > 0 {
				returnStart := exc.ReturnUntil.Add(-time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
				elapsed := float64(now.Sub(returnStart).Milliseconds())
				t = 1.0 - smoothstep(clamp01(elapsed / retMs))
			}
		}
		d := depth * t
		return perpX * d, perpY * d
	}

	if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
		perpX, perpY := hm.roadPerpendicularUnit()
		const roadsideDepth = 2.0
		return perpX * roadsideDepth, perpY * roadsideDepth
	}

	return 0, 0
}

// WanderingMerchantCost matches REST encounter / npc alms pricing.
func WanderingMerchantCost(level int) int64 {
	cfg := tuning.Get()
	return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel
}

// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
	cfg := tuning.Get()
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return false, model.Enemy{}, false
	}
	if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
		return false, model.Enemy{}, false
	}
	if rand.Float64() >= cfg.EncounterActivityBase {
		return false, model.Enemy{}, false
	}
	monsterW := cfg.MonsterEncounterWeightBase
	merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
	total := monsterW + merchantW
	r := rand.Float64() * total
	if r < monsterW {
		e := PickEnemyForLevel(hm.Hero.Level)
		return true, e, true
	}
	return false, model.Enemy{}, true
}

// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
	destID := hm.DestinationTownID
	hm.CurrentTownID = destID
	hm.DestinationTownID = 0
	hm.Road = nil
	hm.TownNPCQueue = nil
	hm.NextTownNPCRollAt = time.Time{}
	hm.TownVisitNPCName = ""
	hm.TownVisitNPCType = ""
	hm.TownVisitStartedAt = time.Time{}
	hm.TownVisitLogsEmitted = 0
	hm.TownLeaveAt = time.Time{}
	hm.TownRestHealRemainder = 0
	hm.Excursion = model.ExcursionSession{}
	hm.ActiveRestKind = model.RestKindNone
	hm.RestHealRemainder = 0

	ids := graph.TownNPCIDs(destID)
	if len(ids) == 0 {
		hm.State = model.StateResting
		hm.Hero.State = model.StateResting
		hm.ActiveRestKind = model.RestKindTown
		hm.RestUntil = now.Add(randomRestDuration())
		return
	}

	q := make([]int64, len(ids))
	copy(q, ids)
	rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
	hm.TownNPCQueue = q
	hm.State = model.StateInTown
	hm.Hero.State = model.StateInTown
	hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
}

// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
	hm.TownNPCQueue = nil
	hm.NextTownNPCRollAt = time.Time{}
	hm.TownVisitNPCName = ""
	hm.TownVisitNPCType = ""
	hm.TownVisitStartedAt = time.Time{}
	hm.TownVisitLogsEmitted = 0
	hm.TownLeaveAt = time.Time{}
	hm.TownRestHealRemainder = 0
	hm.RestUntil = time.Time{}
	hm.ActiveRestKind = model.RestKindNone
	hm.RestHealRemainder = 0
	hm.Excursion = model.ExcursionSession{}
	hm.State = model.StateWalking
	hm.Hero.State = model.StateWalking
	// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
	hm.LastMoveTick = now
	hm.pickDestination(graph)
	hm.assignRoad(graph)
	hm.refreshSpeed(now)
}

func randomTownNPCDelay() time.Duration {
	cfg := tuning.Get()
	minDelay := time.Duration(cfg.TownNPCRollMinMs) * time.Millisecond
	maxDelay := time.Duration(cfg.TownNPCRollMaxMs) * time.Millisecond
	rangeMs := (maxDelay - minDelay).Milliseconds()
	if rangeMs < 0 {
		rangeMs = 0
	}
	return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}

// StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() {
	hm.State = model.StateFighting
	hm.WanderingMerchantDeadline = time.Time{}
}

// ResumWalking resumes movement after combat.
func (hm *HeroMovement) ResumeWalking(now time.Time) {
	hm.State = model.StateWalking
	hm.LastMoveTick = now
	hm.refreshSpeed(now)
}

// Die sets the movement state to dead.
func (hm *HeroMovement) Die() {
	hm.State = model.StateDead
}

// SyncToHero writes movement state back to the hero model for persistence.
// Position uses the same world coordinates as hero_move / position_sync (road spine + display offset).
func (hm *HeroMovement) SyncToHero() {
	now := time.Now()
	ox, oy := hm.displayOffset(now)
	hm.Hero.PositionX = hm.CurrentX + ox
	hm.Hero.PositionY = hm.CurrentY + oy
	hm.Hero.State = hm.State
	if hm.CurrentTownID != 0 {
		id := hm.CurrentTownID
		hm.Hero.CurrentTownID = &id
	} else {
		hm.Hero.CurrentTownID = nil
	}
	if hm.DestinationTownID != 0 {
		id := hm.DestinationTownID
		hm.Hero.DestinationTownID = &id
	} else {
		hm.Hero.DestinationTownID = nil
	}
	hm.Hero.MoveState = string(hm.State)
	hm.Hero.RestKind = model.RestKindNone
	if hm.State == model.StateResting {
		if hm.ActiveRestKind != model.RestKindNone {
			hm.Hero.RestKind = hm.ActiveRestKind
		} else {
			hm.Hero.RestKind = model.RestKindTown
		}
	}
	hm.Hero.TownPause = hm.townPauseBlob()
}

func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
	var p *model.TownPausePersisted

	switch hm.State {
	case model.StateResting:
		rk := model.RestKindTown
		if hm.ActiveRestKind != model.RestKindNone {
			rk = hm.ActiveRestKind
		}
		if rk == model.RestKindTown && hm.RestUntil.IsZero() {
			break
		}
		p = &model.TownPausePersisted{
			RestKind:              rk,
			TownRestHealRemainder: hm.TownRestHealRemainder,
			RestHealRemainder:     hm.RestHealRemainder,
		}
		if !hm.RestUntil.IsZero() {
			t := hm.RestUntil
			p.RestUntil = &t
		}
	case model.StateInTown:
		p = &model.TownPausePersisted{
			TownVisitNPCName:     hm.TownVisitNPCName,
			TownVisitNPCType:     hm.TownVisitNPCType,
			TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
		}
		if len(hm.TownNPCQueue) > 0 {
			p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
		}
		if !hm.NextTownNPCRollAt.IsZero() {
			t := hm.NextTownNPCRollAt
			p.NextTownNPCRollAt = &t
		}
		if !hm.TownLeaveAt.IsZero() {
			t := hm.TownLeaveAt
			p.TownLeaveAt = &t
		}
		if !hm.TownVisitStartedAt.IsZero() {
			t := hm.TownVisitStartedAt
			p.TownVisitStartedAt = &t
		}
	}

	// Persist active excursion session regardless of hero state (the hero can be fighting
	// or resting while an excursion is in progress).
	if hm.Excursion.Active() {
		ep := hm.excursionPersisted()
		if p == nil {
			p = &model.TownPausePersisted{}
		}
		p.Excursion = ep
	}

	return p
}

func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
	s := &hm.Excursion
	ep := &model.ExcursionPersisted{
		Phase:              string(s.Phase),
		DepthWorldUnits:    s.DepthWorldUnits,
		RoadFreezeWaypoint: s.RoadFreezeWaypoint,
		RoadFreezeFraction: s.RoadFreezeFraction,
	}
	if !s.StartedAt.IsZero() {
		t := s.StartedAt
		ep.StartedAt = &t
	}
	if !s.WildUntil.IsZero() {
		t := s.WildUntil
		ep.WildUntil = &t
	}
	if !s.ReturnUntil.IsZero() {
		t := s.ReturnUntil
		ep.ReturnUntil = &t
	}
	return ep
}

func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) {
	blob := hero.TownPause
	switch hero.State {
	case model.StateResting:
		if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) {
			if blob.RestUntil != nil && !blob.RestUntil.IsZero() {
				hm.RestUntil = *blob.RestUntil
			}
			hm.ActiveRestKind = blob.RestKind
			hm.TownRestHealRemainder = blob.TownRestHealRemainder
			hm.RestHealRemainder = blob.RestHealRemainder
		} else {
			// Legacy row without town_pause: treat rest as already elapsed so offline/reconnect unblocks.
			hm.RestUntil = now.Add(-time.Millisecond)
		}
	case model.StateInTown:
		if blob == nil {
			return
		}
		if len(blob.NPCQueue) > 0 {
			hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...)
		}
		if blob.NextTownNPCRollAt != nil {
			hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt
		}
		if blob.TownLeaveAt != nil {
			hm.TownLeaveAt = *blob.TownLeaveAt
		}
		hm.TownVisitNPCName = blob.TownVisitNPCName
		hm.TownVisitNPCType = blob.TownVisitNPCType
		if blob.TownVisitStartedAt != nil {
			hm.TownVisitStartedAt = *blob.TownVisitStartedAt
		}
		hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
	}

	// Restore excursion session from blob (may exist alongside any hero state).
	if blob != nil && blob.Excursion != nil {
		hm.applyExcursionFromBlob(blob.Excursion)
	}
}

func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
	hm.Excursion.Phase = model.ExcursionPhase(ep.Phase)
	if ep.StartedAt != nil {
		hm.Excursion.StartedAt = *ep.StartedAt
	}
	if ep.WildUntil != nil {
		hm.Excursion.WildUntil = *ep.WildUntil
	}
	if ep.ReturnUntil != nil {
		hm.Excursion.ReturnUntil = *ep.ReturnUntil
	}
	hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits
	hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint
	hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction
}

// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload {
	tx, ty := hm.TargetPoint()
	ox, oy := hm.displayOffset(now)
	return model.HeroMovePayload{
		X:       hm.CurrentX + ox,
		Y:       hm.CurrentY + oy,
		TargetX: tx + ox,
		TargetY: ty + oy,
		Speed:   hm.Speed,
		Heading: hm.Heading(),
	}
}

// RoutePayload builds the route_assigned WS payload.
func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload {
	if hm.Road == nil {
		return nil
	}
	waypoints := make([]model.PointXY, len(hm.Road.Waypoints))
	for i, p := range hm.Road.Waypoints {
		waypoints[i] = model.PointXY{X: p.X, Y: p.Y}
	}
	return &model.RouteAssignedPayload{
		RoadID:            hm.Road.ID,
		Waypoints:         waypoints,
		DestinationTownID: hm.DestinationTownID,
		Speed:             hm.Speed,
	}
}

// PositionSyncPayload builds the position_sync WS payload.
func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload {
	ox, oy := hm.displayOffset(now)
	return model.PositionSyncPayload{
		X:                hm.CurrentX + ox,
		Y:                hm.CurrentY + oy,
		WaypointIndex:    hm.WaypointIndex,
		WaypointFraction: hm.WaypointFraction,
		State:            string(hm.State),
	}
}

// randomRestDuration returns a random duration between TownRestMin and TownRestMax.
func randomRestDuration() time.Duration {
	cfg := tuning.Get()
	minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond
	maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond
	rangeMs := (maxDur - minDur).Milliseconds()
	if rangeMs < 0 {
		rangeMs = 0
	}
	return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}

// EncounterStarter starts or resolves a random encounter while walking (engine: combat;
// offline: synchronous SimulateOneFight via callback).
type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)

// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)

// AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func(hero *model.Hero)

func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
	if log == nil || hm.TownVisitStartedAt.IsZero() {
		return
	}
	logInterval := townNPCLogInterval()
	for hm.TownVisitLogsEmitted < townNPCVisitLogLines {
		deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval)
		if now.Before(deadline) {
			break
		}
		msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted)
		if msg != "" {
			log(heroID, msg)
		}
		hm.TownVisitLogsEmitted++
	}
}

func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
	if lineIndex < 0 || lineIndex >= townNPCVisitLogLines {
		return ""
	}
	switch npcType {
	case "merchant":
		switch lineIndex {
		case 0:
			return fmt.Sprintf("You stop at %s's stall.", npcName)
		case 1:
			return "Crates, pouches, and price tags blur together as you browse."
		case 2:
			return fmt.Sprintf("%s points out a few curious trinkets.", npcName)
		case 3:
			return "You weigh a potion against your coin purse."
		case 4:
			return "A short haggle ends in a reluctant nod."
		case 5:
			return fmt.Sprintf("You thank %s and step back from the counter.", npcName)
		}
	case "healer":
		switch lineIndex {
		case 0:
			return fmt.Sprintf("You seek out %s.", npcName)
		case 1:
			return "The healer examines your wounds with a calm eye."
		case 2:
			return "Herbs steam gently; bandages are laid out in neat rows."
		case 3:
			return "A slow warmth spreads as salves are applied."
		case 4:
			return "You rest a moment on a bench, breathing easier."
		case 5:
			return fmt.Sprintf("You nod to %s and return to the street.", npcName)
		}
	case "quest_giver":
		switch lineIndex {
		case 0:
			return fmt.Sprintf("You speak with %s about the road ahead.", npcName)
		case 1:
			return "Rumors of trouble and slim rewards fill the air."
		case 2:
			return "A worn map is smoothed flat between you."
		case 3:
			return "You mark targets and deadlines in your mind."
		case 4:
			return fmt.Sprintf("%s hints at better pay for the bold.", npcName)
		case 5:
			return "You part with a clearer picture of what must be done."
		}
	default:
		switch lineIndex {
		case 0:
			return fmt.Sprintf("You spend time with %s.", npcName)
		case 1:
			return "Conversation drifts from weather to the wider world."
		case 2:
			return "A few practical details stick in your memory."
		case 3:
			return "You listen more than you speak."
		case 4:
			return "Promises and coin change hands—or almost do."
		case 5:
			return fmt.Sprintf("You say farewell to %s.", npcName)
		}
	}
	return ""
}

// --- Excursion (mini-adventure) FSM helpers ---

func smoothstep(t float64) float64 {
	return t * t * (3 - 2*t)
}

func clamp01(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

func (hm *HeroMovement) isLowHP() bool {
	if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 {
		return false
	}
	return float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) < tuning.Get().LowHpThreshold
}

func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
	cfg := tuning.Get()
	if hm.Excursion.Active() {
		return false
	}
	if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
		return false
	}
	cooldown := time.Duration(cfg.AdventureCooldownMs) * time.Millisecond
	if !hm.LastExcursionEndedAt.IsZero() && now.Sub(hm.LastExcursionEndedAt) < cooldown {
		return false
	}
	remaining := len(hm.Road.Waypoints) - 1 - hm.WaypointIndex
	if remaining < 2 {
		return false
	}
	return rand.Float64() < cfg.AdventureStartChance
}

func (hm *HeroMovement) beginExcursion(now time.Time) {
	cfg := tuning.Get()
	hm.Excursion = model.ExcursionSession{
		Phase:              model.ExcursionOut,
		StartedAt:          now,
		DepthWorldUnits:    cfg.AdventureDepthWorldUnits,
		RoadFreezeWaypoint: hm.WaypointIndex,
		RoadFreezeFraction: hm.WaypointFraction,
	}
	outEnd := now.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
	wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs)
	wildEnd := outEnd.Add(wildDur)
	hm.Excursion.WildUntil = wildEnd
	hm.Excursion.ReturnUntil = wildEnd.Add(time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
}

// advanceExcursionPhases progresses through out->wild->return and returns true when complete.
func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) {
	exc := &hm.Excursion
	cfg := tuning.Get()
	if exc.Phase == model.ExcursionOut {
		outEnd := exc.StartedAt.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
		if !now.Before(outEnd) {
			exc.Phase = model.ExcursionWild
		}
	}
	if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) {
		exc.Phase = model.ExcursionReturn
	}
	if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) {
		return true
	}
	return false
}

func (hm *HeroMovement) endExcursion(now time.Time) {
	hm.LastExcursionEndedAt = now
	hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
	hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
	hm.Excursion = model.ExcursionSession{}
	if hm.Road != nil && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
		from := hm.Road.Waypoints[hm.WaypointIndex]
		to := hm.Road.Waypoints[hm.WaypointIndex+1]
		hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
		hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
	}
}

func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
	cfg := tuning.Get()
	hm.State = model.StateResting
	hm.Hero.State = model.StateResting
	hm.ActiveRestKind = model.RestKindRoadside
	hm.RestHealRemainder = 0
	dur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
	hm.RestUntil = now.Add(dur)
}

func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) {
	_ = now
	hm.State = model.StateResting
	hm.Hero.State = model.StateResting
	hm.ActiveRestKind = model.RestKindAdventureInline
	hm.RestHealRemainder = 0
}

func (hm *HeroMovement) applyRestHealTick(dt float64) {
	if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
		return
	}
	cfg := tuning.Get()
	var hpPerS float64
	switch hm.ActiveRestKind {
	case model.RestKindRoadside:
		hpPerS = cfg.RoadsideRestHpPerS
	case model.RestKindAdventureInline:
		hpPerS = cfg.AdventureRestHpPerS
	default:
		return
	}
	rawGain := float64(hm.Hero.MaxHP)*hpPerS*dt + hm.RestHealRemainder
	gain := int(math.Floor(rawGain))
	hm.RestHealRemainder = rawGain - float64(gain)
	if gain <= 0 {
		return
	}
	hm.Hero.HP += gain
	if hm.Hero.HP > hm.Hero.MaxHP {
		hm.Hero.HP = hm.Hero.MaxHP
	}
}

func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
	cfg := tuning.Get()
	cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
	if now.Sub(hm.LastEncounterAt) < cooldown {
		return false, model.Enemy{}, false
	}
	if rand.Float64() >= cfg.EncounterActivityBase {
		return false, model.Enemy{}, false
	}
	monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus
	merchantW := cfg.MerchantEncounterWeightBase
	total := monsterW + merchantW
	r := rand.Float64() * total
	if r < monsterW {
		e := PickEnemyForLevel(hm.Hero.Level)
		return true, e, true
	}
	return false, model.Enemy{}, true
}

func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
	if maxMs <= minMs {
		return time.Duration(minMs) * time.Millisecond
	}
	return time.Duration(minMs+rand.Int63n(maxMs-minMs+1)) * time.Millisecond
}

// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
// It mirrors the online engine's configured movement cadence.
// steps (plus a final partial step to real time) for catch-up simulation.
//
// sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block).
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
func ProcessSingleHeroMovementTick(
	heroID int64,
	hm *HeroMovement,
	graph *RoadGraph,
	now time.Time,
	sender MessageSender,
	onEncounter EncounterStarter,
	onMerchantEncounter MerchantEncounterHook,
	adventureLog AdventureLogWriter,
	persistAfterTownEnter AfterTownEnterPersist,
) {
	if graph == nil {
		return
	}

	switch hm.State {
	case model.StateFighting, model.StateDead:
		return

	case model.StateResting:
		dt := now.Sub(hm.LastMoveTick).Seconds()
		if dt <= 0 {
			dt = movementTickRate().Seconds()
		}
		hm.LastMoveTick = now

		switch hm.ActiveRestKind {
		case model.RestKindRoadside:
			hm.applyRestHealTick(dt)
			cfg := tuning.Get()
			hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
			if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
				hm.ActiveRestKind = model.RestKindNone
				hm.RestUntil = time.Time{}
				hm.RestHealRemainder = 0
				hm.State = model.StateWalking
				hm.Hero.State = model.StateWalking
				hm.refreshSpeed(now)
			}
			hm.SyncToHero()
			if sender != nil && hm.Hero != nil {
				sender.SendToHero(heroID, "hero_state", hm.Hero)
				sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
			}

		case model.RestKindAdventureInline:
			hm.applyRestHealTick(dt)
			excursionEnded := hm.advanceExcursionPhases(now)
			cfg := tuning.Get()
			hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
			if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded {
				if excursionEnded {
					hm.endExcursion(now)
					if sender != nil {
						sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
					}
				}
				hm.ActiveRestKind = model.RestKindNone
				hm.RestHealRemainder = 0
				hm.State = model.StateWalking
				hm.Hero.State = model.StateWalking
				hm.refreshSpeed(now)
			}
			hm.SyncToHero()
			if sender != nil && hm.Hero != nil {
				sender.SendToHero(heroID, "hero_state", hm.Hero)
				sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
			}

		default:
			hm.applyTownRestHeal(dt)
			hm.SyncToHero()
			if sender != nil && hm.Hero != nil {
				sender.SendToHero(heroID, "hero_state", hm.Hero)
				sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
			}
			if now.After(hm.RestUntil) {
				hm.LeaveTown(graph, now)
				hm.SyncToHero()
				if sender != nil {
					sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
					if route := hm.RoutePayload(); route != nil {
						sender.SendToHero(heroID, "route_assigned", route)
					}
				}
			}
		}

	case model.StateInTown:
		cfg := tuning.Get()
		// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
		hm.LastMoveTick = now
		// NPC visit pause ended: clear visit log state before the next roll.
		if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
			hm.TownVisitNPCName = ""
			hm.TownVisitNPCType = ""
			hm.TownVisitStartedAt = time.Time{}
			hm.TownVisitLogsEmitted = 0
		}
		emitTownNPCVisitLogs(heroID, hm, now, adventureLog)

		if len(hm.TownNPCQueue) == 0 {
			if hm.TownLeaveAt.IsZero() {
				hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
			}
			if now.Before(hm.TownLeaveAt) {
				hm.SyncToHero()
				return
			}
			hm.TownLeaveAt = time.Time{}
			hm.LeaveTown(graph, now)
			hm.SyncToHero()
			if sender != nil {
				sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
				if route := hm.RoutePayload(); route != nil {
					sender.SendToHero(heroID, "route_assigned", route)
				}
			}
			return
		}
		if now.Before(hm.NextTownNPCRollAt) {
			hm.SyncToHero()
			return
		}
		if rand.Float64() < cfg.TownNPCVisitChance {
			npcID := hm.TownNPCQueue[0]
			hm.TownNPCQueue = hm.TownNPCQueue[1:]
			if npc, ok := graph.NPCByID[npcID]; ok {
				if sender != nil {
					sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
						NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
					})
				}
				hm.TownVisitNPCName = npc.Name
				hm.TownVisitNPCType = npc.Type
				hm.TownVisitStartedAt = now
				hm.TownVisitLogsEmitted = 0
				if npc.Type == "merchant" {
					share := cfg.MerchantTownAutoSellShare
					if share <= 0 || share > 1 {
						share = tuning.DefaultValues().MerchantTownAutoSellShare
					}
					soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
					if soldItems > 0 && adventureLog != nil {
						adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
					}
				}
				emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
			}
			hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
		} else {
			hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
		}
		hm.SyncToHero()

	case model.StateWalking:
		cfg := tuning.Get()
		hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
		if hadNoRoad {
			hm.Road = nil
			hm.pickDestination(graph)
			hm.assignRoad(graph)
			if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 {
				if route := hm.RoutePayload(); route != nil {
					sender.SendToHero(heroID, "route_assigned", route)
				}
			}
		}

		// Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout.
		if !hm.WanderingMerchantDeadline.IsZero() {
			if !now.Before(hm.WanderingMerchantDeadline) {
				hm.WanderingMerchantDeadline = time.Time{}
				if sender != nil {
					sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"})
				}
			} else {
				hm.LastMoveTick = now
				if sender != nil {
					sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
				}
				if hm.Hero != nil {
					hm.SyncToHero()
				}
				return
			}
		}

		// --- Active excursion (mini-adventure) ---
		if hm.Excursion.Active() {
			prevPhase := hm.Excursion.Phase
			excursionEnded := hm.advanceExcursionPhases(now)
			if excursionEnded {
				hm.endExcursion(now)
				hm.refreshSpeed(now)
				if sender != nil {
					sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
				}
			} else {
				if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil {
					sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)})
				}
				if hm.isLowHP() {
					hm.beginAdventureInlineRest(now)
					hm.SyncToHero()
					if sender != nil && hm.Hero != nil {
						sender.SendToHero(heroID, "hero_state", hm.Hero)
						sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
					}
					return
				}
				canEncounter := hm.Excursion.Phase == model.ExcursionWild ||
					(hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled)
				if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) {
					monster, enemy, hit := hm.rollAdventureEncounter(now)
					if hit {
						if monster && onEncounter != nil {
							hm.LastEncounterAt = now
							onEncounter(hm, &enemy, now)
							return
						}
						if !monster {
							cost := WanderingMerchantCost(hm.Hero.Level)
							hm.LastEncounterAt = now
							if sender != nil {
								hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
								sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
									NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost,
								})
							}
							if onMerchantEncounter != nil {
								onMerchantEncounter(hm, now, cost)
							}
							return
						}
					}
				}
				hm.LastMoveTick = now
				if sender != nil {
					sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
				}
				hm.SyncToHero()
				return
			}
		}

		// --- Normal walking (no active excursion) ---
		reachedTown := hm.AdvanceTick(now, graph)

		if reachedTown {
			hm.EnterTown(now, graph)

			if sender != nil {
				town := graph.Towns[hm.CurrentTownID]
				if town != nil {
					npcInfos := graph.TownNPCInfos(hm.CurrentTownID)
					buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID)
					var restMs int64
					if hm.State == model.StateResting {
						restMs = hm.RestUntil.Sub(now).Milliseconds()
					}
					sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{
						TownID:         town.ID,
						TownName:       town.Name,
						Biome:          town.Biome,
						NPCs:           npcInfos,
						Buildings:      buildingInfos,
						RestDurationMs: restMs,
					})
				}
			}

			hm.SyncToHero()
			if persistAfterTownEnter != nil {
				persistAfterTownEnter(hm.Hero)
			}
			return
		}

		if hm.isLowHP() {
			hm.beginRoadsideRest(now)
			hm.SyncToHero()
			if sender != nil && hm.Hero != nil {
				sender.SendToHero(heroID, "hero_state", hm.Hero)
				sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
			}
			return
		}

		canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
		if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
			monster, enemy, hit := hm.rollRoadEncounter(now)
			if hit {
				if monster {
					if onEncounter != nil {
						hm.LastEncounterAt = now
						onEncounter(hm, &enemy, now)
						return
					}
				} else {
					cost := WanderingMerchantCost(hm.Hero.Level)
					if sender != nil || onMerchantEncounter != nil {
						hm.LastEncounterAt = now
						if sender != nil {
							hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
							sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
								NPCID:   0,
								NPCName: "Wandering Merchant",
								Role:    "alms",
								Cost:    cost,
							})
						}
						if onMerchantEncounter != nil {
							onMerchantEncounter(hm, now, cost)
						}
						return
					}
				}
			}
		}

		if hm.mayStartExcursion(now) {
			hm.beginExcursion(now)
			if sender != nil {
				sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{
					DepthWorldUnits: hm.Excursion.DepthWorldUnits,
				})
			}
			hm.SyncToHero()
			return
		}

		if sender != nil {
			sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
		}

		hm.SyncToHero()
	}
}
