extends Node
## Storyteller — daily 6 AM event roll, cooldown gating, tension model, ghost state.
##
## Public API (locked — Agents A + C compile against these names):
##   const CATEGORY_COOLDOWN_DAYS: Dictionary  — category-snake → in-game days
##   tension: float                             — running 0..100 score
##   ghost_state: bool
##   register_event(def: EventDef)              — EventCatalog calls at boot
##   fire_event(def: EventDef, focus_tile)      — immediate dispatch, skips roll/cooldowns
##   roll_today()                               — daily roll (also public test hook)
##   resolve_current(choice_index: int)         — called by UI after player choice
##   save_dict() / apply_dict()                 — round-trips all runtime state
##
## Emits EventBus signals: storyteller_event_fired, storyteller_event_resolved,
## storyteller_tension_changed, storyteller_ghost_state_entered/exited.

## Per-category cooldown floors. design.md §"Storyteller": no two threats within
## 3 days, no two wanderers within 5 days. Other categories quieter.
const CATEGORY_COOLDOWN_DAYS: Dictionary = {
	&"nudge":     2,
	&"seasonal":  12,
	&"wanderer":  5,
	&"threat":    3,
	&"disease":   4,
	&"resource":  3,
	&"lore":      6,
	&"milestone": 30,
}

## Running 0..100 score; bumped by threats, decays slowly each roll.
var tension: float = 30.0  # mid-pool start

## True when all colonists are dead/gone. Wanderer recovery clock starts.
var ghost_state: bool = false

## Registry of all known EventDefs, keyed by StringName id.
var _events: Dictionary = {}

## The currently active (un-resolved) event, or null.
var _current_event: EventDef = null

## Guards one roll per in-game day. -1 = never rolled.
var _last_rolled_day_index: int = -1

## event_id → day_index of last fire. Prevents repeating an event too soon.
var _event_last_fired: Dictionary = {}   # StringName → int

## category_snake → day_index of last fire. Per-category cooldown gate.
var _category_last_fired: Dictionary = {}  # StringName → int

## Target day_index for the ghost-state wanderer auto-fire. -1 = not scheduled.
var _ghost_wanderer_target_day: int = -1

## Sim speed captured before an auto_pause event paused the sim. -1 when no
## restore is pending. Restored by resolve_current() so the player doesn't have
## to manually un-pause every threat modal dismissal.
var _speed_before_auto_pause: int = -1


func _ready() -> void:
	Clock.phase_changed.connect(_on_phase_changed)
	EventBus.pawn_died.connect(_on_pawn_died)


# ── public API ────────────────────────────────────────────────────────────────

## Called by EventCatalog at boot to populate the event pool.
func register_event(def: EventDef) -> void:
	if def == null or def.id == &"":
		push_warning("Storyteller.register_event: null or empty-id def")
		return
	_events[def.id] = def


## Immediate dispatch: skip cooldown/predicate gates, jump straight to fire.
## Used by tests and ghost-state wanderer auto-fire.
func fire_event(def: EventDef, focus_tile: Vector2i = Vector2i(-1, -1)) -> void:
	if def == null:
		push_warning("Storyteller.fire_event: null def")
		return
	_fire(def, focus_tile)


## Trigger the daily roll regardless of current phase. Public for MCP runtime tests.
func roll_today() -> void:
	_do_daily_roll()


## Called by UI (banner/modal) after the player makes a choice or dismisses.
func resolve_current(choice_index: int = 0) -> void:
	if _current_event == null:
		push_warning("Storyteller.resolve_current: no active event — double-dismiss?")
		return
	if _current_event.on_resolve.is_valid():
		_current_event.on_resolve.call(choice_index)
	EventBus.storyteller_event_resolved.emit(_current_event, choice_index)
	_current_event = null
	# Restore the sim speed if this dismissal closes an auto_pause event AND
	# the player hasn't manually changed speed during the modal. (If they did,
	# current_speed won't be PAUSE and we leave their choice alone.)
	if _speed_before_auto_pause >= 0:
		if Sim.current_speed == Sim.Speed.PAUSE:
			Sim.set_speed(_speed_before_auto_pause)
		_speed_before_auto_pause = -1


# ── save / load ───────────────────────────────────────────────────────────────

func save_dict() -> Dictionary:
	# Convert StringName keys to plain strings for JSON round-trip.
	var event_fired_str: Dictionary = {}
	for k: StringName in _event_last_fired:
		event_fired_str[String(k)] = _event_last_fired[k]
	var cat_fired_str: Dictionary = {}
	for k: StringName in _category_last_fired:
		cat_fired_str[String(k)] = _category_last_fired[k]
	return {
		"tension":                   tension,
		"ghost_state":               ghost_state,
		"last_rolled_day_index":     _last_rolled_day_index,
		"event_last_fired":          event_fired_str,
		"category_last_fired":       cat_fired_str,
		"ghost_wanderer_target_day": _ghost_wanderer_target_day,
		"speed_before_auto_pause":   _speed_before_auto_pause,
	}


func apply_dict(d: Dictionary) -> void:
	tension               = d.get("tension", 30.0)
	ghost_state           = d.get("ghost_state", false)
	_last_rolled_day_index = int(d.get("last_rolled_day_index", -1))
	_ghost_wanderer_target_day = int(d.get("ghost_wanderer_target_day", -1))
	_speed_before_auto_pause = int(d.get("speed_before_auto_pause", -1))
	# Restore StringName keyed dicts.
	_event_last_fired.clear()
	for k: String in d.get("event_last_fired", {}):
		_event_last_fired[StringName(k)] = int(d["event_last_fired"][k])
	_category_last_fired.clear()
	for k: String in d.get("category_last_fired", {}):
		_category_last_fired[StringName(k)] = int(d["category_last_fired"][k])


# ── phase listener — triggers the daily 6 AM roll at dawn ────────────────────

func _on_phase_changed(phase: StringName) -> void:
	if phase != Clock.PHASE_DAWN:
		return
	var today: int = Clock.day_index_from_start()
	if today == _last_rolled_day_index:
		return  # Already rolled today (e.g. fast-speed bounce through dawn twice).
	_last_rolled_day_index = today
	_do_daily_roll()


# ── core roll logic ───────────────────────────────────────────────────────────

func _do_daily_roll() -> void:
	var today: int = Clock.day_index_from_start()

	# Ghost-state wanderer auto-fire check.
	if ghost_state and _ghost_wanderer_target_day >= 0 and today >= _ghost_wanderer_target_day:
		_try_fire_ghost_wanderer()
		return  # Ghost-state fires one special event; skip normal pool roll.

	# Decay tension before building the pool (net = decay − 3, then threat adds +15 if fired).
	tension = maxf(0.0, tension - 3.0)

	# Build eligible pool.
	var pool: Array = []  # Each entry: {def: EventDef, weight: float}
	for id: StringName in _events:
		var def: EventDef = _events[id]
		if not _is_eligible(def, today):
			continue
		var w: float = _compute_weight(def)
		pool.append({"def": def, "weight": w})

	if pool.is_empty():
		Audit.log("storyteller", "[storyteller] day %d roll → no eligible events" % today)
		return

	var picked: EventDef = _weighted_pick(pool)
	Audit.log("storyteller", "[storyteller] day %d roll → '%s' (cat=%s, weight=%.2f, tension=%.1f)" % [
		today,
		String(picked.id),
		EventDef.Category.keys()[picked.category],
		_compute_weight(picked),
		tension,
	])
	_fire(picked)


func _is_eligible(def: EventDef, today: int) -> bool:
	# Per-event cooldown gate.
	var last_event: int = _event_last_fired.get(def.id, -9999)
	if today - last_event < def.cooldown_days:
		return false

	# Per-category cooldown gate.
	var cat_key: StringName = _category_to_str(def.category)
	var cat_floor: int = CATEGORY_COOLDOWN_DAYS.get(cat_key, 0)
	var last_cat: int = _category_last_fired.get(cat_key, -9999)
	if today - last_cat < cat_floor:
		return false

	# Trigger predicate gate.
	if def.trigger_predicate.is_valid() and not def.trigger_predicate.call():
		return false

	return true


func _compute_weight(def: EventDef) -> float:
	var w: float = def.base_weight

	# Tension modifier per category.
	match def.category:
		EventDef.Category.THREAT:
			# Low tension → boost threats (exciting); high tension → suppress (breathing room).
			w *= lerp(2.0, 0.3, tension / 100.0)
		EventDef.Category.RESOURCE:
			# High tension → more positive events to balance.
			w *= lerp(0.5, 1.5, tension / 100.0)
		_:
			pass  # Other categories: weight unmodified.

	# State-triggered events (predicate is set AND currently true) get a 3× boost.
	if def.trigger_predicate.is_valid() and def.trigger_predicate.call():
		w *= 3.0

	return w


func _weighted_pick(pool: Array) -> EventDef:
	var total: float = 0.0
	for entry in pool:
		total += entry["weight"]
	var roll: float = randf() * total
	var cumulative: float = 0.0
	for entry in pool:
		cumulative += entry["weight"]
		if roll <= cumulative:
			return entry["def"]
	# Fallback: last entry (floating-point edge case).
	return pool[pool.size() - 1]["def"]


# ── fire + resolve helpers ────────────────────────────────────────────────────

func _fire(def: EventDef, focus_tile: Vector2i = Vector2i(-1, -1)) -> void:
	_current_event = def

	# Mark cooldowns.
	var today: int = Clock.day_index_from_start()
	_event_last_fired[def.id] = today
	_category_last_fired[_category_to_str(def.category)] = today

	# Tension: threats bump up by +15 (net +12 after the −3 decay applied pre-roll).
	# Direct fire (fire_event / ghost) also bumps since it's still a threat.
	if def.category == EventDef.Category.THREAT:
		tension = minf(100.0, tension + 15.0)
		EventBus.storyteller_tension_changed.emit(tension)

	# Auto-pause for modal events that require it. Capture the prior speed so
	# resolve_current() can restore it on dismissal — without this the sim
	# stays paused after every threat modal, which the player reads as "pawns
	# stopped working for no reason."
	if def.auto_pause:
		if Sim.current_speed != Sim.Speed.PAUSE:
			_speed_before_auto_pause = int(Sim.current_speed)
		Sim.set_speed(Sim.Speed.PAUSE)

	# For banners with no choices, resolve immediately after a short delay is
	# handled by the UI layer (Agent C). We just emit.
	EventBus.storyteller_event_fired.emit(def)


# ── ghost state ───────────────────────────────────────────────────────────────

func _on_pawn_died(_pawn, _cause: StringName) -> void:
	# Check on next frame so the pawn registry has time to unregister.
	call_deferred("_check_ghost_state")


func _check_ghost_state() -> void:
	if World.pawns.size() == 0 and not ghost_state:
		ghost_state = true
		_ghost_wanderer_target_day = Clock.day_index_from_start() + randi_range(3, 5)
		Audit.log("storyteller", "ghost state entered — wanderer recovery clock starts (target day %d)" % _ghost_wanderer_target_day)
		EventBus.storyteller_ghost_state_entered.emit()
	elif World.pawns.size() > 0 and ghost_state:
		_exit_ghost_state()


func _exit_ghost_state() -> void:
	ghost_state = false
	_ghost_wanderer_target_day = -1
	EventBus.storyteller_ghost_state_exited.emit()
	Audit.log("storyteller", "ghost state exited — colony has survivors again")


func _try_fire_ghost_wanderer() -> void:
	# Find a WANDERER event; prefer &"a_traveler".
	var wanderer_def: EventDef = null
	for id: StringName in _events:
		var def: EventDef = _events[id]
		if def.category != EventDef.Category.WANDERER:
			continue
		if id == &"a_traveler":
			wanderer_def = def
			break
		if wanderer_def == null:
			wanderer_def = def  # Any wanderer as fallback.

	if wanderer_def == null:
		Audit.log("storyteller", "ghost state: no WANDERER event registered — cannot auto-fire wanderer")
		return

	Audit.log("storyteller", "ghost state: auto-firing '%s' on day %d" % [String(wanderer_def.id), Clock.day_index_from_start()])
	_ghost_wanderer_target_day = -1  # Clear schedule before firing.
	fire_event(wanderer_def)


# ── utilities ─────────────────────────────────────────────────────────────────

## Convert a Category enum value to the snake-case StringName used in
## CATEGORY_COOLDOWN_DAYS and _category_last_fired.
func _category_to_str(cat: EventDef.Category) -> StringName:
	match cat:
		EventDef.Category.NUDGE:     return &"nudge"
		EventDef.Category.SEASONAL:  return &"seasonal"
		EventDef.Category.WANDERER:  return &"wanderer"
		EventDef.Category.THREAT:    return &"threat"
		EventDef.Category.DISEASE:   return &"disease"
		EventDef.Category.RESOURCE:  return &"resource"
		EventDef.Category.LORE:      return &"lore"
		EventDef.Category.MILESTONE: return &"milestone"
		_:
			push_warning("Storyteller._category_to_str: unknown category %d" % cat)
			return &"nudge"
