package game

import (
	"context"
	"encoding/json"
	"fmt"
	"math"
	"math/rand"
	"time"
	"unicode/utf8"

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

func heroMeetOrderedPair(a, b int64) (lo, hi int64) {
	if a < b {
		return a, b
	}
	return b, a
}

func (e *Engine) heroMeetAnySubscriberOnline(a, b int64) bool {
	if e.heroSubscriber == nil {
		return false
	}
	return e.heroSubscriber(a) || e.heroSubscriber(b)
}

// heroMeetPlayerOnline is true when this hero has an active WebSocket (player connected).
func (e *Engine) heroMeetPlayerOnline(heroID int64) bool {
	if e.heroSubscriber == nil {
		return false
	}
	return e.heroSubscriber(heroID)
}

func (e *Engine) copyExcursionHeroMeetFields(dst, src *model.ExcursionSession) {
	if dst == nil || src == nil {
		return
	}
	dst.HeroMeetPartnerID = src.HeroMeetPartnerID
	dst.HeroMeetSubPhase = src.HeroMeetSubPhase
	dst.HeroMeetPromptUntil = src.HeroMeetPromptUntil
	dst.HeroMeetNextAutoAt = src.HeroMeetNextAutoAt
	dst.HeroMeetTurnHeroID = src.HeroMeetTurnHeroID
	dst.HeroMeetHadPlayerMessage = src.HeroMeetHadPlayerMessage
	dst.HeroMeetOfflineDeadline = src.HeroMeetOfflineDeadline
	dst.HeroMeetOfflineTimerRunning = src.HeroMeetOfflineTimerRunning
	dst.HeroMeetOfflineRemainingMs = src.HeroMeetOfflineRemainingMs
	dst.HeroMeetAutoLineIdx = src.HeroMeetAutoLineIdx
	dst.HeroMeetAnchorX = src.HeroMeetAnchorX
	dst.HeroMeetAnchorY = src.HeroMeetAnchorY
	dst.Phase = src.Phase
}

func (e *Engine) syncHeroMeetPartnerExcursion(leader *HeroMovement, partner *HeroMovement) {
	if leader == nil || partner == nil {
		return
	}
	lid := leader.HeroID
	e.copyExcursionHeroMeetFields(&partner.Excursion, &leader.Excursion)
	partner.Excursion.HeroMeetPartnerID = lid
}

func heroMeetHeroNearAttractor(hm *HeroMovement) bool {
	if hm == nil || !hm.Excursion.AttractorSet {
		return false
	}
	eps := ExcursionArrivalEpsilonWorld()
	dx := hm.Excursion.AttractorX - hm.CurrentX
	dy := hm.Excursion.AttractorY - hm.CurrentY
	return math.Hypot(dx, dy) <= eps
}

func (e *Engine) transitionHeroMeetDialogueTimersLocked(lo, hi int64, now time.Time) {
	leader := e.movements[lo]
	partner := e.movements[hi]
	if leader == nil || partner == nil {
		return
	}
	ex := &leader.Excursion
	cfg := tuning.Get()
	anyOnline := e.heroMeetAnySubscriberOnline(lo, hi)
	offlineBudget := time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond
	if offlineBudget <= 0 {
		offlineBudget = randomDurationBetweenMs(240_000, 360_000)
		ex.HeroMeetOfflineRemainingMs = offlineBudget.Milliseconds()
	}
	promptMs := cfg.HeroMeetPromptWindowMs
	if promptMs <= 0 {
		promptMs = tuning.DefaultValues().HeroMeetPromptWindowMs
	}
	autoInt := cfg.HeroMeetAutoLineIntervalMs
	if autoInt <= 0 {
		autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs
	}
	if anyOnline {
		ex.HeroMeetSubPhase = model.HeroMeetSubPrompt
		ex.HeroMeetPromptUntil = now.Add(time.Duration(promptMs) * time.Millisecond)
		ex.HeroMeetOfflineTimerRunning = false
		ex.HeroMeetNextAutoAt = ex.HeroMeetPromptUntil
	} else {
		ex.HeroMeetSubPhase = model.HeroMeetSubAuto
		ex.HeroMeetOfflineTimerRunning = true
		ex.HeroMeetOfflineDeadline = now.Add(offlineBudget)
		ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond)
	}
	e.syncHeroMeetPartnerExcursion(leader, partner)
}

// checkHeroMeetApproachArrivalLocked promotes out → meet when both heroes reach approach attractors.
func (e *Engine) checkHeroMeetApproachArrivalLocked(now time.Time) {
	seen := make(map[string]struct{})
	for id, hm := range e.movements {
		if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
			continue
		}
		if hm.Excursion.Phase != model.ExcursionOut {
			continue
		}
		pid := hm.Excursion.HeroMeetPartnerID
		if pid == 0 {
			continue
		}
		lo, hi := heroMeetOrderedPair(id, pid)
		key := fmt.Sprintf("%d_%d", lo, hi)
		if _, dup := seen[key]; dup {
			continue
		}
		seen[key] = struct{}{}
		a := e.movements[lo]
		b := e.movements[hi]
		if a == nil || b == nil {
			continue
		}
		if !heroMeetHeroNearAttractor(a) || !heroMeetHeroNearAttractor(b) {
			continue
		}
		a.Excursion.Phase = model.ExcursionPhaseHeroMeet
		b.Excursion.Phase = model.ExcursionPhaseHeroMeet
		a.Excursion.AttractorSet = false
		b.Excursion.AttractorSet = false
		e.transitionHeroMeetDialogueTimersLocked(lo, hi, now)
		cfg := tuning.Get()
		linger := cfg.HeroMeetPartnerLingerMs
		if linger <= 0 {
			linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
		}
		e.pushHeroMeetStartLocked(lo, linger, "meet")
		e.pushHeroMeetStartLocked(hi, linger, "meet")
		e.persistHeroPairAfterMeetChangeLocked(lo, hi)
	}
}

func heroMeetHeroNearReturnPoint(hm *HeroMovement) bool {
	if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || hm.Excursion.Phase != model.ExcursionReturn {
		return false
	}
	eps := ExcursionArrivalEpsilonWorld()
	dx := hm.Excursion.StartX - hm.CurrentX
	dy := hm.Excursion.StartY - hm.CurrentY
	return math.Hypot(dx, dy) <= eps
}

// checkHeroMeetReturnArrivalLocked clears the meet when both heroes return to pre-meet positions.
func (e *Engine) checkHeroMeetReturnArrivalLocked(now time.Time) {
	seen := make(map[string]struct{})
	for id, hm := range e.movements {
		if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
			continue
		}
		if hm.Excursion.Phase != model.ExcursionReturn {
			continue
		}
		pid := hm.Excursion.HeroMeetPartnerID
		if pid == 0 {
			continue
		}
		lo, hi := heroMeetOrderedPair(id, pid)
		key := fmt.Sprintf("%d_%d", lo, hi)
		if _, dup := seen[key]; dup {
			continue
		}
		seen[key] = struct{}{}
		a := e.movements[lo]
		b := e.movements[hi]
		if a == nil || b == nil {
			continue
		}
		if !heroMeetHeroNearReturnPoint(a) || !heroMeetHeroNearReturnPoint(b) {
			continue
		}
		for _, hm2 := range []*HeroMovement{a, b} {
			hm2.clearHeroMeetResumeWalking(now)
			hm2.SyncToHero()
			if e.sender != nil && hm2.Hero != nil {
				hm2.Hero.EnsureGearMap()
				hm2.Hero.RefreshDerivedCombatStats(now)
				e.sender.SendToHero(hm2.HeroID, "hero_state", hm2.Hero)
				e.sender.SendToHero(hm2.HeroID, "hero_move", hm2.MovePayload(now))
				if route := hm2.RoutePayload(); route != nil {
					e.sender.SendToHero(hm2.HeroID, "route_assigned", route)
				}
			}
		}
		if e.heroStore != nil {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			if a.Hero != nil {
				_ = e.heroStore.Save(ctx, a.Hero)
			}
			if b.Hero != nil {
				_ = e.heroStore.Save(ctx, b.Hero)
			}
			cancel()
		}
	}
}

// teleportHeroTowardWorldPoint moves hm to lie `sep` world units from (tx,ty) along the segment toward hm (only if farther than sep).
func teleportHeroTowardWorldPoint(hm *HeroMovement, tx, ty, sep float64, heroStore *storage.HeroStore) {
	if hm == nil || sep <= 0 {
		return
	}
	ox, oy := hm.CurrentX, hm.CurrentY
	vx, vy := ox-tx, oy-ty
	d := math.Hypot(vx, vy)
	if d < 1e-6 {
		return
	}
	if d <= sep {
		return
	}
	scale := sep / d
	hm.CurrentX = tx + vx*scale
	hm.CurrentY = ty + vy*scale
	hm.SyncToHero()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := heroStore.Save(ctx, hm.Hero); err != nil {
		return
	}
}

// BeginHeroMeetPairLocked starts a meet between two resident heroes. Caller must hold e.mu.
// On failure, reason is a stable code for logs and admin API (not shown to players).
func (e *Engine) BeginHeroMeetPairLocked(now time.Time, idA, idB int64) (ok bool, reason string) {
	if e.roadGraph == nil {
		return false, "road_graph_nil"
	}
	if idA == idB {
		return false, "same_hero"
	}
	ha, okA := e.movements[idA]
	hb, okB := e.movements[idB]
	if !okA || !okB || ha == nil || hb == nil || ha.Hero == nil || hb.Hero == nil {
		return false, "movement_or_hero_nil"
	}
	if _, inA := e.combats[idA]; inA {
		return false, "hero_a_in_combat"
	}
	if _, inB := e.combats[idB]; inB {
		return false, "hero_b_in_combat"
	}
	if ha.State != model.StateWalking || hb.State != model.StateWalking {
		return false, "not_both_walking"
	}
	if ha.Excursion.Active() || hb.Excursion.Active() {
		return false, "excursion_already_active"
	}
	if !ha.WanderingMerchantDeadline.IsZero() || !hb.WanderingMerchantDeadline.IsZero() {
		return false, "wandering_merchant_pending"
	}
	x1, y1 := ha.worldPositionAt(now)
	x2, y2 := hb.worldPositionAt(now)
	if e.roadGraph.HeroInTownAt(x1, y1) || e.roadGraph.HeroInTownAt(x2, y2) {
		return false, "hero_position_in_town_radius"
	}

	cfg := tuning.Get()
	offlineBudget := randomDurationBetweenMs(240_000, 360_000)
	offlineMs := offlineBudget.Milliseconds()
	if offlineMs < 0 {
		offlineMs = 0
	}

	anchorX := (x1 + x2) / 2
	anchorY := (y1 + y2) / 2
	off := cfg.HeroMeetStandHalfOffsetWorld
	if off <= 0 {
		off = tuning.DefaultValues().HeroMeetStandHalfOffsetWorld
	}
	ax, ay := anchorX-off, anchorY
	bx, by := anchorX+off, anchorY

	lo, _ := heroMeetOrderedPair(idA, idB)
	leader := ha
	follower := hb
	if idA != lo {
		leader, follower = hb, ha
	}

	base := model.ExcursionSession{
		Kind:                       model.ExcursionKindHeroMeet,
		Phase:                      model.ExcursionOut,
		StartedAt:                  now,
		HeroMeetAnchorX:            anchorX,
		HeroMeetAnchorY:            anchorY,
		HeroMeetTurnHeroID:         lo,
		HeroMeetAutoLineIdx:        0,
		HeroMeetOfflineRemainingMs: offlineMs,
	}

	leader.Excursion = base
	leader.Excursion.HeroMeetPartnerID = follower.HeroID
	leader.Excursion.RoadFreezeWaypoint = leader.WaypointIndex
	leader.Excursion.RoadFreezeFraction = leader.WaypointFraction
	leader.Excursion.StartX = leader.CurrentX
	leader.Excursion.StartY = leader.CurrentY
	if leader.HeroID == idA {
		leader.Excursion.AttractorX, leader.Excursion.AttractorY = ax, ay
	} else {
		leader.Excursion.AttractorX, leader.Excursion.AttractorY = bx, by
	}
	leader.Excursion.AttractorSet = true

	follower.Excursion = base
	follower.Excursion.HeroMeetPartnerID = leader.HeroID
	follower.Excursion.RoadFreezeWaypoint = follower.WaypointIndex
	follower.Excursion.RoadFreezeFraction = follower.WaypointFraction
	follower.Excursion.StartX = follower.CurrentX
	follower.Excursion.StartY = follower.CurrentY
	if follower.HeroID == idA {
		follower.Excursion.AttractorX, follower.Excursion.AttractorY = ax, ay
	} else {
		follower.Excursion.AttractorX, follower.Excursion.AttractorY = bx, by
	}
	follower.Excursion.AttractorSet = true

	e.syncHeroMeetPartnerExcursion(leader, follower)

	e.heroMeetLastRoll[idA] = now
	e.heroMeetLastRoll[idB] = now

	e.persistHeroPairAfterMeetChangeLocked(idA, idB)

	lingerOut := cfg.HeroMeetPartnerLingerMs
	if lingerOut <= 0 {
		lingerOut = tuning.DefaultValues().HeroMeetPartnerLingerMs
	}
	e.pushHeroMeetStartLocked(idA, lingerOut, "out")
	e.pushHeroMeetStartLocked(idB, lingerOut, "out")
	return true, ""
}

// pushHeroMeetStartLocked sends hero_meet_start. meetPhase is "out" (approach) or "meet" (dialogue).
func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase string) {
	hm, ok := e.movements[heroID]
	if !ok || hm == nil || hm.Hero == nil || e.sender == nil {
		return
	}
	if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
		return
	}
	switch meetPhase {
	case "out":
		if hm.Excursion.Phase != model.ExcursionOut {
			return
		}
	case "meet":
		if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet {
			return
		}
	default:
		return
	}
	pid := hm.Excursion.HeroMeetPartnerID
	ph, okp := e.movements[pid]
	if !okp || ph == nil || ph.Hero == nil {
		return
	}
	// Frozen meet stand: sync partner model so snapshot matches DB / hero_move (no drift during session).
	ph.SyncToHero()
	hm.SyncToHero()
	px, py := ph.Hero.PositionX, ph.Hero.PositionY
	partner := model.HeroMeetPartnerSnapshot{
		ID:           ph.Hero.ID,
		Name:         ph.Hero.Name,
		Level:        ph.Hero.Level,
		ModelVariant: ph.Hero.ModelVariant,
		PositionX:    px,
		PositionY:    py,
	}
	anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid)
	var promptEnds *time.Time
	if meetPhase == "meet" && anyOnline && hm.Excursion.HeroMeetSubPhase == model.HeroMeetSubPrompt {
		t := hm.Excursion.HeroMeetPromptUntil
		promptEnds = &t
	}
	hm.Hero.EnsureGearMap()
	hm.Hero.RefreshDerivedCombatStats(time.Now())
	e.sender.SendToHero(heroID, "hero_state", hm.Hero)
	e.sender.SendToHero(heroID, "hero_meet_start", model.HeroMeetStartPayload{
		Partner:         partner,
		AnySideOnline:   anyOnline,
		PromptEndsAt:    promptEnds,
		PartnerLingerMs: lingerMs,
		MeetPhase:       meetPhase,
	})
}

func (e *Engine) persistHeroPairAfterMeetChangeLocked(a, b int64) {
	if e.heroStore == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	for _, id := range []int64{a, b} {
		hm := e.movements[id]
		if hm == nil || hm.Hero == nil {
			continue
		}
		hm.SyncToHero()
		_ = e.heroStore.Save(ctx, hm.Hero)
	}
}

// EndHeroMeetPairLocked ends the session for both heroes. Caller must hold e.mu.
// user_end while in dialogue starts a return walk to pre-meet positions; abrupt reasons snap back to the road immediately.
func (e *Engine) EndHeroMeetPairLocked(lo, hi int64, now time.Time, reason string) {
	ha, okA := e.movements[lo]
	hb, okB := e.movements[hi]
	if !okA || !okB {
		return
	}
	cfg := tuning.Get()
	linger := cfg.HeroMeetPartnerLingerMs
	if linger <= 0 {
		linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
	}
	endPayload := model.HeroMeetEndPayload{Reason: reason, PartnerLingerMs: linger}

	userEndEarly := reason == "user_end" && ha.Excursion.Kind == model.ExcursionKindHeroMeet &&
		(ha.Excursion.Phase != model.ExcursionPhaseHeroMeet || hb.Excursion.Phase != model.ExcursionPhaseHeroMeet)
	abruptEnd := reason == "partner_gone" || reason == "offline_timer" || userEndEarly

	if abruptEnd {
		for _, hm := range []*HeroMovement{ha, hb} {
			if hm == nil || hm.Hero == nil {
				continue
			}
			if hm.Excursion.Kind != model.ExcursionKindHeroMeet {
				continue
			}
			hm.clearHeroMeetResumeWalking(now)
			hm.SyncToHero()
			if e.sender != nil {
				hm.Hero.EnsureGearMap()
				hm.Hero.RefreshDerivedCombatStats(now)
				e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload)
				e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero)
				e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now))
				if route := hm.RoutePayload(); route != nil {
					e.sender.SendToHero(hm.HeroID, "route_assigned", route)
				}
			}
		}
		if e.heroStore != nil {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			_ = e.heroStore.Save(ctx, ha.Hero)
			_ = e.heroStore.Save(ctx, hb.Hero)
		}
		return
	}

	for _, hm := range []*HeroMovement{ha, hb} {
		if hm == nil || hm.Hero == nil {
			continue
		}
		if hm.Excursion.Kind != model.ExcursionKindHeroMeet {
			continue
		}
		hm.Excursion.Phase = model.ExcursionReturn
		hm.Excursion.AttractorX = hm.Excursion.StartX
		hm.Excursion.AttractorY = hm.Excursion.StartY
		hm.Excursion.AttractorSet = true
		hm.SyncToHero()
		if e.sender != nil {
			hm.Hero.EnsureGearMap()
			hm.Hero.RefreshDerivedCombatStats(now)
			e.sender.SendToHero(hm.HeroID, "hero_meet_end", endPayload)
			e.sender.SendToHero(hm.HeroID, "hero_state", hm.Hero)
			e.sender.SendToHero(hm.HeroID, "hero_move", hm.MovePayload(now))
			if route := hm.RoutePayload(); route != nil {
				e.sender.SendToHero(hm.HeroID, "route_assigned", route)
			}
		}
	}
	if la, lb := e.movements[lo], e.movements[hi]; la != nil && lb != nil {
		e.syncHeroMeetPartnerExcursion(la, lb)
	}
	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = e.heroStore.Save(ctx, ha.Hero)
		_ = e.heroStore.Save(ctx, hb.Hero)
	}
}

func (e *Engine) tryRandomHeroMeetProximityLocked(now time.Time) {
	if e.roadGraph == nil || len(e.movements) < 2 {
		return
	}
	cfg := tuning.Get()
	radius := cfg.HeroMeetRadiusWorld
	if radius <= 0 {
		radius = tuning.DefaultValues().HeroMeetRadiusWorld
	}
	ch := cfg.HeroMeetChancePerTick
	if ch <= 0 {
		ch = tuning.DefaultValues().HeroMeetChancePerTick
	}
	cd := time.Duration(cfg.HeroMeetCooldownMs) * time.Millisecond
	if cd <= 0 {
		cd = time.Duration(tuning.DefaultValues().HeroMeetCooldownMs) * time.Millisecond
	}

	ids := make([]int64, 0, len(e.movements))
	for id := range e.movements {
		ids = append(ids, id)
	}
	for i := 0; i < len(ids); i++ {
		for j := i + 1; j < len(ids); j++ {
			a, b := ids[i], ids[j]
			lo, hi := heroMeetOrderedPair(a, b)
			ha := e.movements[lo]
			hb := e.movements[hi]
			if ha == nil || hb == nil {
				continue
			}
			if now.Sub(e.heroMeetLastRoll[lo]) < cd || now.Sub(e.heroMeetLastRoll[hi]) < cd {
				continue
			}
			x1, y1 := ha.worldPositionAt(now)
			x2, y2 := hb.worldPositionAt(now)
			dx, dy := x1-x2, y1-y2
			if dx*dx+dy*dy > radius*radius {
				continue
			}
			if rand.Float64() >= ch {
				continue
			}
			if started, _ := e.BeginHeroMeetPairLocked(now, lo, hi); started {
				return
			}
		}
	}
}

func (e *Engine) processHeroMeetTickLocked(now time.Time) {
	if len(e.movements) < 2 {
		return
	}
	seen := make(map[string]struct{})
	for id, hm := range e.movements {
		if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
			continue
		}
		partnerID := hm.Excursion.HeroMeetPartnerID
		if partnerID == 0 {
			continue
		}
		lo, hi := heroMeetOrderedPair(id, partnerID)
		key := fmt.Sprintf("%d_%d", lo, hi)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}

		leader := e.movements[lo]
		partner := e.movements[hi]
		if leader == nil || partner == nil {
			e.EndHeroMeetPairLocked(lo, hi, now, "partner_gone")
			continue
		}

		ex := &leader.Excursion
		if ex.Phase != model.ExcursionPhaseHeroMeet {
			e.syncHeroMeetPartnerExcursion(leader, partner)
			continue
		}

		anyOnline := e.heroMeetAnySubscriberOnline(lo, hi)
		cfg := tuning.Get()
		autoInt := cfg.HeroMeetAutoLineIntervalMs
		if autoInt <= 0 {
			autoInt = tuning.DefaultValues().HeroMeetAutoLineIntervalMs
		}

		// Offline wall timer
		if anyOnline {
			if ex.HeroMeetOfflineTimerRunning {
				rem := ex.HeroMeetOfflineDeadline.Sub(now)
				if rem < 0 {
					rem = 0
				}
				ex.HeroMeetOfflineRemainingMs = rem.Milliseconds()
				ex.HeroMeetOfflineTimerRunning = false
			}
		} else {
			if !ex.HeroMeetOfflineTimerRunning {
				ex.HeroMeetOfflineTimerRunning = true
				ex.HeroMeetOfflineDeadline = now.Add(time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond)
			}
			if now.After(ex.HeroMeetOfflineDeadline) {
				e.EndHeroMeetPairLocked(lo, hi, now, "offline_timer")
				continue
			}
		}

		// Scripted auto lines: off if both players have WS; both offline → alternate; one offline → only that hero speaks.
		loOn := e.heroMeetPlayerOnline(lo)
		hiOn := e.heroMeetPlayerOnline(hi)
		emitAuto := !(loOn && hiOn)
		if emitAuto && !now.Before(ex.HeroMeetNextAutoAt) {
			e.emitHeroMeetAutoLineLocked(lo, hi, now)
			ex.HeroMeetNextAutoAt = now.Add(time.Duration(autoInt) * time.Millisecond)
		}

		e.syncHeroMeetPartnerExcursion(leader, partner)
	}
}

func (e *Engine) emitHeroMeetAutoLineLocked(lo, hi int64, now time.Time) {
	leader := e.movements[lo]
	if leader == nil || leader.Hero == nil {
		return
	}
	if leader.Excursion.Phase != model.ExcursionPhaseHeroMeet {
		return
	}
	ex := &leader.Excursion
	loOn := e.heroMeetPlayerOnline(lo)
	hiOn := e.heroMeetPlayerOnline(hi)
	if loOn && hiOn {
		return
	}

	var speakerID int64
	if !loOn && !hiOn {
		speakerID = ex.HeroMeetTurnHeroID
		if speakerID != lo && speakerID != hi {
			speakerID = lo
		}
	} else {
		// Exactly one online: scripted lines only from the offline hero.
		if !loOn {
			speakerID = lo
		} else {
			speakerID = hi
		}
	}

	sh := e.movements[speakerID]
	if sh == nil || sh.Hero == nil {
		return
	}
	lineKey := model.RandomHeroMeetAutoPhraseKey()

	if !loOn && !hiOn {
		if speakerID == lo {
			ex.HeroMeetTurnHeroID = hi
		} else {
			ex.HeroMeetTurnHeroID = lo
		}
	} else {
		// Mixed: point turn at the online hero so when they disconnect, alternation resumes naturally.
		if speakerID == lo {
			ex.HeroMeetTurnHeroID = hi
		} else {
			ex.HeroMeetTurnHeroID = lo
		}
	}

	name := sh.Hero.Name
	if e.adventureLog != nil {
		line := model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseHeroMeetScripted,
				Args: map[string]any{"speaker": name, "lineKey": lineKey},
			},
		}
		e.adventureLog(lo, line)
		e.adventureLog(hi, line)
	}
	if e.sender != nil {
		payload := model.HeroMeetLinePayload{
			FromHeroID: speakerID,
			Kind:       "scripted",
			LineKey:    lineKey,
		}
		e.sender.SendToHero(lo, "hero_meet_line", payload)
		e.sender.SendToHero(hi, "hero_meet_line", payload)
	}
	partner := e.movements[hi]
	if partner != nil {
		e.syncHeroMeetPartnerExcursion(leader, partner)
	}
}

// pushHeroMeetIfActiveLocked sends hero_meet_start when the hero is in dialogue meet phase. Caller holds e.mu.
func (e *Engine) pushHeroMeetIfActiveLocked(heroID int64) {
	hm := e.movements[heroID]
	if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
		return
	}
	cfg := tuning.Get()
	linger := cfg.HeroMeetPartnerLingerMs
	if linger <= 0 {
		linger = tuning.DefaultValues().HeroMeetPartnerLingerMs
	}
	if hm.Excursion.Phase != "" {
		e.pushHeroMeetStartLocked(heroID, linger, string(hm.Excursion.Phase))
		return
	}
	if e.sender != nil && hm.Hero != nil {
		hm.SyncToHero()
		e.sender.SendToHero(heroID, "hero_state", hm.Hero)
		e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(time.Now()))
	}
}

// ApplyAdminStartHeroMeet teleports other to primary and starts a meet (online heroes only).
// reason is set when ok is false (for logs and admin JSON detail).
func (e *Engine) ApplyAdminStartHeroMeet(primaryID, otherID int64) (hero *model.Hero, ok bool, reason string) {
	e.mu.Lock()
	defer e.mu.Unlock()
	logReject := func(r string, extra ...any) (*model.Hero, bool, string) {
		if e.logger != nil {
			args := append([]any{"reason", r, "primary_id", primaryID, "other_id", otherID}, extra...)
			e.logger.Warn("admin hero_meet start rejected", args...)
		}
		return nil, false, r
	}
	if primaryID == otherID {
		return logReject("same_hero")
	}
	hp := e.movements[primaryID]
	ho := e.movements[otherID]
	if hp == nil || ho == nil || hp.Hero == nil || ho.Hero == nil {
		return logReject("movement_not_in_engine", "has_primary_movement", hp != nil, "has_other_movement", ho != nil)
	}
	if _, ok := e.combats[primaryID]; ok {
		return logReject("primary_in_combat")
	}
	if _, ok := e.combats[otherID]; ok {
		return logReject("other_in_combat")
	}
	now := time.Now()
	px, py := hp.worldPositionAt(now)
	cfg := tuning.Get()
	sep := cfg.HeroMeetAdminSnapSeparationWorld
	if sep <= 0 {
		sep = tuning.DefaultValues().HeroMeetAdminSnapSeparationWorld
	}

	ho.DestinationTownID = hp.DestinationTownID
	ho.CurrentTownID = hp.CurrentTownID
	ho.WaypointIndex = hp.WaypointIndex
	ho.WaypointFraction = hp.WaypointFraction
	ho.Excursion.RoadFreezeFraction = hp.Excursion.RoadFreezeFraction
	ho.Excursion.RoadFreezeWaypoint = hp.Excursion.RoadFreezeWaypoint
	teleportHeroTowardWorldPoint(ho, px, py, sep, e.heroStore)

	if started, r := e.BeginHeroMeetPairLocked(now, primaryID, otherID); !started {
		if e.logger != nil {
			e.logger.Warn("admin hero_meet start rejected after teleport snap",
				"reason", r,
				"primary_id", primaryID,
				"other_id", otherID,
				"primary_state", hp.State,
				"other_state", ho.State,
				"primary_excursion_active", hp.Excursion.Active(),
				"other_excursion_active", ho.Excursion.Active(),
				"primary_wm_deadline_set", !hp.WanderingMerchantDeadline.IsZero(),
				"other_wm_deadline_set", !ho.WanderingMerchantDeadline.IsZero(),
			)
		}
		return nil, false, r
	}
	out := e.movements[primaryID]
	if out == nil || out.Hero == nil {
		if e.logger != nil {
			e.logger.Error("admin hero_meet inconsistent: primary missing after begin", "primary_id", primaryID, "other_id", otherID)
		}
		return nil, false, "internal_primary_missing_after_begin"
	}
	return out.Hero, true, ""
}

// NotifyHeroMeetAfterWSConnect pushes meet UI state after WS connect (dialogue start or approach snapshot).
func (e *Engine) NotifyHeroMeetAfterWSConnect(heroID int64) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm := e.movements[heroID]
	if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
		return
	}
	e.pushHeroMeetIfActiveLocked(heroID)
	pid := hm.Excursion.HeroMeetPartnerID
	if pid != 0 {
		e.pushHeroMeetIfActiveLocked(pid)
	}
}

func (e *Engine) handleHeroMeetSendMessage(msg IncomingMessage) {
	var p model.HeroMeetSendMessagePayload
	if err := json.Unmarshal(msg.Payload, &p); err != nil {
		e.sendError(msg.HeroID, "invalid_payload", "invalid hero_meet_send_message")
		return
	}
	text := trimHeroMeetMessage(p.Text)
	maxR := tuning.Get().HeroMeetMessageMaxRunes
	if maxR <= 0 {
		maxR = tuning.DefaultValues().HeroMeetMessageMaxRunes
	}
	if utf8.RuneCountInString(text) > maxR {
		e.sendError(msg.HeroID, "message_too_long", "message too long")
		return
	}
	if text == "" {
		e.sendError(msg.HeroID, "empty_message", "empty message")
		return
	}
	if profanity.ChatMessageIsProfane(text) {
		e.sendError(msg.HeroID, "profanity", "message rejected")
		return
	}

	e.mu.Lock()
	defer e.mu.Unlock()
	hm := e.movements[msg.HeroID]
	if hm == nil || hm.Hero == nil {
		e.sendError(msg.HeroID, "no_hero", "hero not active")
		return
	}
	if hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
		e.sendError(msg.HeroID, "not_in_meet", "not in hero meet")
		return
	}
	if hm.Excursion.Phase != model.ExcursionPhaseHeroMeet {
		e.sendError(msg.HeroID, "not_in_dialogue", "meet dialogue not active")
		return
	}
	if !e.heroMeetAnySubscriberOnline(msg.HeroID, hm.Excursion.HeroMeetPartnerID) {
		e.sendError(msg.HeroID, "offline", "cannot chat while offline")
		return
	}
	cd := time.Duration(tuning.Get().HeroMeetMessageCooldownMs) * time.Millisecond
	if cd <= 0 {
		cd = time.Duration(tuning.DefaultValues().HeroMeetMessageCooldownMs) * time.Millisecond
	}
	now := time.Now()
	if t := e.heroMeetLastMsg[msg.HeroID]; !t.IsZero() && now.Sub(t) < cd {
		e.sendError(msg.HeroID, "rate_limited", "slow down")
		return
	}
	e.heroMeetLastMsg[msg.HeroID] = now

	pid := hm.Excursion.HeroMeetPartnerID
	lo, hi := heroMeetOrderedPair(msg.HeroID, pid)
	leader := e.movements[lo]
	partner := e.movements[hi]
	if leader == nil || partner == nil {
		return
	}
	leader.Excursion.HeroMeetHadPlayerMessage = true
	e.syncHeroMeetPartnerExcursion(leader, partner)

	name := hm.Hero.Name
	if e.adventureLog != nil {
		line := model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseHeroMeetPlayerSaid,
				Args: map[string]any{"speaker": name, "text": text},
			},
		}
		e.adventureLog(lo, line)
		e.adventureLog(hi, line)
	}
	payload := model.HeroMeetLinePayload{FromHeroID: msg.HeroID, Kind: "player", Text: text}
	if e.sender != nil {
		e.sender.SendToHero(lo, "hero_meet_line", payload)
		e.sender.SendToHero(hi, "hero_meet_line", payload)
	}
}

func (e *Engine) handleHeroMeetEndConversation(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm := e.movements[msg.HeroID]
	if hm == nil || hm.Excursion.Kind != model.ExcursionKindHeroMeet || !hm.Excursion.Active() {
		return
	}
	pid := hm.Excursion.HeroMeetPartnerID
	if pid == 0 {
		return
	}
	if !e.heroMeetAnySubscriberOnline(msg.HeroID, pid) {
		return
	}
	lo, hi := heroMeetOrderedPair(msg.HeroID, pid)
	e.EndHeroMeetPairLocked(lo, hi, time.Now(), "user_end")
}

func trimHeroMeetMessage(s string) string {
	// trim ASCII whitespace; keep inner spaces
	for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
		s = s[1:]
	}
	for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
		s = s[:len(s)-1]
	}
	return s
}
