using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Game.Input;
using Theriapolis.Game.Platform;
using Theriapolis.Game.Rendering;

namespace Theriapolis.Game.Screens;

/// <summary>
/// The in-game screen. Owns the camera, both renderers, the player actor,
/// the world clock, and the input pipeline.
///
/// Phase 4 — M1: world-map only (click to walk). M3 will plug in the tactical
/// renderer and view swap; M4 the autosave hooks.
/// </summary>
public sealed class PlayScreen : IScreen
{
    private readonly WorldGenContext _ctx;
    private readonly SaveBody? _restoredBody;
    private readonly Theriapolis.Core.Rules.Character.Character? _pendingCharacter;
    private readonly string? _pendingName;
    private ContentResolver? _content;
    private float _saveFlashTimer;
    private string _saveFlashText = "";

    // Phase 5 M5: per-session NPC roster delta. ChunkCoord → killed spawn indices.
    private readonly Dictionary<Theriapolis.Core.Tactical.ChunkCoord, HashSet<int>> _killedByChunk = new();
    // Tracks the active CombatHUD so SaveTo can snapshot it for save-anywhere mid-combat.
    private CombatHUDScreen? _activeCombatHud;
    // Cached interact prompt — updated each tick.
    private Theriapolis.Core.Entities.NpcActor? _interactCandidate;
    // Edge-detect F key for the interact prompt.
    private bool _fWasDown;
    // Phase 5 M5: pending encounter restore (deferred to first Update so chunks load NPCs first).
    private EncounterState? _pendingEncounterRestore;
    // Phase 6 M1: anchor:* and role:* lookup table for quest/dialogue resolution.
    private readonly Theriapolis.Core.World.Settlements.AnchorRegistry _anchorRegistry = new();
    // Phase 6 M2: faction standings + per-NPC personal disposition + ledger.
    private readonly Theriapolis.Core.Rules.Reputation.PlayerReputation _reputation = new();
    // Phase 6 M3: world flag dictionary written by dialogue set_flag effects
    // (and Phase 6 M4 by quest steps). Round-trips through SaveBody.Flags.
    private readonly Dictionary<string, int> _flags = new();
    // Phase 6 M4: quest engine — ticked from PlayScreen.Update.
    private readonly Theriapolis.Core.Rules.Quests.QuestEngine _questEngine = new();
    private Theriapolis.Core.Rules.Quests.QuestContext? _questCtx;

    // Phase 6 M3 accessors used by InteractionScreen / ShopScreen to drive
    // dialogue + shop state without copying the live aggregates.
    internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation;
    internal Dictionary<string, int> Flags => _flags;
    internal Theriapolis.Core.World.Settlements.AnchorRegistry Anchors => _anchorRegistry;

    internal Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
        => _actors.Player?.Character;
    internal Theriapolis.Core.Rules.Quests.QuestEngine QuestEngine => _questEngine;

    /// <summary>
    /// Phase 6 M4 — fresh quest context for dialogue / shop screens that
    /// need to fire <c>start_quest</c> effects outside the regular tick.
    /// </summary>
    internal Theriapolis.Core.Rules.Quests.QuestContext? BuildQuestContextForDialogue()
    {
        if (_content is null) return null;
        if (_questCtx is null) return null;
        _questCtx.PlayerCharacter = _actors.Player?.Character;
        return _questCtx;
    }
    internal Vec2 PlayerActorPosition()
        => _actors.Player?.Position ?? new Vec2(0, 0);
    internal long ClockSeconds()
        => _clock.InGameSeconds;
    internal ulong WorldSeed()
        => _ctx.World.WorldSeed;
    internal Theriapolis.Core.World.WorldState World()
        => _ctx.World;
    internal ContentResolver? ContentResolver => _content;

    private Game1 _game = null!;

    private Camera2D _camera = null!;
    private TileAtlas _atlas = null!;
    private TacticalAtlas _tacticalAtlas = null!;
    private WorldMapRenderer _worldRenderer = null!;
    private TacticalRenderer _tacticalRenderer = null!;
    private LineFeatureRenderer _lineOverlay = null!;
    private PlayerSprite _playerSprite = null!;
    private NpcSprite    _npcSprite    = null!;
    private InputManager _input = null!;
    private SpriteBatch _sb = null!;

    private ActorManager _actors = null!;
    private WorldClock   _clock  = null!;
    private PlayerController _controller = null!;
    private ChunkStreamer _streamer = null!;
    private InMemoryChunkDeltaStore _deltas = null!;

    private Desktop _overlayDesktop = null!;
    private Label _hudLabel = null!;
    private int _cursorTileX, _cursorTileY;          // world-tile coords (0..255)
    private int _cursorTacticalX, _cursorTacticalY;  // tactical-tile coords (= world pixels)

    // Click-vs-drag detection (same idiom as WorldMapScreen).
    private Vector2 _mouseDownPos;
    private int _mouseDownTileX, _mouseDownTileY;
    private bool _mouseDownTracked;
    private const float ClickSlopPixels = 4f;

    public PlayScreen(WorldGenContext ctx)
    {
        _ctx = ctx;
    }

    /// <summary>Restore-from-save constructor: applies the snapshot once Initialize runs.</summary>
    public PlayScreen(WorldGenContext ctx, SaveBody restoredBody)
        : this(ctx)
    {
        _restoredBody = restoredBody;
    }

    /// <summary>
    /// Phase 5 M2: new-game-with-character constructor. The character was built
    /// by <see cref="CharacterCreationScreen"/> and is attached to the spawned
    /// player on Initialize.
    /// </summary>
    public PlayScreen(WorldGenContext ctx, Theriapolis.Core.Rules.Character.Character character, string playerName)
        : this(ctx)
    {
        _pendingCharacter = character;
        _pendingName      = playerName;
    }

    public void Initialize(Game1 game)
    {
        _game  = game;
        _input = new InputManager();
        _sb    = new SpriteBatch(game.GraphicsDevice);

        var gdw = new GraphicsDeviceWrapper(game.GraphicsDevice);
        _camera = new Camera2D(gdw);

        _atlas = new TileAtlas(game.GraphicsDevice);
        _atlas.GeneratePlaceholders(_ctx.World.BiomeDefs!);

        _worldRenderer = new WorldMapRenderer(_ctx, _atlas);
        _playerSprite  = new PlayerSprite(game.GraphicsDevice);
        _npcSprite     = new NpcSprite(game.GraphicsDevice);
        _lineOverlay   = new LineFeatureRenderer(game.GraphicsDevice, _ctx);

        _clock   = new WorldClock();
        _actors  = new ActorManager();
        _deltas  = new InMemoryChunkDeltaStore();

        // Phase 5: ContentResolver is needed for save/restore character round-trips
        // and to look up NPC templates from chunk spawns. Phase 6 M0: also feeds
        // building/layout content to the streamer so settlements stamp templates
        // instead of the placeholder plaza.
        _content = new ContentResolver(new ContentLoader(_game.ContentDataDirectory));
        _streamer = new ChunkStreamer(_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);

        // Phase 6 M1: pre-register every settlement's anchor id. Role tags
        // register lazily as residents stream in.
        _anchorRegistry.RegisterAllAnchors(_ctx.World);

        // Phase 6 M4: build the quest context once content + clock + actors
        // are wired up. PlayerCharacter is filled in once SpawnPlayer runs.
        _questCtx = new Theriapolis.Core.Rules.Quests.QuestContext(
            _content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);

        // Tactical art root: Gfx/tactical/{surface,deco}/<name>.png. The atlas
        // falls back to placeholders for any tile that has no PNG yet.
        string tacticalGfx = System.IO.Path.Combine(_game.ContentGfxDirectory, "tactical");
        _tacticalAtlas    = new TacticalAtlas(game.GraphicsDevice, tacticalGfx);
        _tacticalRenderer = new TacticalRenderer(game.GraphicsDevice, _streamer, _tacticalAtlas);

        // Phase 5 M5: subscribe to chunk events so NPCs spawn/despawn with the
        // active tactical window.
        _streamer.OnChunkLoaded   += HandleChunkLoaded;
        _streamer.OnChunkEvicting += HandleChunkEvicting;

        if (_restoredBody is not null)
        {
            ApplyRestoredBody(_restoredBody);
        }
        else
        {
            // New game: spawn at the Tier-1 anchor (Millhaven), or world centre as
            // a safe fallback if no Tier-1 exists yet.
            var spawn = ChooseSpawn(_ctx.World);
            if (_pendingCharacter is not null)
            {
                var p = _actors.SpawnPlayer(spawn, _pendingCharacter);
                if (!string.IsNullOrWhiteSpace(_pendingName)) p.Name = _pendingName;
            }
            else
            {
                _actors.SpawnPlayer(spawn);
            }
        }
        _controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
        // Tactical sampler — looks up walkability through the streamer.
        _controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;

        // Camera initially centred on the player and zoomed to a comfortable
        // mid-zoom (between fit-the-world and tactical threshold) so the player
        // can see their surroundings without instantly entering tactical.
        _camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
        SetInitialZoom();

        BuildOverlay();

        // Phase 5 M5: if we restored a mid-combat encounter, force-load chunks
        // so the NPC actors spawn, then rehydrate the encounter and push the
        // CombatHUD on top.
        if (_pendingEncounterRestore is not null)
        {
            _streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);
            RestoreEncounter(_pendingEncounterRestore);
            _pendingEncounterRestore = null;
        }
    }

    private void RestoreEncounter(EncounterState saved)
    {
        if (_actors.Player?.Character is null) return;
        var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
        foreach (var snap in saved.Combatants)
        {
            Theriapolis.Core.Rules.Combat.Combatant combatant;
            if (snap.IsPlayer)
            {
                combatant = Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
                    _actors.Player.Character!, _actors.Player.Id, _actors.Player.Name,
                    new Vec2((int)snap.PositionX, (int)snap.PositionY),
                    Theriapolis.Core.Rules.Character.Allegiance.Player);
            }
            else
            {
                // Try to find the live NPC actor (same chunk + spawn index).
                Theriapolis.Core.Entities.NpcActor? npc = null;
                if (snap.NpcChunkX is int cx && snap.NpcChunkY is int cy && snap.NpcSpawnIndex is int si)
                    npc = _actors.FindNpcBySource(new Theriapolis.Core.Tactical.ChunkCoord(cx, cy), si);
                if (npc is null)
                {
                    // Fall back to template-only combatant. Won't write back to a live actor on resolve,
                    // but the encounter still completes correctly.
                    var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
                    if (template is null) continue;
                    combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
                        template, snap.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
                }
                else if (npc.Template is not null)
                {
                    combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
                        npc.Template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
                }
                else
                {
                    // Phase 6 M1 resident — re-resolve via template id from the snapshot.
                    var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
                    if (template is null) continue;
                    combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
                        template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
                }
            }
            combatant.CurrentHp = snap.CurrentHp;
            combatant.Position  = new Vec2((int)snap.PositionX, (int)snap.PositionY);
            foreach (byte cb in snap.Conditions)
                combatant.Conditions.Add((Theriapolis.Core.Rules.Stats.Condition)cb);
            participants.Add(combatant);
        }

        var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
            _ctx.World.WorldSeed, saved.EncounterId, participants);
        encounter.ResumeRolls(saved.RollCount);
        // Note: we do NOT restore CurrentTurnIndex / RoundNumber directly — the
        // encounter constructor recomputes initiative from the participants. Save
        // captures the round/turn for HUD display purposes; functional resume
        // works because the dice stream is at the same point.

        _activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
        _game.Screens.Push(_activeCombatHud);
    }

    private void ApplyRestoredBody(SaveBody body)
    {
        var player = _actors.RestorePlayer(body.Player);
        _clock.RestoreState(body.Clock);
        // Reload chunk delta store from the save.
        foreach (var kv in body.ModifiedChunks)
            _deltas.Put(kv.Key, kv.Value);
        // Apply world-tile deltas in place — these are sparse "the player burned
        // a settlement" style overrides, not full tile rewrites.
        foreach (var d in body.ModifiedWorldTiles)
        {
            ref var t = ref _ctx.World.TileAt(d.X, d.Y);
            t.Biome    = (BiomeId)d.NewBiome;
            t.Features = (FeatureFlags)d.NewFeatures;
        }
        // Phase 5: rehydrate the character if one was saved. Phase-4 saves
        // (without character) would have been refused by SaveLoadScreen, so
        // here PlayerCharacter should always be non-null. Defensive null-check
        // anyway in case a hand-edited save sneaks through.
        if (body.PlayerCharacter is not null && _content is not null)
        {
            player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
        }
        // Phase 5 M5: restore per-chunk killed-spawn-indices.
        _killedByChunk.Clear();
        foreach (var d in body.NpcRoster.ChunkDeltas)
        {
            var coord = new Theriapolis.Core.Tactical.ChunkCoord(d.ChunkX, d.ChunkY);
            _killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
        }
        // Phase 5 M5: defer encounter rehydration until chunks load and NPC actors
        // exist; the first Update tick triggers EnsureLoadedAround which spawns them.
        _pendingEncounterRestore = body.ActiveEncounter;

        // Phase 6 M2 — restore reputation aggregate. Replace the empty default
        // by mutating the existing instance in place so consumers holding a
        // reference (the ReputationScreen, dialogue runner) keep working.
        var restoredRep = Theriapolis.Core.Persistence.ReputationCodec.Restore(body.ReputationState);
        _reputation.Factions.Clear();
        foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
        _reputation.Personal.Clear();
        foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
        _reputation.Ledger.Clear();
        foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);

        // Phase 6 M3 — restore world flag dictionary.
        _flags.Clear();
        foreach (var (k, v) in body.Flags) _flags[k] = v;

        // Phase 6 M4 — restore quest engine state.
        Theriapolis.Core.Persistence.QuestCodec.Restore(_questEngine, body.QuestEngineState);
    }

    /// <summary>Build a save body snapshot from the current screen state.</summary>
    private SaveBody CaptureBody()
    {
        // Capture the encounter snapshot BEFORE flushing chunks (snapshot needs
        // live combatant state, and FlushAll evicts NPCs which would erase it).
        EncounterState? activeEnc = _activeCombatHud is { IsOver: false }
            ? _activeCombatHud.SnapshotForSave()
            : null;

        // Push every loaded chunk through eviction so any in-memory deltas
        // hit the store before we read it. NOTE: this also despawns all live
        // NPCs via OnChunkEvicting — fine for save (state is captured above
        // for the encounter; NpcRoster captures the kill-list).
        _streamer.FlushAll();

        var body = new SaveBody
        {
            Player = _actors.Player!.CaptureState(),
            Clock  = _clock.CaptureState(),
        };
        // Phase 5: capture the character if one is attached.
        if (_actors.Player.Character is not null)
            body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
        foreach (var kv in _deltas.All) body.ModifiedChunks[kv.Key] = kv.Value;

        // Phase 5 M5: per-chunk killed-spawn-indices.
        foreach (var kv in _killedByChunk)
            body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
            {
                ChunkX = kv.Key.X,
                ChunkY = kv.Key.Y,
                KilledSpawnIndices = kv.Value.ToArray(),
            });

        body.ActiveEncounter = activeEnc;
        // Phase 6 M2 — capture reputation state.
        body.ReputationState = Theriapolis.Core.Persistence.ReputationCodec.Capture(_reputation);
        // Phase 6 M3 — capture world flag dictionary (dialogue set_flag effects).
        body.Flags = new Dictionary<string, int>(_flags);
        // Phase 6 M4 — capture quest engine state.
        body.QuestEngineState = Theriapolis.Core.Persistence.QuestCodec.Capture(_questEngine);
        return body;
    }

    // ── Phase 5 M5: chunk → NPC lifecycle ───────────────────────────────

    private void HandleChunkLoaded(Theriapolis.Core.Tactical.TacticalChunk chunk)
    {
        if (_content is null) return;
        // For each spawn in the chunk that hasn't been recorded as killed,
        // resolve it against the per-zone template table and spawn an NPC at
        // the spawn's tactical-tile coord (= world-pixel coord).
        _killedByChunk.TryGetValue(chunk.Coord, out var killed);
        for (int i = 0; i < chunk.Spawns.Count; i++)
        {
            if (killed is not null && killed.Contains(i)) continue;
            var spawn = chunk.Spawns[i];
            // Skip if an actor from this slot already exists (chunk reload).
            if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;

            // Phase 6 M1: residents take a different lookup path —
            // by building-role tag, not by danger zone.
            if (spawn.Kind == Theriapolis.Core.Tactical.SpawnKind.Resident)
            {
                Theriapolis.Core.Rules.Combat.ResidentInstantiator.Spawn(
                    _ctx.World.WorldSeed, chunk, i, spawn,
                    _ctx.World, _content, _actors, _anchorRegistry);
                continue;
            }

            var template = Theriapolis.Core.Rules.Combat.NpcInstantiator.PickTemplate(
                spawn.Kind, chunk.DangerZone, _content.Npcs);
            if (template is null) continue;

            int tx = chunk.OriginX + spawn.LocalX;
            int ty = chunk.OriginY + spawn.LocalY;
            _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
        }
    }

    private void HandleChunkEvicting(Theriapolis.Core.Tactical.TacticalChunk chunk)
    {
        // Despawn any live NPCs sourced from this chunk so the active actor
        // list stays bounded as the player moves.
        var toRemove = new List<int>();
        foreach (var npc in _actors.Npcs)
        {
            if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
            {
                toRemove.Add(npc.Id);
                // Phase 6 M1 — drop role-tag mapping so the registry stays in
                // sync with active actors. Anchor entries (settlements) stay.
                if (!string.IsNullOrEmpty(npc.RoleTag))
                    _anchorRegistry.UnregisterRole(npc.RoleTag);
            }
        }
        foreach (int id in toRemove) _actors.RemoveActor(id);
    }

    /// <summary>
    /// Phase 5 M5 per-tick check: hostile in LOS within
    /// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> → start an encounter.
    /// Friendly/neutral within <see cref="C.INTERACT_PROMPT_TILES"/> →
    /// record interact candidate so the HUD can show "[F] Talk to ...".
    /// </summary>
    private void TickEncounterAndInteract()
    {
        if (_actors.Player is null) return;
        if (_activeCombatHud is { IsOver: false }) return;  // already in combat

        // Phase 6 M4 — quest engine tick. Updates active quests, checks
        // auto-start triggers, runs effects. Cheap (a few µs even with
        // dozens of active quests).
        if (_questCtx is not null)
        {
            _questCtx.PlayerCharacter = _actors.Player.Character;
            _questEngine.Tick(_questCtx);
        }

        // Phase 6 M5 — faction-driven aggression. Flips friendly/neutral
        // faction-affiliated NPCs to Hostile when local disposition drops
        // through the HOSTILE threshold. Runs BEFORE FindHostileTrigger so
        // a freshly-flipped patrol attacks on the same tick.
        if (_content is not null && _actors.Player.Character is { } pcChar)
        {
            Theriapolis.Core.Rules.Reputation.FactionAggression.UpdateAllegiances(
                _actors, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
        }

        // Hostile auto-trigger.
        var hostile = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindHostileTrigger(_actors);
        if (hostile is not null)
        {
            StartEncounterWith(hostile);
            return;
        }

        // Friendly/neutral interact prompt.
        _interactCandidate = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindInteractCandidate(_actors);
    }

    private void StartEncounterWith(Theriapolis.Core.Entities.NpcActor seed)
    {
        if (_actors.Player?.Character is null) return;

        // Player + the triggering NPC + any other living hostiles within
        // ENCOUNTER_TRIGGER_TILES (multi-mob encounters).
        var player = _actors.Player;
        var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
        participants.Add(Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
            player.Character!, player.Id, player.Name,
            new Vec2((int)player.Position.X, (int)player.Position.Y),
            Theriapolis.Core.Rules.Character.Allegiance.Player));
        foreach (var npc in _actors.Npcs)
        {
            if (!npc.IsAlive) continue;
            if (npc.Allegiance != Theriapolis.Core.Rules.Character.Allegiance.Hostile) continue;
            if (npc.Template is null) continue;  // residents (Phase 6 M1) skip combat
            int dx = (int)System.Math.Abs(player.Position.X - npc.Position.X);
            int dy = (int)System.Math.Abs(player.Position.Y - npc.Position.Y);
            if (System.Math.Max(dx, dy) > C.ENCOUNTER_TRIGGER_TILES) continue;
            var combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
                npc.Template, npc.Id,
                new Vec2((int)npc.Position.X, (int)npc.Position.Y));
            // Sync HP from the live actor in case it took damage from a previous fight.
            combatant.CurrentHp = npc.CurrentHp;
            participants.Add(combatant);
        }

        ulong encId = (ulong)seed.Id;
        var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
            _ctx.World.WorldSeed, encId, participants);

        // Phase 6.5 M1 — top up per-encounter resource pools (Lay on Paws,
        // Field Repair, Vocalization Dice). Phase 8's rest model will replace
        // this encounter-rest equivalence.
        // Phase 6.5 M3 adds Pheromone Craft + Covenant Authority pools.
        if (_actors.Player?.Character is { } pc)
        {
            Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureLayOnPawsPoolReady(pc);
            Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureFieldRepairReady(pc);
            Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureVocalizationDiceReady(pc);
            Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsurePheromoneUsesReady(pc);
            Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureCovenantAuthorityReady(pc);
        }

        // Combat-start autosave to a dedicated slot so the player can always
        // retry the most recent fight even if their manual save is older.
        SaveTo(SavePaths.AutosavePath());

        _activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
        _game.Screens.Push(_activeCombatHud);
    }

    private void OnEncounterEnd(EncounterEndResult result)
    {
        // Merge per-chunk kill records.
        foreach (var kv in result.Killed)
        {
            if (!_killedByChunk.TryGetValue(kv.Key, out var set))
                _killedByChunk[kv.Key] = set = new HashSet<int>();
            foreach (int idx in kv.Value) set.Add(idx);
        }
        _activeCombatHud = null;
    }

    /// <summary>Build the save header from current state + worldgen StageHashes.</summary>
    private SaveHeader BuildHeader()
    {
        var h = new SaveHeader
        {
            WorldSeedHex   = $"0x{_ctx.World.WorldSeed:X}",
            PlayerName     = _actors.Player!.Name,
            PlayerTier     = _actors.Player.HighestTierReached,
            InGameSeconds  = _clock.InGameSeconds,
            SavedAtUtc     = DateTime.UtcNow.ToString("u"),
        };
        foreach (var kv in _ctx.World.StageHashes)
            h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
        return h;
    }

    /// <summary>
    /// Write the current state to the given slot path (atomic). Used by F5
    /// quicksave, by the slot picker, and by autosave on screen transitions.
    /// </summary>
    public bool SaveTo(string path)
    {
        try
        {
            var header = BuildHeader();
            var body   = CaptureBody();
            var bytes  = SaveCodec.Serialize(header, body);
            SavePaths.WriteAtomic(path, bytes);
            FlashSavedToast($"Saved to {Path.GetFileName(path)}");
            return true;
        }
        catch (Exception ex)
        {
            FlashSavedToast($"Save failed: {ex.Message}");
            return false;
        }
    }

    private void FlashSavedToast(string text)
    {
        _saveFlashText  = text;
        _saveFlashTimer = 2.5f;
    }

    private static Vec2 ChooseSpawn(WorldState w)
    {
        var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
        if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
        var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
        if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
        // Last-ditch: centre of the world.
        return new Vec2(C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
                         C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
    }

    private void SetInitialZoom()
    {
        // Frame ~24 tiles across the screen — comfortable overland-travel zoom.
        float targetZoom = (float)_game.GraphicsDevice.Viewport.Width
                         / (24f * C.WORLD_TILE_PIXELS);
        targetZoom = Math.Clamp(targetZoom, Camera2D.MinZoom, Camera2D.TacticalThreshold * 0.95f);
        _camera.AdjustZoom(
            targetZoom / _camera.Zoom - 1f,
            new Vector2(_game.GraphicsDevice.Viewport.Width  * 0.5f,
                        _game.GraphicsDevice.Viewport.Height * 0.5f));
    }

    private void BuildOverlay()
    {
        _hudLabel = new Label
        {
            Text                = "",
            HorizontalAlignment = HorizontalAlignment.Left,
            VerticalAlignment   = VerticalAlignment.Top,
            Margin              = new Thickness(8),
            Padding             = new Thickness(8, 4, 8, 4),
            Background          = new SolidBrush(new Color(0, 0, 0, 180)),
        };
        _overlayDesktop = new Desktop { Root = _hudLabel };
    }

    public void Update(GameTime gt)
    {
        _input.Update();
        if (!_game.IsActive) return;

        // ESC → push the pause menu (Phase 5 M2). The menu offers Resume,
        // Save Game (any slot), Quicksave, and Quit-to-Title (autosaves first).
        if (_input.JustPressed(Keys.Escape))
        {
            _game.Screens.Push(new PauseMenuScreen(this));
            return;
        }

        // TAB → open inventory (Phase 5 M3). Requires a Character on the player.
        if (_input.JustPressed(Keys.Tab) && _actors.Player?.Character is not null)
        {
            _game.Screens.Push(new InventoryScreen(_actors.Player.Character));
            return;
        }

        // R → reputation screen (Phase 6 M2).
        if (_input.JustPressed(Keys.R) && _content is not null)
        {
            _game.Screens.Push(new ReputationScreen(_reputation, _content));
            return;
        }

        // J → quest journal (Phase 6 M4).
        if (_input.JustPressed(Keys.J) && _content is not null)
        {
            _game.Screens.Push(new QuestLogScreen(_questEngine, _content));
            return;
        }
        // F5 → quicksave to autosave slot (no slot-picker flow).
        if (_input.JustPressed(Keys.F5))
            SaveTo(SavePaths.AutosavePath());

        float dt = (float)gt.ElapsedGameTime.TotalSeconds;
        float panSpeed = 400f / _camera.Zoom;

        // Camera pan stays on arrow keys / middle-drag so WASD remains free for
        // tactical stepping (M3). The world-map view doesn't read WASD.
        Vector2 panDir = Vector2.Zero;
        if (_input.IsDown(Keys.Up))    panDir.Y -= 1;
        if (_input.IsDown(Keys.Down))  panDir.Y += 1;
        if (_input.IsDown(Keys.Left))  panDir.X -= 1;
        if (_input.IsDown(Keys.Right)) panDir.X += 1;
        if (panDir != Vector2.Zero && _camera.Mode == ViewMode.WorldMap)
            _camera.Pan(panDir * panSpeed * dt);

        // Track mouse-down for click-vs-drag.
        if (_input.LeftJustDown)
        {
            _mouseDownPos = _input.MousePosition;
            var downWorld = _camera.ScreenToWorld(_input.MousePosition);
            _mouseDownTileX = (int)MathF.Floor(downWorld.X / C.WORLD_TILE_PIXELS);
            _mouseDownTileY = (int)MathF.Floor(downWorld.Y / C.WORLD_TILE_PIXELS);
            _mouseDownTracked = true;
        }

        // Mouse drag → pan (right-mouse on tactical so left-click stays usable for actions later).
        var dragDelta = _input.ConsumeDragDelta(_camera);
        if (dragDelta != Vector2.Zero) _camera.Pan(dragDelta);

        // Mouse wheel zoom.
        int scroll = _input.ScrollDelta;
        if (scroll != 0)
            _camera.AdjustZoom(scroll > 0 ? 0.12f : -0.12f, _input.MousePosition);

        // Resolve cursor → both world-tile and tactical-tile coords for the HUD.
        var worldPos = _camera.ScreenToWorld(_input.MousePosition);
        _cursorTileX     = (int)MathF.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
        _cursorTileY     = (int)MathF.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
        _cursorTacticalX = (int)MathF.Floor(worldPos.X);
        _cursorTacticalY = (int)MathF.Floor(worldPos.Y);

        // Click handler: world-map → travel; tactical → no-op for now.
        if (_input.LeftJustUp && _mouseDownTracked)
        {
            _mouseDownTracked = false;
            bool wasClick = Vector2.Distance(_input.MousePosition, _mouseDownPos) <= ClickSlopPixels;
            if (wasClick && _camera.Mode == ViewMode.WorldMap)
            {
                if (InBounds(_mouseDownTileX, _mouseDownTileY))
                    _controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
            }
        }

        _controller.Update(gt, _input, _camera, _game.IsActive);

        // Camera follow when traveling so the player stays centred.
        if (_controller.IsTraveling || _camera.Mode == ViewMode.Tactical)
        {
            _camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
        }

        // Stream tactical chunks around the player whenever we're in (or
        // about to enter) tactical mode. We do this even on world-map mode
        // so the swap is instantaneous when the player zooms in.
        if (_camera.Mode == ViewMode.Tactical)
            _streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);

        // Phase 5 M5: encounter trigger + interact prompt only fire in
        // tactical mode (world-map travel doesn't surface NPCs at this scale).
        if (_camera.Mode == ViewMode.Tactical) TickEncounterAndInteract();
        else _interactCandidate = null;

        // Friendly NPC F-press → push InteractionScreen.
        bool fNow       = _input.IsDown(Keys.F);
        bool fJustDown  = fNow && !_fWasDown;
        _fWasDown       = fNow;
        if (fJustDown && _interactCandidate is not null)
        {
            _game.Screens.Push(new InteractionScreen(_interactCandidate, _content, this));
            _interactCandidate = null;
        }

        if (_saveFlashTimer > 0f)
            _saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);

        UpdateOverlayText();
    }

    private static bool InBounds(int x, int y)
        => (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;

    private void UpdateOverlayText()
    {
        var p = _actors.Player!;
        int ptx = (int)MathF.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
        int pty = (int)MathF.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
        ref var t = ref _ctx.World.TileAt(
            Math.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1),
            Math.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1));
        string status = _controller.IsTraveling
            ? "Traveling..."
            : _camera.Mode == ViewMode.Tactical
                ? "WASD to step. Mouse-wheel out to leave tactical."
                : "Click a tile to travel. Mouse-wheel in for tactical.";

        string toast = _saveFlashTimer > 0f ? $"\n[ {_saveFlashText} ]" : "";

        string cursorBlock = _camera.Mode == ViewMode.Tactical
            ? FormatTacticalCursor()
            : $"Cursor: ({_cursorTileX},{_cursorTileY})";

        // Phase 5 M3: character header with HP/AC/encumbrance when attached.
        string charBlock = "";
        if (p.Character is { } pc)
        {
            int  ac    = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
            var  enc   = Theriapolis.Core.Rules.Stats.DerivedStats.Encumbrance(pc);
            string encTag = enc switch
            {
                Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Heavy => "  [encumbered]",
                Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Over  => "  [over-encumbered]",
                _ => "",
            };
            charBlock = $"{p.Name}  HP {pc.CurrentHp}/{pc.MaxHp}  AC {ac}{encTag}\n";
        }

        // Phase 5 M5: show "[F] Talk to ..." when a friendly/neutral is near.
        // Phase 6 M2: append the effective-disposition breakdown so the
        // player can see why an NPC is friendly/cool/hostile before talking.
        string interact = "";
        if (_interactCandidate is { } npc)
        {
            interact = $"\n[F] Talk to {npc.DisplayName}";
            if (p.Character is { } pcChar && _content is not null)
            {
                var br = Theriapolis.Core.Rules.Reputation.EffectiveDisposition.Breakdown(
                    npc, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
                interact += $"  ({Theriapolis.Core.Rules.Reputation.DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0})";
                interact += $"\n     clade {br.CladeBias:+#;-#;0}  size {br.SizeDifferential:+#;-#;0}  faction {br.FactionModifier:+#;-#;0}  personal {br.Personal:+#;-#;0}";

                // Phase 6 M5 — "why" breadcrumb. If the NPC has a settlement
                // home and a faction, show the most recent event reaching
                // them with its decay band so the player understands the
                // tooltip score.
                if (!string.IsNullOrEmpty(npc.FactionId) && npc.HomeSettlementId is { } hid
                    && _ctx.World.Settlements.FirstOrDefault(s => s.Id == hid) is { } home)
                {
                    var explained = Theriapolis.Core.Rules.Reputation.RepPropagation
                        .ExplainLocalStanding(npc.FactionId, home, _ctx.World.WorldSeed,
                            _reputation.Ledger, _content.Factions, max: 1)
                        .FirstOrDefault();
                    if (explained.Event is not null)
                    {
                        interact += $"\n     ↳ recent: {explained.Event.Note} "
                                  + $"({explained.Band}, {explained.LocalDelta:+#;-#;0})";
                    }
                }
            }
        }

        _hudLabel.Text =
            charBlock +
            $"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
            $"Player: ({ptx},{pty})  {t.Biome}\n" +
            $"{cursorBlock}\n" +
            $"View: {_camera.Mode}  zoom={_camera.Zoom:F2}\n" +
            $"Time: {_clock.Format()}\n" +
            $"{status}\n" +
            "F5 = Quicksave · TAB = Inventory · ESC = Pause Menu" + interact + toast;
    }

    /// <summary>
    /// Tactical cursor read-out: tactical coord, surface, deco, walkability,
    /// and the active flag set. SampleTile lazy-generates the chunk under the
    /// cursor if needed; the soft cache cap evicts it on the next stream sweep.
    /// </summary>
    private string FormatTacticalCursor()
    {
        int worldPxW = C.WORLD_WIDTH_TILES  * C.TACTICAL_PER_WORLD_TILE;
        int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
        if ((uint)_cursorTacticalX >= worldPxW || (uint)_cursorTacticalY >= worldPxH)
            return $"Cursor: ({_cursorTacticalX},{_cursorTacticalY})  <off-world>";

        var tt = _streamer.SampleTile(_cursorTacticalX, _cursorTacticalY);
        string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString();
        string pass = tt.IsWalkable ? "walkable" : "blocked";
        if (tt.SlowsMovement && tt.IsWalkable) pass = "slow";

        // Render flag set as a compact tag list (River, Road, Bridge, Settlement, Slow).
        var flags = (TacticalFlags)tt.Flags;
        string flagText = flags == TacticalFlags.None ? "" : $"  [{flags}]";

        return
            $"Cursor: ({_cursorTacticalX},{_cursorTacticalY})\n" +
            $"  Surface: {tt.Surface} (v{tt.Variant})\n" +
            $"  Deco:    {deco}\n" +
            $"  Move:    {pass}{flagText}";
    }

    public void Draw(GameTime gt, SpriteBatch _)
    {
        _game.GraphicsDevice.Clear(new Color(5, 10, 20));

        // Renderer swap — world-map view below the tactical-zoom threshold,
        // tactical above. WorldMapRenderer already includes its polyline pass.
        // In tactical mode the chunk gen has already burned roads/rivers into
        // surface tiles via TacticalChunkGen.Pass2_Polylines, so re-stroking
        // the polylines on top would double-draw the road and create visible
        // overlap artefacts.
        if (_camera.Mode == ViewMode.WorldMap)
        {
            _worldRenderer.Draw(_sb, _camera, gt);
        }
        else
        {
            _tacticalRenderer.Draw(_sb, _camera, gt);
        }
        // NPCs draw before the player so the player marker sits on top.
        _npcSprite.Draw(_sb, _camera, _actors.Npcs);
        _playerSprite.Draw(_sb, _camera, _actors.Player!);
        _overlayDesktop.Render();
    }

    public void Deactivate() { }
    public void Reactivate() { }

    ~PlayScreen()
    {
        _worldRenderer?.Dispose();
        _tacticalRenderer?.Dispose();
        _tacticalAtlas?.Dispose();
        _lineOverlay?.Dispose();
        _playerSprite?.Dispose();
        _npcSprite?.Dispose();
        _atlas?.Dispose();
    }
}
