using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Quests;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Settlements;
using Theriapolis.GodotHost.Input;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering;
using Theriapolis.GodotHost.UI;

namespace Theriapolis.GodotHost.Scenes;

/// <summary>
/// M7.2 + M7.3 — the play screen. Wraps <see cref="WorldRenderNode"/>
/// with the game-state layer: player actor, world clock, chunk
/// streamer, NPC markers, player controller, save layer, and a
/// top-left HUD overlay. Click on the world map to travel; WASD to
/// step at tactical zoom. F5 quicksaves; Esc returns to title (M7.4
/// will replace this with a pause menu).
///
/// Save round-trip (M7.3): <see cref="SaveTo"/> wraps
/// <see cref="SaveCodec.Serialize"/> + <see cref="SavePaths.WriteAtomic"/>.
/// <see cref="ApplyRestoredBody"/> on init consumes
/// <see cref="GameSession.PendingRestore"/> set by the
/// <see cref="SaveLoadScreen"/> hand-off; replaces the new-game spawn.
/// The save format is owned by Core and untouched — saves written by
/// the MonoGame build load here byte-identically and vice versa.
///
/// M7 sub-milestone status:
///   M7.4 (pause menu) — Esc still does quit-to-title for now.
///   M7.5 (interact prompt) — F-to-talk not yet wired.
///   M7.6 (encounter trigger stub) — hostile detection not yet wired.
/// </summary>
public partial class PlayScreen : Control
{
    private const float ClickSlopPixels = 4f;

    // Composed Core systems
    private WorldGenContext _ctx = null!;
    private ContentResolver _content = null!;
    private InMemoryChunkDeltaStore _deltas = null!;
    private ChunkStreamer _streamer = null!;
    private ActorManager _actors = null!;
    private WorldClock _clock = null!;
    private PlayerController _controller = null!;
    private AnchorRegistry _anchorRegistry = null!;
    private readonly PlayerReputation _reputation = new();
    private readonly Dictionary<string, int> _flags = new();
    private readonly QuestEngine _questEngine = new();
    private QuestContext? _questCtx;

    // M7.3 — save round-trip plumbing
    private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
    // Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
    // push. Captured by load, picked up after chunks load; M8 will turn
    // this into an actual push to the combat screen.
    private EncounterState? _pendingEncounterRestore;
    private float _saveFlashTimer;
    private string _saveFlashText = "";

    // Godot tree
    private WorldRenderNode _render = null!;
    private PlayerMarker _playerMarker = null!;
    private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
    private Label _hudLabel = null!;
    private PanelContainer _hudPanel = null!;
    private Label? _saveFlashLabel;

    // Click-vs-drag state (left-click only; PanZoomCamera handles
    // middle/right-drag pan independently).
    private Vector2 _mouseDownPos;
    private int _mouseDownTileX, _mouseDownTileY;
    private bool _mouseDownTracked;

    public override void _Ready()
    {
        var session = GameSession.From(this);
        if (session.Ctx is null)
        {
            GD.PushError("[play] No WorldGenContext on session — falling back to title.");
            BackToTitle();
            return;
        }
        _ctx = session.Ctx;

        Theme = CodexTheme.Build();
        SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);

        // World render layer — biome + polylines + settlements + camera.
        _render = new WorldRenderNode();
        AddChild(_render);
        _render.Initialize(_ctx.World);

        // Core systems.
        _content = new ContentResolver(
            new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
        _deltas = new InMemoryChunkDeltaStore();
        _streamer = new ChunkStreamer(
            _ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
        _streamer.OnChunkLoaded   += _render.AddChunkNode;
        _streamer.OnChunkEvicting += _render.RemoveChunkNode;
        _streamer.OnChunkLoaded   += HandleChunkLoaded;
        _streamer.OnChunkEvicting += HandleChunkEvicting;

        _clock = new WorldClock();
        _actors = new ActorManager();
        _anchorRegistry = new AnchorRegistry();
        _anchorRegistry.RegisterAllAnchors(_ctx.World);

        // Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/
        // world for the quest engine. Round-trips through the save body.
        _questCtx = new QuestContext(
            _content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);

        // Spawn or restore the player. Restore wins when a load was queued.
        if (session.PendingRestore is not null)
        {
            ApplyRestoredBody(session.PendingRestore);
        }
        else
        {
            var spawn = ChooseSpawn(_ctx.World);
            if (session.PendingCharacter is not null)
            {
                var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
                if (!string.IsNullOrWhiteSpace(session.PendingName))
                    p.Name = session.PendingName;
            }
            else
            {
                _actors.SpawnPlayer(spawn);
            }
        }

        _controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
        _controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;

        // Player marker.
        _playerMarker = new PlayerMarker
        {
            Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
            Rotation = _actors.Player.FacingAngleRad,
        };
        AddChild(_playerMarker);

        _render.Camera.Position = _playerMarker.Position;
        SetInitialZoom();
        BuildHud();

        // M7.5/M8 will pick up _pendingEncounterRestore here once the
        // combat HUD screen exists. For now we keep the snapshot on the
        // body so a re-save preserves it across the Godot↔MonoGame round
        // trip, but we don't attempt to resume combat.
        if (_pendingEncounterRestore is not null)
            GD.Print("[play] Loaded save has an active encounter — "
                   + "combat HUD ships with M8; encounter preserved through save round-trip.");

        // Clear pending so a quit-to-title doesn't see stale data.
        session.PendingCharacter = null;
        session.PendingName = "Wanderer";
        session.PendingRestore = null;
        session.PendingHeader = null;
    }

    public override void _Process(double delta)
    {
        if (_actors?.Player is null || _render is null) return;
        float dt = (float)delta;

        bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;

        // WASD is context-sensitive: tactical mode steps the player,
        // world-map mode pans the camera. Same keys, intent depends on zoom.
        float wasdX = 0f, wasdY = 0f;
        if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up))    wasdY -= 1f;
        if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down))  wasdY += 1f;
        if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left))  wasdX -= 1f;
        if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) wasdX += 1f;

        // Controller always ticks (path-follow runs even when WASD is idle).
        // Pass step input only in tactical mode.
        float stepX = tactical ? wasdX : 0f;
        float stepY = tactical ? wasdY : 0f;
        _controller.Update(dt, stepX, stepY, tactical, isFocused: true);

        // World-map WASD pan. Skip while traveling — the follow logic below
        // re-centres the camera on the player and would clobber the pan.
        // Speed scales inversely with zoom so the on-screen pan rate feels
        // consistent at any zoom level (matches MonoGame's 400 px/sec).
        if (!tactical && !_controller.IsTraveling && (wasdX != 0f || wasdY != 0f))
        {
            const float PanScreenPxPerSec = 400f;
            float invLen = (wasdX != 0f && wasdY != 0f) ? 0.70710678f : 1f;
            float panSpeed = PanScreenPxPerSec / Mathf.Max(_render.Camera.Zoom.X, 0.01f);
            _render.Camera.Position += new Vector2(wasdX * invLen, wasdY * invLen) * panSpeed * dt;
        }

        // Sync the player marker from Core state. Rotation drives the
        // facing tick via the transform — auto-property setters on a
        // PlayerMarker field would skip QueueRedraw and the cached
        // _Draw commands would stay stuck at the initial angle.
        var p = _actors.Player;
        _playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
        _playerMarker.Rotation = p.FacingAngleRad;

        // Camera follow when traveling or in tactical (matches MonoGame).
        if (_controller.IsTraveling || tactical)
            _render.Camera.Position = _playerMarker.Position;

        // Stream tactical chunks around the player when at tactical zoom.
        if (tactical)
            _streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);

        // Counter-scale markers so on-screen size stays constant.
        float zoom = _render.Camera.Zoom.X;
        if (zoom > 0f)
        {
            float inv = 1f / zoom;
            _playerMarker.Scale = new Vector2(inv, inv);
            _playerMarker.ShowFacingTick = tactical;
            foreach (var marker in _npcMarkers.Values)
                marker.Scale = new Vector2(inv, inv);
        }

        // Save-flash toast decay.
        if (_saveFlashTimer > 0f)
        {
            _saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
            if (_saveFlashLabel is not null)
            {
                _saveFlashLabel.Text = _saveFlashText;
                _saveFlashLabel.Modulate = new Color(1, 1, 1, Mathf.Min(1f, _saveFlashTimer / 0.5f));
                _saveFlashLabel.Visible = _saveFlashTimer > 0f;
            }
        }
        else if (_saveFlashLabel is not null && _saveFlashLabel.Visible)
        {
            _saveFlashLabel.Visible = false;
        }

        UpdateHud(tactical);
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
            return;

        if (mb.Pressed)
        {
            _mouseDownPos = mb.Position;
            var worldPos = ScreenToWorld(mb.Position);
            _mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
            _mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
            _mouseDownTracked = true;
            return;
        }

        if (!_mouseDownTracked) return;
        _mouseDownTracked = false;
        bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
        if (!wasClick) return;

        bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
        if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
        {
            _controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
            GetViewport().SetInputAsHandled();
        }
    }

    public override void _Input(InputEvent @event)
    {
        if (@event is not InputEventKey { Pressed: true } key) return;
        if (key.Echo) return;
        // Skip key events while the game is paused — the pause overlay
        // owns input handling for itself; PlayScreen shouldn't see Esc/F5
        // again until the overlay closes.
        if (GetTree().Paused) return;

        switch (key.Keycode)
        {
            case Key.F5:
                SaveTo(SavePaths.AutosavePath());
                GetViewport().SetInputAsHandled();
                break;
            case Key.Escape:
                GetViewport().SetInputAsHandled();
                AddChild(new PauseMenuScreen(this));
                break;
        }
    }

    /// <summary>Read-only accessor for the live player Character — used
    /// by <see cref="PauseMenuScreen"/> to surface the level-up affordance
    /// when eligible.</summary>
    public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
        => _actors?.Player?.Character;

    private Vector2 ScreenToWorld(Vector2 screenPos)
        => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;

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

    // ──────────────────────────────────────────────────────────────────────
    // Chunk → NPC lifecycle (Phase 5 M5)

    private void HandleChunkLoaded(TacticalChunk chunk)
    {
        if (_content is null) return;
        _killedByChunk.TryGetValue(chunk.Coord, out var killed);
        for (int i = 0; i < chunk.Spawns.Count; i++)
        {
            // Skip slots the player previously killed — they don't respawn
            // on chunk reload until the save is wiped.
            if (killed is not null && killed.Contains(i)) continue;
            var spawn = chunk.Spawns[i];
            if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;

            if (spawn.Kind == SpawnKind.Resident)
            {
                var resident = ResidentInstantiator.Spawn(
                    _ctx.World.WorldSeed, chunk, i, spawn,
                    _ctx.World, _content, _actors, _anchorRegistry);
                if (resident is not null) MountNpcMarker(resident);
                continue;
            }

            var template = 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;
            var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
            MountNpcMarker(npc);
        }
    }

    private void HandleChunkEvicting(TacticalChunk chunk)
    {
        var toRemove = new List<int>();
        foreach (var npc in _actors.Npcs)
        {
            if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
            {
                toRemove.Add(npc.Id);
                if (!string.IsNullOrEmpty(npc.RoleTag))
                    _anchorRegistry.UnregisterRole(npc.RoleTag);
            }
        }
        foreach (int id in toRemove)
        {
            _actors.RemoveActor(id);
            if (_npcMarkers.Remove(id, out var marker))
                marker.QueueFree();
        }
    }

    private void MountNpcMarker(NpcActor npc)
    {
        var marker = new NpcMarker
        {
            Position = new Vector2(npc.Position.X, npc.Position.Y),
            Allegiance = npc.Allegiance,
        };
        AddChild(marker);
        _npcMarkers[npc.Id] = marker;
    }

    // ──────────────────────────────────────────────────────────────────────
    // M7.3 — Save / Load

    /// <summary>Write the current state to the given slot path (atomic).</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}");
            GD.PushError($"[save] {ex}");
            return false;
        }
    }

    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>Build a save body from the current PlayScreen state. Mirrors
    /// the MonoGame source's <c>CaptureBody</c> field-by-field so the
    /// resulting byte stream is interoperable across builds.</summary>
    private SaveBody CaptureBody()
    {
        // Mid-combat snapshot — null in M7 (combat HUD doesn't exist yet),
        // but the field is preserved so a save loaded from MonoGame with
        // an active encounter round-trips back through Godot intact.
        EncounterState? activeEnc = _pendingEncounterRestore;

        // Push every loaded chunk through eviction so any in-memory deltas
        // land in the store before we read it.
        _streamer.FlushAll();

        var body = new SaveBody
        {
            Player = _actors.Player!.CaptureState(),
            Clock  = _clock.CaptureState(),
        };
        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;

        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;
        body.ReputationState = ReputationCodec.Capture(_reputation);
        body.Flags           = new Dictionary<string, int>(_flags);
        body.QuestEngineState = QuestCodec.Capture(_questEngine);
        return body;
    }

    /// <summary>Restore PlayScreen state from a deserialised body. Caller
    /// must have already set <c>_ctx</c>, <c>_content</c>, <c>_streamer</c>,
    /// <c>_actors</c>, <c>_clock</c>, and <c>_anchorRegistry</c> — this is
    /// invoked from <see cref="_Ready"/> after those are wired but before
    /// the player marker is created.</summary>
    private void ApplyRestoredBody(SaveBody body)
    {
        var player = _actors.RestorePlayer(body.Player);
        _clock.RestoreState(body.Clock);

        foreach (var kv in body.ModifiedChunks)
            _deltas.Put(kv.Key, kv.Value);

        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;
        }

        if (body.PlayerCharacter is not null)
            player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);

        _killedByChunk.Clear();
        foreach (var d in body.NpcRoster.ChunkDeltas)
        {
            var coord = new ChunkCoord(d.ChunkX, d.ChunkY);
            _killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
        }

        // Defer the mid-combat encounter restore until M8 wires the combat
        // HUD — but keep the body so a re-save round-trips byte-identical.
        _pendingEncounterRestore = body.ActiveEncounter;

        // Reputation aggregate — mutate the existing instance in place so
        // consumers holding a reference (future ReputationScreen / dialogue
        // runner) keep working.
        var restoredRep = 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);

        _flags.Clear();
        foreach (var (k, v) in body.Flags) _flags[k] = v;

        QuestCodec.Restore(_questEngine, body.QuestEngineState);
    }

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

    // ──────────────────────────────────────────────────────────────────────
    // Spawn + initial-zoom helpers

    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);
        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()
    {
        Vector2 viewport = GetViewport().GetVisibleRect().Size;
        float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
        targetZoom = Mathf.Clamp(targetZoom,
            _render.Camera.MinZoom,
            WorldRenderNode.TacticalRenderZoomMin * 0.95f);
        _render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
    }

    // ──────────────────────────────────────────────────────────────────────
    // HUD overlay (top-left panel, codex-styled) + save-flash toast

    private void BuildHud()
    {
        var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
        AddChild(hudLayer);

        _hudPanel = new PanelContainer
        {
            ThemeTypeVariation = "Card",
            MouseFilter = MouseFilterEnum.Ignore,
            OffsetLeft = 12, OffsetTop = 12,
            OffsetRight = 420, OffsetBottom = 220,
        };
        hudLayer.AddChild(_hudPanel);

        var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
        margin.AddThemeConstantOverride("margin_left", 12);
        margin.AddThemeConstantOverride("margin_top", 8);
        margin.AddThemeConstantOverride("margin_right", 12);
        margin.AddThemeConstantOverride("margin_bottom", 8);
        _hudPanel.AddChild(margin);

        _hudLabel = new Label
        {
            Text = "…",
            ThemeTypeVariation = "CardBody",
            AutowrapMode = TextServer.AutowrapMode.WordSmart,
            MouseFilter = MouseFilterEnum.Ignore,
        };
        margin.AddChild(_hudLabel);

        // Save-flash toast, mounted bottom-center on the same canvas
        // layer. Hidden by default; FlashSavedToast pops it in.
        _saveFlashLabel = new Label
        {
            Text = "",
            ThemeTypeVariation = "Eyebrow",
            HorizontalAlignment = HorizontalAlignment.Center,
            MouseFilter = MouseFilterEnum.Ignore,
            AnchorLeft = 0.5f, AnchorRight = 0.5f,
            AnchorTop = 1.0f, AnchorBottom = 1.0f,
            OffsetLeft = -180, OffsetRight = 180,
            OffsetTop = -56, OffsetBottom = -28,
            Visible = false,
        };
        hudLayer.AddChild(_saveFlashLabel);
    }

    private void UpdateHud(bool tactical)
    {
        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);
        int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
        int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
        ref var t = ref _ctx.World.TileAt(cx, cy);

        string charBlock = "";
        if (p.Character is { } pc)
        {
            int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
            charBlock = $"{p.Name}  HP {pc.CurrentHp}/{pc.MaxHp}  AC {ac}\n";
        }

        string viewBlock = tactical
            ? "View: Tactical   (WASD to step)"
            : "View: World Map  (WASD to pan · click a tile to travel)";

        string status = _controller.IsTraveling
            ? "Traveling…"
            : tactical
                ? "Mouse-wheel out to leave tactical."
                : "Mouse-wheel in for tactical.";

        _hudLabel.Text =
            charBlock +
            $"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
            $"Player: ({ptx}, {pty})  {t.Biome}\n" +
            $"{viewBlock}\n" +
            $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
            $"{status}\n" +
            "F5 quicksaves · Esc opens pause";
    }

    // ──────────────────────────────────────────────────────────────────────
    // Quit path

    private void BackToTitle()
    {
        var session = GameSession.From(this);
        session.ClearPending();
        session.Ctx = null;

        var parent = GetParent();
        if (parent is null) return;
        foreach (Node sibling in parent.GetChildren())
            if (sibling != this) sibling.QueueFree();
        parent.AddChild(new TitleScreen());
        QueueFree();
    }
}
