# =============================================================================
# render/renderer.py  —  EcoForge "World Balance Architects"
# Elegant pixel-art renderer using pygame-ce + Pillow
#
# Features:
#   • Pixel-art terrain tiles (48×48, generated by Pillow)
#   • Animated water (2-frame oscillation)
#   • Day/Night cycle tint (every 20 turns)
#   • FloatingText  — "+Food", "+O₂", etc. rising and fading
#   • CameraShake   — triggered on critical events
#   • Cell highlight — glowing border on last-played tile
#   • Particle burst — on harvest / plant actions
#   • Pulsing meter  — flashes red when any resource < 20
#   • AI "thinking"  — animated dot while agent computes
#   • Game-over overlay with winner banner
#   • Scanline + vignette post-FX for retro feel
# =============================================================================

from __future__ import annotations
import math, os, sys
from array import array
import pygame
from PIL import Image as PILImage

try:
    from .animations import WaterAnimator, FloatingText, Particle, CameraShake, AmbientOrb
except ImportError:
    # Fallback for direct script execution outside package context.
    from animations import WaterAnimator, FloatingText, Particle, CameraShake, AmbientOrb

# ── PATH SETUP ────────────────────────────────────────────────────────────────
_HERE       = os.path.dirname(os.path.abspath(__file__))
_ROOT       = os.path.dirname(_HERE)
ASSETS_DIR  = os.path.join(_ROOT, "assets", "tiles")
BGS_ASSET_DIR = os.path.join(_ROOT, "assets", "bgs")
AGENT_ASSET_DIR = os.path.join(_ROOT, "assets", "agent_images")

_AGENT_FILENAME_CACHE: dict[str, str | None] = {}
_AGENT_IMAGE_CACHE: dict[tuple[str, int], pygame.Surface | None] = {}

# ── IMPORT CONFIG (fall back to sensible defaults) ────────────────────────────
try:
    sys.path.insert(0, _ROOT)
    from config import (
        GRID_WIDTH, GRID_HEIGHT, TILE_SIZE,
        SCREEN_WIDTH, SCREEN_HEIGHT, MAX_TURNS,
        TERRAIN_LAND, TERRAIN_RIVER, TERRAIN_FARM,
        TERRAIN_FOREST, TERRAIN_RESERVOIR, TERRAIN_SOLAR,
        AGENT_A, AGENT_B,
        AGENT_A_COLOR, AGENT_B_COLOR,
        STABILITY_HIGH, STABILITY_MODERATE, STABILITY_LOW,
        UI_PANEL_X, UI_PANEL_WIDTH,
    )
except ImportError:
    # Sensible defaults so the renderer can be tested standalone
    GRID_WIDTH, GRID_HEIGHT, TILE_SIZE = 25, 18, 48
    SCREEN_WIDTH, SCREEN_HEIGHT        = 1480, 900
    MAX_TURNS                          = 150
    TERRAIN_LAND, TERRAIN_RIVER        = "land",    "river"
    TERRAIN_FARM, TERRAIN_FOREST       = "farm",    "forest"
    TERRAIN_RESERVOIR, TERRAIN_SOLAR   = "reservoir","solar_plant"
    AGENT_A, AGENT_B                   = "A", "B"
    AGENT_A_COLOR, AGENT_B_COLOR       = (220,80,60),(60,140,220)
    STABILITY_HIGH, STABILITY_MODERATE = 0.75, 0.50
    STABILITY_LOW                      = 0.25
    UI_PANEL_X   = GRID_WIDTH * TILE_SIZE
    UI_PANEL_WIDTH = SCREEN_WIDTH - UI_PANEL_X

GRID_PX_W = GRID_WIDTH  * TILE_SIZE
GRID_PX_H = GRID_HEIGHT * TILE_SIZE
BOTTOM_TURN_BAR_H = 132

# ── COLOUR PALETTE ────────────────────────────────────────────────────────────
C_BG          = ( 12,  14,  22)   # deep space background
C_PANEL       = ( 18,  22,  36)   # UI panel
C_PANEL_DARK  = ( 12,  15,  26)   # panel header
C_BORDER      = ( 40,  48,  72)   # panel border
C_TEXT        = (200, 210, 230)   # primary text
C_TEXT_DIM    = (110, 125, 160)   # secondary text
C_TEXT_BRIGHT = (240, 245, 255)   # heading text
C_ACCENT      = ( 80, 180, 255)   # blue accent
C_GOOD        = ( 80, 220, 130)   # green — healthy
C_WARN        = (220, 200,  60)   # yellow — caution
C_DANGER      = (220,  70,  60)   # red — critical
C_GOLD        = (255, 210,  60)   # gold — score / win

METER_COLORS = {
    "water":   ( 60, 160, 255),
    "food":    (100, 220,  80),
    "oxygen":  (100, 230, 210),
    "temp":    (255, 140,  60),
}

DAY_TINTS = [
    (255, 255, 255, 0  ),   # day      — no tint
    (255, 190, 120, 45 ),   # dusk     — warm amber
    ( 40,  50, 110, 70 ),   # night    — deep blue
    (180, 130, 200, 35 ),   # dawn     — soft violet
]

DAY_PHASES = [
    ("Morning", (245, 235, 170)),
    ("Afternoon", (255, 195, 120)),
    ("Night", (145, 170, 255)),
    ("Dawn", (220, 170, 240)),
]


# =============================================================================
# ASSET LOADER
# =============================================================================

def _pil_to_pygame(pil_img: PILImage.Image) -> pygame.Surface:
    """Convert a PIL RGBA image to a pygame Surface."""
    raw  = pil_img.convert("RGBA").tobytes()
    surf = pygame.image.frombytes(raw, pil_img.size, "RGBA").convert_alpha()
    return surf


def _load_tile(name: str) -> pygame.Surface | None:
    path = os.path.join(ASSETS_DIR, f"{name}.png")
    if not os.path.exists(path):
        return None
    pil = PILImage.open(path)
    return _pil_to_pygame(pil)


def _load_preview_sheet_tile(sheet_name: str, index: int) -> pygame.Surface | None:
    """Crop a 48x48 tile from a preview sheet when the standalone tile is missing."""
    path = os.path.join(ASSETS_DIR, f"{sheet_name}.png")
    if not os.path.exists(path):
        return None

    pil = PILImage.open(path).convert("RGBA")
    tile_size = 48
    left = index * tile_size
    box = (left, 0, left + tile_size, tile_size)
    if box[2] > pil.width or box[3] > pil.height:
        return None
    return _pil_to_pygame(pil.crop(box))


def _scale_tile(surf: pygame.Surface, size: int) -> pygame.Surface:
    """Nearest-neighbour upscale — keeps pixel-art crisp."""
    if surf.get_width() == size and surf.get_height() == size:
        return surf
    raw = pygame.image.tobytes(surf, "RGBA")
    pil = PILImage.frombytes("RGBA", surf.get_size(), raw)
    resized = pil.resize((size, size), PILImage.NEAREST)
    return _pil_to_pygame(resized)


def _resolve_agent_image_filename(agent_type: str) -> str | None:
    if agent_type in _AGENT_FILENAME_CACHE:
        return _AGENT_FILENAME_CACHE[agent_type]

    if not os.path.isdir(AGENT_ASSET_DIR):
        _AGENT_FILENAME_CACHE[agent_type] = None
        return None

    files = [f for f in os.listdir(AGENT_ASSET_DIR)
             if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp"))]

    keys = {
        "minimax": ["minimax"],
        "montecarlo": ["montecarlo", "monte_carlo", "monte"],
        "qlearning": ["qlearning", "q_learning"],
        "dqn": ["dqn"],
    }.get(agent_type, [agent_type])

    for f in files:
        n = f.lower().replace(" ", "").replace("-", "_")
        if any(k in n for k in keys):
            _AGENT_FILENAME_CACHE[agent_type] = f
            return f
    _AGENT_FILENAME_CACHE[agent_type] = None
    return None


def _load_agent_image(agent_type: str, size: int) -> pygame.Surface | None:
    cache_key = (agent_type, size)
    if cache_key in _AGENT_IMAGE_CACHE:
        return _AGENT_IMAGE_CACHE[cache_key]

    fname = _resolve_agent_image_filename(agent_type)
    if fname is None:
        _AGENT_IMAGE_CACHE[cache_key] = None
        return None
    path = os.path.join(AGENT_ASSET_DIR, fname)
    try:
        surf = pygame.image.load(path).convert_alpha()
        if surf.get_width() != size or surf.get_height() != size:
            surf = pygame.transform.scale(surf, (size, size))
        _AGENT_IMAGE_CACHE[cache_key] = surf
        return surf
    except Exception:
        _AGENT_IMAGE_CACHE[cache_key] = None
        return None


# =============================================================================
# MAIN RENDERER
# =============================================================================

class Renderer:
    """
    Drop-in replacement for the original Renderer class.
    Call  renderer.draw(world)  once per frame.
    For game-over:  renderer.draw_game_over(world, winner)
    To spawn FX:    renderer.add_float(text, col, row, color)
                    renderer.add_particles(col, row, color, count)
                    renderer.camera_shake.trigger()
    """

    # ── INIT ─────────────────────────────────────────────────────────────────

    def __init__(self, screen: pygame.Surface):
        self.screen       = screen
        self._water       = WaterAnimator()
        self.camera_shake = CameraShake()
        self._floats:    list[FloatingText] = []
        self._particles: list[Particle]     = []
        self._last_action                   = None   # (row, col, age)
        self._thinking_agent: str | None    = None
        self._ticks = 0
        self._ambient_orbs: list[AmbientOrb] = []
        self._agent_types = {AGENT_A: "minimax", AGENT_B: "montecarlo"}
        self._agent_portraits: dict[str, pygame.Surface | None] = {AGENT_A: None, AGENT_B: None}
        self._agent_tokens: dict[str, pygame.Surface | None] = {AGENT_A: None, AGENT_B: None}
        self._agent_mini_tokens: dict[str, pygame.Surface | None] = {AGENT_A: None, AGENT_B: None}
        self._agent_token_pos = {
            AGENT_A: [1.5 * TILE_SIZE, 1.5 * TILE_SIZE],
            AGENT_B: [(GRID_WIDTH - 1.5) * TILE_SIZE, (GRID_HEIGHT - 1.5) * TILE_SIZE],
        }
        self._agent_target_pos = {AGENT_A: None, AGENT_B: None}
        self._agent_action_cell = {AGENT_A: None, AGENT_B: None}
        self._agent_dock_pending_cell = {AGENT_A: None, AGENT_B: None}
        self._agent_dock_delay_frames = {AGENT_A: 0, AGENT_B: 0}
        self._audio_ready = False
        self._sfx: dict[str, pygame.mixer.Sound] = {}
        self._critical_alarm_active = False
        self._critical_alarm_cooldown_frames = 0
        self._turn_banner_agent_id: str | None = None
        self._turn_banner_agent_name: str = ""
        self._turn_banner_action_text: str = ""
        self._bottom_banner_bg: pygame.Surface | None = None
        self._soil_gap_bg: pygame.Surface | None = None

        self._load_fonts()
        self._init_audio()
        self._load_tiles()
        self._load_backgrounds()
        self._build_static_surfaces()
        # Ambient orbs disabled to avoid distracting bubble-like motion.
        self._ambient_orbs = []
        self.set_agent_visuals(self._agent_types)

    def _load_fonts(self):
        pygame.font.init()
        # Try to load a pixel font; fall back to built-in
        pixel_font_path = os.path.join(_ROOT, "assets", "fonts", "Pixel Game.otf")
        try:
            self.font_xs  = pygame.font.Font(pixel_font_path, 10)
            self.font_sm  = pygame.font.Font(pixel_font_path, 13)
            self.font_md  = pygame.font.Font(pixel_font_path, 16)
            self.font_lg  = pygame.font.Font(pixel_font_path, 22)
            self.font_xl  = pygame.font.Font(pixel_font_path, 30)
            # Right-panel-specific fonts: intentionally larger for readability.
            self.ui_xs    = pygame.font.Font(pixel_font_path, 13)
            self.ui_sm    = pygame.font.Font(pixel_font_path, 16)
            self.ui_md    = pygame.font.Font(pixel_font_path, 19)
            self.ui_lg    = pygame.font.Font(pixel_font_path, 25)
            print(f"[FONT] Loaded Pixel Game.otf from: {pixel_font_path}")
        except Exception as e:
            print(f"[FONT] Failed to load {pixel_font_path}: {e}")
            print(f"[FONT] Fallback to Consolas,monospace")
            self.font_xs  = pygame.font.SysFont("Consolas,monospace", 11)
            self.font_sm  = pygame.font.SysFont("Consolas,monospace", 13)
            self.font_md  = pygame.font.SysFont("Consolas,monospace", 15)
            self.font_lg  = pygame.font.SysFont("Consolas,monospace", 20, bold=True)
            self.font_xl  = pygame.font.SysFont("Consolas,monospace", 28, bold=True)
            self.ui_xs    = pygame.font.SysFont("Consolas,monospace", 13)
            self.ui_sm    = pygame.font.SysFont("Consolas,monospace", 16, bold=True)
            self.ui_md    = pygame.font.SysFont("Consolas,monospace", 19, bold=True)
            self.ui_lg    = pygame.font.SysFont("Consolas,monospace", 24, bold=True)

    def _init_audio(self):
        """Initialize mixer and procedural SFX. Fails silently if audio is unavailable."""
        try:
            if pygame.mixer.get_init() is None:
                pygame.mixer.init(frequency=22050, size=-16, channels=1, buffer=512)
            self._audio_ready = pygame.mixer.get_init() is not None
        except Exception:
            self._audio_ready = False
            return

        if not self._audio_ready:
            return

        try:
            self._sfx["harvest_chime"] = self._make_sequence([
                (880.0, 0.11, 10.0, 0.35),
                (1174.0, 0.14, 12.0, 0.34),
            ], gap_seconds=0.012)
            self._sfx["build_thud"] = self._make_sequence([
                (140.0, 0.09, 18.0, 0.45),
                (90.0,  0.08, 20.0, 0.34),
            ], gap_seconds=0.0)
            self._sfx["critical_alarm"] = self._make_sequence([
                (760.0, 0.09, 16.0, 0.34),
                (620.0, 0.09, 16.0, 0.34),
            ], gap_seconds=0.04)
        except Exception:
            self._audio_ready = False
            self._sfx.clear()

    def _make_sequence(self, notes: list[tuple[float, float, float, float]], gap_seconds: float = 0.0) -> pygame.mixer.Sound:
        """Build a short procedural sound sequence from sine tones."""
        sample_rate = 22050
        pcm = array("h")
        gap_samples = int(sample_rate * max(0.0, gap_seconds))

        for idx, (freq, duration, decay, volume) in enumerate(notes):
            count = max(1, int(sample_rate * max(0.01, duration)))
            for i in range(count):
                t = i / sample_rate
                env = math.exp(-decay * t)
                base = math.sin(2.0 * math.pi * freq * t)
                harmonic = 0.35 * math.sin(2.0 * math.pi * (freq * 2.0) * t)
                sample = (base + harmonic) * env * volume
                sample = max(-1.0, min(1.0, sample))
                pcm.append(int(sample * 32767))

            if gap_samples > 0 and idx < len(notes) - 1:
                pcm.extend([0] * gap_samples)

        return pygame.mixer.Sound(buffer=pcm.tobytes())

    def _play_sfx(self, key: str):
        if not self._audio_ready:
            return
        s = self._sfx.get(key)
        if s is not None:
            s.play()

    def _maybe_play_critical_alarm(self, is_critical_now: bool):
        if self._critical_alarm_cooldown_frames > 0:
            self._critical_alarm_cooldown_frames -= 1

        if is_critical_now:
            if (not self._critical_alarm_active) or self._critical_alarm_cooldown_frames == 0:
                self._play_sfx("critical_alarm")
                self._critical_alarm_cooldown_frames = 120
            self._critical_alarm_active = True
        else:
            self._critical_alarm_active = False

    def _load_tiles(self):
        """Load pixel-art tile PNGs (generated by Pillow) and scale to TILE_SIZE."""
        T = TILE_SIZE
        def L(name): s = _load_tile(name); return _scale_tile(s, T) if s else None

        self._tile: dict[str, pygame.Surface | None] = {
            TERRAIN_LAND:       L("land"),
            TERRAIN_RIVER:      L("river_1"),     # frame 0
            "river_2":          L("river_2"),  # frame 1
            TERRAIN_FARM:       L("farm"),
            TERRAIN_FOREST:     L("forest"),
            TERRAIN_RESERVOIR:  L("reservoir"),
            TERRAIN_SOLAR:      L("solar_plant"),
            # overlays
            "farm_s1":          L("farm_stage_1"),
            "farm_s2":          L("farm_stage_2"),
            "farm_s3":          L("farm_stage_3"),
            "forest_m2":        L("forest_mat_2"),
            "forest_m3":        L("forest_mat_3"),
            "highlight":        L("highlight"),
            "highlight_green":  L("highlight_green"),
            "owner_a":          L("owner_a_tint"),
            "owner_b":          L("owner_b_tint"),
        }

        # Generate fallback coloured rects for any missing tiles
        FALLBACK_COLORS = {
            TERRAIN_LAND:      (104, 168,  72),
            TERRAIN_RIVER:     ( 40, 100, 180),
            TERRAIN_FARM:      (180, 130,  50),
            TERRAIN_FOREST:    ( 40, 100,  40),
            TERRAIN_RESERVOIR: ( 60, 100, 160),
            TERRAIN_SOLAR:     (180, 180,  60),
        }
        for key, color in FALLBACK_COLORS.items():
            if self._tile.get(key) is None:
                surf = pygame.Surface((T, T))
                surf.fill(color)
                self._tile[key] = surf

    def _load_backgrounds(self):
        """Load non-tile UI backgrounds."""
        bottom_path = os.path.join(BGS_ASSET_DIR, "bottom panel bg.png")
        if os.path.exists(bottom_path):
            try:
                self._bottom_banner_bg = pygame.image.load(bottom_path).convert_alpha()
            except Exception:
                self._bottom_banner_bg = None
        else:
            self._bottom_banner_bg = None

        soil_path = os.path.join(BGS_ASSET_DIR, "soil.png")
        if os.path.exists(soil_path):
            try:
                self._soil_gap_bg = pygame.image.load(soil_path).convert_alpha()
            except Exception:
                self._soil_gap_bg = None
        else:
            self._soil_gap_bg = None

    def _build_static_surfaces(self):
        """Pre-render surfaces that don't change each frame."""
        T  = TILE_SIZE
        PW = UI_PANEL_WIDTH

        # Scanline overlay for the grid area (retro effect)
        self._scanlines = pygame.Surface((GRID_PX_W, GRID_PX_H), pygame.SRCALPHA)
        for y in range(0, GRID_PX_H, 2):
            pygame.draw.line(self._scanlines, (0,0,0,30), (0,y), (GRID_PX_W,y))

        # Vignette overlay for the grid
        self._vignette = pygame.Surface((GRID_PX_W, GRID_PX_H), pygame.SRCALPHA)
        cx, cy = GRID_PX_W // 2, GRID_PX_H // 2
        for r in range(max(cx,cy), 0, -4):
            alpha = max(0, int(80 * (r / max(cx,cy)) - 30))
            # draw rings from outside in
            pygame.draw.ellipse(self._vignette, (0,0,0,alpha),
                                (cx-r, cy-r//2, r*2, r), 4)

        # Owner tint fallbacks (in case PNGs didn't load)
        if self._tile.get("owner_a") is None:
            s = pygame.Surface((T,T), pygame.SRCALPHA)
            s.fill((*AGENT_A_COLOR, 55))
            pygame.draw.rect(s, (*AGENT_A_COLOR, 200), s.get_rect(), 2)
            self._tile["owner_a"] = s
        if self._tile.get("owner_b") is None:
            s = pygame.Surface((T,T), pygame.SRCALPHA)
            s.fill((*AGENT_B_COLOR, 55))
            pygame.draw.rect(s, (*AGENT_B_COLOR, 200), s.get_rect(), 2)
            self._tile["owner_b"] = s

        # Pre-scale tiny legend icons once to avoid per-frame scaling cost.
        self._legend_icons: dict[str, pygame.Surface] = {}
        for terrain in [
            TERRAIN_LAND,
            TERRAIN_RIVER,
            TERRAIN_FARM,
            TERRAIN_FOREST,
            TERRAIN_RESERVOIR,
            TERRAIN_SOLAR,
        ]:
            tile = self._tile.get(terrain)
            if tile is not None:
                self._legend_icons[terrain] = pygame.transform.scale(tile, (14, 14))

    def _build_ambient_layer(self):
        """Create slow-moving glows that keep the board feeling alive."""
        self._ambient_orbs = [
            AmbientOrb((GRID_PX_W, GRID_PX_H), (80, 180, 255), 42, (0.35, 0.12)),
            AmbientOrb((GRID_PX_W, GRID_PX_H), (80, 220, 130), 34, (-0.22, 0.18)),
            AmbientOrb((GRID_PX_W, GRID_PX_H), (255, 210, 90), 28, (0.18, -0.14)),
        ]

    # ── PUBLIC API ────────────────────────────────────────────────────────────

    def set_last_action(self, row: int, col: int):
        """Call after each agent move to highlight that cell."""
        self._last_action = [row, col, 0, "highlight"]

    def set_agent_visuals(self, agent_types: dict[str, str]):
        """Bind selected agent types to portraits/tokens for panel and map animation."""
        mini_size = max(8, TILE_SIZE // 4)
        for agent_id in (AGENT_A, AGENT_B):
            atype = agent_types.get(agent_id, self._agent_types.get(agent_id, "minimax"))
            self._agent_types[agent_id] = atype
            self._agent_portraits[agent_id] = _load_agent_image(atype, 40)
            self._agent_tokens[agent_id] = _load_agent_image(atype, 24)
            token = self._agent_tokens[agent_id]
            self._agent_mini_tokens[agent_id] = (
                pygame.transform.scale(token, (mini_size, mini_size)) if token is not None else None
            )

    def set_turn_banner(self, agent_id: str | None, agent_name: str = "", action_text: str = ""):
        """Set the bottom turn banner to show the active agent and current action."""
        self._turn_banner_agent_id = agent_id if agent_id in (AGENT_A, AGENT_B) else None
        self._turn_banner_agent_name = agent_name
        self._turn_banner_action_text = action_text

    def clear_turn_banner(self):
        self._turn_banner_agent_id = None
        self._turn_banner_agent_name = ""
        self._turn_banner_action_text = ""

    def move_agent_to(self, agent_id: str, col: int, row: int):
        """Queue movement toward a target cell before action effects are rendered."""
        if agent_id not in (AGENT_A, AGENT_B):
            return

        # Leaving the previous action tile: resume normal-sized traveling token.
        self._agent_action_cell[agent_id] = None
        self._agent_dock_pending_cell[agent_id] = None
        self._agent_dock_delay_frames[agent_id] = 0

        tx = col * TILE_SIZE + TILE_SIZE / 2
        ty = row * TILE_SIZE + TILE_SIZE / 2
        pos = self._agent_token_pos[agent_id]
        dx = tx - pos[0]
        dy = ty - pos[1]
        dist = math.hypot(dx, dy)

        # If very far away, pre-position near target so arrival feels synced to the action.
        if dist > TILE_SIZE * 7 and dist > 0:
            near_dist = TILE_SIZE * 2.5
            pos[0] = tx - (dx / dist) * near_dist
            pos[1] = ty - (dy / dist) * near_dist

        self._agent_target_pos[agent_id] = [tx, ty]

    def set_thinking(self, agent: str | None):
        """Pass the current agent name while it's computing; None to clear."""
        self._thinking_agent = agent

    def add_float(self, text: str, col: int, row: int,
                  color: tuple = (255, 255, 200)):
        self._floats.append(FloatingText(text, col, row, TILE_SIZE, color))

    def add_particles(self, col: int, row: int, color: tuple, count: int = 8):
        cx = col * TILE_SIZE + TILE_SIZE // 2
        cy = row * TILE_SIZE + TILE_SIZE // 2
        for _ in range(count):
            self._particles.append(Particle((cx, cy), color))

    # ── MAIN DRAW ─────────────────────────────────────────────────────────────

    def draw(self, world):
        """Full frame render. Call once per game-loop iteration."""
        self._ticks += 1
        self._water.update()

        ox, oy = self.camera_shake.offset()

        # Render grid to an off-screen surface, then blit with shake offset
        grid_surf = pygame.Surface((GRID_PX_W, GRID_PX_H))
        grid_surf.fill(C_BG)
        self._draw_tiles(grid_surf, world)
        self._draw_owner_tints(grid_surf, world)
        self._draw_grid_lines(grid_surf)
        self._draw_action_highlight(grid_surf)
        self._apply_day_night(grid_surf, world)
        grid_surf.blit(self._scanlines, (0, 0))

        self.screen.fill(C_BG)
        self.screen.blit(grid_surf, (ox, oy))
        self._draw_gap_strip()
        self._update_agent_tokens()
        self._draw_agent_tokens(ox, oy)

        # Particles and floats drawn directly on screen (not shaken)
        self._update_and_draw_particles()
        self._update_and_draw_floats()

        self._draw_ui_panel(world)
        self._draw_turn_banner(world)
        self._draw_hover_tooltip(world, ox, oy)
        self._tick_last_action()

    def _draw_gap_strip(self):
        """Fill space between grid and bottom turn banner."""
        gap_top = GRID_PX_H
        gap_bottom = SCREEN_HEIGHT - BOTTOM_TURN_BAR_H
        gap_h = gap_bottom - gap_top
        if gap_h <= 0:
            return

        rect = pygame.Rect(0, gap_top, GRID_PX_W, gap_h)
        if self._soil_gap_bg is not None:
            img = self._soil_gap_bg
            if img.get_width() != rect.width or img.get_height() != rect.height:
                img = pygame.transform.scale(img, (rect.width, rect.height))
            self.screen.blit(img, rect.topleft)
            shade = pygame.Surface((rect.width, rect.height), pygame.SRCALPHA)
            shade.fill((10, 10, 14, 64))
            self.screen.blit(shade, rect.topleft)
        else:
            pygame.draw.rect(self.screen, (28, 24, 18), rect)

    def _update_agent_tokens(self):
        speed = 16.0
        for agent_id in (AGENT_A, AGENT_B):
            # Small delay at action cell in normal size before docking to corner.
            if self._agent_dock_delay_frames[agent_id] > 0:
                self._agent_dock_delay_frames[agent_id] -= 1
                if self._agent_dock_delay_frames[agent_id] == 0:
                    self._agent_action_cell[agent_id] = self._agent_dock_pending_cell[agent_id]
                    self._agent_dock_pending_cell[agent_id] = None

            target = self._agent_target_pos.get(agent_id)
            if target is None:
                continue
            pos = self._agent_token_pos[agent_id]
            dx = target[0] - pos[0]
            dy = target[1] - pos[1]
            dist = math.hypot(dx, dy)
            if dist <= speed or dist == 0:
                pos[0], pos[1] = target[0], target[1]
                self._agent_target_pos[agent_id] = None
            else:
                pos[0] += dx / dist * speed
                pos[1] += dy / dist * speed

    def _draw_agent_tokens(self, ox: int, oy: int):
        for agent_id in (AGENT_A, AGENT_B):
            # After an action, keep a small docked token in the tile corner
            # until the agent starts moving for its next action.
            if self._agent_target_pos[agent_id] is None and self._agent_action_cell[agent_id] is not None:
                col, row = self._agent_action_cell[agent_id]
                cell_x = int(col * TILE_SIZE + ox)
                cell_y = int(row * TILE_SIZE + oy)

                token = self._agent_tokens.get(agent_id)
                if token is not None:
                    mini = self._agent_mini_tokens.get(agent_id)
                    if mini is None:
                        continue
                    if agent_id == AGENT_A:
                        draw_x = cell_x + 2
                    else:
                        draw_x = cell_x + TILE_SIZE - mini.get_width() - 2
                    draw_y = cell_y + 2
                    self.screen.blit(mini, (draw_x, draw_y))
                    # thin outline helps visibility while keeping action art readable
                    pygame.draw.rect(
                        self.screen,
                        (250, 250, 250),
                        (draw_x - 1, draw_y - 1, mini.get_width() + 2, mini.get_height() + 2),
                        1,
                    )
                else:
                    color = AGENT_A_COLOR if agent_id == AGENT_A else AGENT_B_COLOR
                    if agent_id == AGENT_A:
                        cx = cell_x + 6
                    else:
                        cx = cell_x + TILE_SIZE - 6
                    cy = cell_y + 6
                    pygame.draw.circle(self.screen, color, (cx, cy), 4)
                continue

            px, py = self._agent_token_pos[agent_id]
            token = self._agent_tokens.get(agent_id)
            if token is not None:
                self.screen.blit(token, (int(px - token.get_width() // 2 + ox), int(py - token.get_height() // 2 + oy)))
            else:
                color = AGENT_A_COLOR if agent_id == AGENT_A else AGENT_B_COLOR
                pygame.draw.circle(self.screen, color, (int(px + ox), int(py + oy)), 8)

    def draw_game_over(self, world, winner: str):
        """Call instead of draw() on the final frame."""
        self.draw(world)   # draw the final game state underneath
        self._draw_gameover_overlay(world, winner)

    # ── TILE DRAWING ──────────────────────────────────────────────────────────

    def _draw_tiles(self, surf: pygame.Surface, world):
        for r in range(GRID_HEIGHT):
            for c in range(GRID_WIDTH):
                cell    = world.grid[r][c]
                terrain = cell.terrain
                if terrain == TERRAIN_RIVER:
                    self._draw_river_flow(surf, c, r)
                    continue
                else:
                    key = terrain
                tile    = self._tile.get(key) or self._tile.get(terrain)
                if tile:
                    surf.blit(tile, (c * TILE_SIZE, r * TILE_SIZE))

                # Reservoir tile: animate only the inner water body.
                if terrain == TERRAIN_RESERVOIR:
                    self._draw_reservoir_inner_water(surf, c, r)

                # Solar tile: subtle moving glint across panel cells.
                if terrain == TERRAIN_SOLAR:
                    self._draw_solar_glint(surf, c, r)

                # Stage / maturity overlays
                if terrain == TERRAIN_FARM:
                    s = getattr(cell, "crop_stage", 0)
                    if 1 <= s <= 3:
                        ov = self._tile.get(f"farm_s{s}")
                        if ov:
                            pulse = 0.90 + 0.10 * math.sin(self._ticks * 0.10 + r * 0.25 + c * 0.17)
                            alpha = {1: 150, 2: 195, 3: 225}.get(s, 180)
                            copy = ov.copy()
                            copy.set_alpha(int(alpha * pulse))
                            surf.blit(copy, (c * TILE_SIZE, r * TILE_SIZE))
                elif terrain == TERRAIN_FOREST:
                    m = getattr(cell, "forest_maturity", 1)
                    if m >= 2:
                        ov = self._tile.get(f"forest_m{min(m,3)}")
                        if ov:
                            pulse = 0.92 + 0.08 * math.sin(self._ticks * 0.08 + r * 0.21 + c * 0.13)
                            alpha = {2: 190, 3: 220}.get(m, 190)
                            copy = ov.copy()
                            copy.set_alpha(int(alpha * pulse))
                            surf.blit(copy, (c * TILE_SIZE, r * TILE_SIZE))

    def _draw_river_flow(self, surf: pygame.Surface, col: int, row: int):
        """Render river tiles with scrolling flow, matching reservoir water motion style."""
        x = col * TILE_SIZE
        y = row * TILE_SIZE

        src = self._tile.get(TERRAIN_RIVER)
        if src is None:
            tile = self._tile.get(TERRAIN_RIVER)
            if tile is not None:
                surf.blit(tile, (x, y))
            return

        tile_surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)

        # Scroll water texture through the tile so rivers feel like active flow.
        offset = int((self._ticks * 0.7 + (row * 5 + col * 3)) % TILE_SIZE)
        for dx in (-offset, TILE_SIZE - offset):
            tile_surf.blit(src, (dx, 0), area=pygame.Rect(0, 0, TILE_SIZE, TILE_SIZE))

        # Keep river tone close to existing art and avoid over-bright results.
        tone = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
        tone.fill((255, 255, 255, 195))
        tile_surf.blit(tone, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)

        surf.blit(tile_surf, (x, y))

    def _draw_reservoir_inner_water(self, surf: pygame.Surface, col: int, row: int):
        """Animate the water area inside the stone reservoir walls."""
        x = col * TILE_SIZE
        y = row * TILE_SIZE

        src = self._tile.get(TERRAIN_RIVER)
        if src is None:
            return

        # Inner basin area of the reservoir tile art.
        water_rect = pygame.Rect(x + 7, y + 9, TILE_SIZE - 14, TILE_SIZE - 18)

        # Draw into a local clipped surface first, so water never spills outside.
        basin = pygame.Surface((water_rect.w, water_rect.h), pygame.SRCALPHA)

        # Scroll ripple pattern slowly for animated inner water feel.
        offset = int((self._ticks * 0.45 + (row * 3 + col * 2)) % TILE_SIZE)
        for dx in (-offset, TILE_SIZE - offset):
            basin.blit(src, (dx, 0), area=pygame.Rect(0, 0, TILE_SIZE, water_rect.h))

        # Slightly dim the water so stone borders remain visually dominant.
        tone = pygame.Surface((water_rect.w, water_rect.h), pygame.SRCALPHA)
        tone.fill((255, 255, 255, 180))
        basin.blit(tone, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)

        surf.blit(basin, water_rect.topleft)

    def _draw_solar_glint(self, surf: pygame.Surface, col: int, row: int):
        """Add a faint periodic glint over solar panel cells."""
        t = (self._ticks + col * 9 + row * 6) % 48
        if t > 20:
            return
        x = col * TILE_SIZE
        y = row * TILE_SIZE
        glint = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
        gx = 8 + t
        pygame.draw.line(glint, (190, 230, 255, 42), (gx, 10), (gx - 8, TILE_SIZE - 10), 2)
        surf.blit(glint, (x, y))

    def _draw_owner_tints(self, surf: pygame.Surface, world):
        for r in range(GRID_HEIGHT):
            for c in range(GRID_WIDTH):
                owner = getattr(world.grid[r][c], "owner", None)
                if owner == AGENT_A:
                    surf.blit(self._tile["owner_a"], (c*TILE_SIZE, r*TILE_SIZE))
                elif owner == AGENT_B:
                    surf.blit(self._tile["owner_b"], (c*TILE_SIZE, r*TILE_SIZE))

    def _draw_grid_lines(self, surf: pygame.Surface):
        color = (0, 0, 0, 50)
        # Draw as a single surface for speed
        line_surf = pygame.Surface((GRID_PX_W, GRID_PX_H), pygame.SRCALPHA)
        for c in range(GRID_WIDTH + 1):
            x = c * TILE_SIZE
            pygame.draw.line(line_surf, (0,0,0,60), (x,0), (x, GRID_PX_H))
        for r in range(GRID_HEIGHT + 1):
            y = r * TILE_SIZE
            pygame.draw.line(line_surf, (0,0,0,60), (0,y), (GRID_PX_W, y))
        surf.blit(line_surf, (0,0))

    def _draw_action_highlight(self, surf: pygame.Surface):
        if self._last_action is None:
            return
        r, c, age = self._last_action[0], self._last_action[1], self._last_action[2]
        if age > 35:
            return
        alpha = int(255 * (1 - age / 35))
        style = self._last_action[3] if len(self._last_action) > 3 else "highlight"
        hl    = self._tile.get(style) or self._tile.get("highlight")
        if hl:
            copy = hl.copy()
            pulse = 0.85 + 0.15 * math.sin(self._ticks * 0.35)
            copy.set_alpha(alpha)
            glow = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
            glow.fill((255, 255, 255, int(28 * pulse)))
            surf.blit(glow, (c * TILE_SIZE, r * TILE_SIZE), special_flags=pygame.BLEND_RGBA_ADD)
            surf.blit(copy, (c * TILE_SIZE, r * TILE_SIZE))

    def _draw_ambient_layer(self, surf: pygame.Surface):
        for orb in self._ambient_orbs:
            orb.update()
            orb.draw(surf)

    def _tick_last_action(self):
        if self._last_action is not None:
            self._last_action[2] += 1
            if self._last_action[2] > 35:
                self._last_action = None

    def _apply_day_night(self, surf: pygame.Surface, world):
        phase = (getattr(world, "turn", 0) % 20) // 5
        r, g, b, a = DAY_TINTS[phase]
        if a == 0:
            return
        tint = pygame.Surface((GRID_PX_W, GRID_PX_H), pygame.SRCALPHA)
        tint.fill((r, g, b, a))
        surf.blit(tint, (0,0), special_flags=pygame.BLEND_RGBA_MULT
                  if a > 0 else 0)

    # ── PARTICLES & FLOATS ────────────────────────────────────────────────────

    def _update_and_draw_particles(self):
        alive = []
        for p in self._particles:
            p.update()
            if p.alive:
                p.draw(self.screen)
                alive.append(p)
        self._particles = alive

    def _update_and_draw_floats(self):
        alive = []
        for f in self._floats:
            f.update()
            if f.alive:
                f.draw(self.screen, self.font_md)
                alive.append(f)
        self._floats = alive

    # ── UI PANEL ──────────────────────────────────────────────────────────────

    def _draw_ui_panel(self, world):
        px = UI_PANEL_X
        pw = UI_PANEL_WIDTH
        ph = SCREEN_HEIGHT

        # Panel background
        pygame.draw.rect(self.screen, C_PANEL, (px, 0, pw, ph))
        # Header bar
        pygame.draw.rect(self.screen, C_PANEL_DARK, (px, 0, pw, 54))
        # Left border accent
        pygame.draw.rect(self.screen, C_ACCENT, (px, 0, 2, ph))

        x  = px + 18
        y  = 10
        uw = pw - 36

        # ── Title ──
        title = self.ui_lg.render("WORLD BALANCE", False, C_TEXT_BRIGHT)
        self.screen.blit(title, (x, y))
        y += 30
        sub = self.ui_xs.render("A R C H I T E C T S", False, C_ACCENT)
        self.screen.blit(sub, (x + 2, y))
        y += 22

        self._divider(x, y, uw); y += 10

        # ── Time Of Day ──
        phase_idx = (getattr(world, "turn", 0) % 20) // 5
        phase_name, phase_col = DAY_PHASES[phase_idx]
        phase_l = self.ui_sm.render("TIME OF DAY", False, C_TEXT_DIM)
        phase_v = self.ui_md.render(phase_name, False, phase_col)
        self.screen.blit(phase_l, (x, y))
        self.screen.blit(phase_v, (x + uw - phase_v.get_width(), y - 1))
        y += 22

        phase_h = self.ui_xs.render("Color tint changes with this cycle", False, C_TEXT_DIM)
        self.screen.blit(phase_h, (x, y))
        y += 18

        self._divider(x, y, uw); y += 10

        # ── Planet Meters ──
        label_h = self.ui_sm.render("PLANETARY STATUS", False, C_TEXT_DIM)
        self.screen.blit(label_h, (x, y)); y += 18

        meters = [
            ("WATER",  getattr(world, "water_level", 50),  METER_COLORS["water"]),
            ("FOOD",   getattr(world, "food",         50),  METER_COLORS["food"]),
            ("OXYGEN", getattr(world, "oxygen",        50),  METER_COLORS["oxygen"]),
            ("TEMP",   getattr(world, "temperature",   50),  METER_COLORS["temp"]),
        ]
        is_critical_now = any(float(value) < 20.0 for _label, value, _color in meters)
        self._maybe_play_critical_alarm(is_critical_now)
        for label, value, color in meters:
            y = self._draw_meter(x, y, uw, label, float(value), color)
            y += 5

        self._divider(x, y, uw); y += 10

        # ── Stability ──
        stab = getattr(world, "stability", 0.5)
        stab_col = self._stability_color(stab)
        sl = self.ui_sm.render("STABILITY", False, C_TEXT_DIM)
        sv = self.ui_lg.render(f"{stab:.2f}", False, stab_col)
        self.screen.blit(sl, (x, y))
        self.screen.blit(sv, (x + uw - sv.get_width(), y - 1))
        y += 28
        # Stability bar
        self._draw_bar(x, y, uw, 14, stab, stab_col, bg=(25,30,50))
        y += 24

        self._divider(x, y, uw); y += 10

        # ── Action Log ──
        log_l = self.ui_xs.render("ACTION LOG", False, C_TEXT_DIM)
        self.screen.blit(log_l, (x, y)); y += 18
        log   = getattr(world, "action_log", [])
        for i, entry in enumerate(log[-5:]):
            # Alternate slightly different brightnesses
            shade = C_TEXT if i == len(log[-5:])-1 else C_TEXT_DIM
            line  = self.ui_sm.render(entry[:40], False, shade)
            self.screen.blit(line, (x, y)); y += 19

        # ── Legend (bottom-anchored, always inside panel) ──
        legend = [
            (TERRAIN_LAND,       "Land"),
            (TERRAIN_RIVER,      "River"),
            (TERRAIN_FARM,       "Farm"),
            (TERRAIN_FOREST,     "Forest"),
            (TERRAIN_RESERVOIR,  "Reservoir"),
            (TERRAIN_SOLAR,      "Solar"),
        ]

        legend_line_h = 18
        legend_top_pad = 8
        legend_bottom_pad = 12
        row_count = 1
        row_width = 0
        for _terrain, name in legend:
            lbl = self.ui_xs.render(name, False, C_TEXT_DIM)
            item_w = lbl.get_width() + 26
            if row_width > 0 and (row_width + item_w) > (uw - 8):
                row_count += 1
                row_width = item_w
            else:
                row_width += item_w

        legend_block_h = legend_top_pad + row_count * legend_line_h + legend_bottom_pad
        legend_start_y = SCREEN_HEIGHT - BOTTOM_TURN_BAR_H - legend_block_h - 10
        y = max(y, legend_start_y)

        self._divider(x, y, uw)
        y += legend_top_pad

        legend_x = x
        for terrain, name in legend:
            lbl = self.ui_xs.render(name, False, C_TEXT_DIM)
            item_w = lbl.get_width() + 26
            if legend_x > x and (legend_x + item_w) > (x + uw - 8):
                legend_x = x
                y += legend_line_h

            sm = self._legend_icons.get(terrain)
            if sm:
                self.screen.blit(sm, (legend_x, y))
            self.screen.blit(lbl, (legend_x + 17, y + 1))
            legend_x += item_w

    # ── GAME OVER OVERLAY ─────────────────────────────────────────────────────

    def _draw_gameover_overlay(self, world, winner: str):
        # Full-screen dim
        dim = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA)
        dim.fill((0, 0, 0, 175))
        self.screen.blit(dim, (0, 0))

        cx = GRID_PX_W // 2
        cy = SCREEN_HEIGHT // 2

        # Glow box
        box_w, box_h = 480, 200
        box = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
        pygame.draw.rect(box, (20, 24, 40, 230), (0,0,box_w,box_h), border_radius=12)
        pygame.draw.rect(box, C_GOLD + (200,),  (0,0,box_w,box_h), 2, border_radius=12)
        self.screen.blit(box, (cx - box_w//2, cy - box_h//2))

        # Winner text
        win_t = self.font_xl.render(f"AGENT {winner} WINS", False, C_GOLD)
        self.screen.blit(win_t, win_t.get_rect(center=(cx, cy - 60)))

        # Stability
        stab   = getattr(world, "stability", 0.5)
        stab_c = self._stability_color(stab)
        st_t   = self.font_md.render(f"Final Stability: {stab:.2f}", False, stab_c)
        self.screen.blit(st_t, st_t.get_rect(center=(cx, cy - 20)))

        # Scores
        scores = getattr(world, "scores", {AGENT_A: 0, AGENT_B: 0})
        for i, (agent, color) in enumerate([(AGENT_A,AGENT_A_COLOR),(AGENT_B,AGENT_B_COLOR)]):
            s = self.font_md.render(
                f"Agent {agent}: {scores.get(agent,0):.1f}", False, color)
            self.screen.blit(s, s.get_rect(center=(cx + (i*2-1)*100, cy + 20)))

        restart = self.font_sm.render("Press R to restart  /  ESC to quit",
                                      False, C_TEXT_DIM)
        self.screen.blit(restart, restart.get_rect(center=(cx, cy + 60)))

    # ── WIDGET HELPERS ────────────────────────────────────────────────────────

    def _draw_meter(self, x:int, y:int, width:int,
                    label:str, value:float, color:tuple) -> int:
        # Flash animation when critical
        is_critical = value < 20
        draw_color  = color
        if is_critical:
            t = (math.sin(self._ticks * 0.18) + 1) * 0.5
            draw_color = (
                int(color[0]*(1-t) + 220*t),
                int(color[1]*(1-t) +  40*t),
                int(color[2]*(1-t) +  40*t),
            )

        # Label row
        lbl = self.ui_sm.render(label, False, C_TEXT_DIM)
        val = self.ui_sm.render(
            f"{'⚠ ' if is_critical else ''}{value:.1f}", False, draw_color)
        self.screen.blit(lbl, (x, y))
        self.screen.blit(val, (x + width - val.get_width(), y))
        y += self.ui_sm.get_height() + 4

        self._draw_bar(x, y, width, 12, value / 100.0, draw_color)
        return y + 12

    def _draw_bar(self, x:int, y:int, w:int, h:int,
                  ratio:float, color:tuple, bg=(30,35,55)):
        ratio = max(0.0, min(1.0, ratio))
        pygame.draw.rect(self.screen, bg,    (x, y, w, h), border_radius=h//2)
        fw = int(w * ratio)
        if fw > 0:
            pygame.draw.rect(self.screen, color, (x, y, fw, h), border_radius=h//2)
            # Bright tip pixel
            if fw > 3:
                bright = tuple(min(255, c+80) for c in color)
                pygame.draw.rect(self.screen, bright, (x+fw-3, y+1, 3, h-2),
                                 border_radius=2)

    def _draw_thinking_dot(self, x:int, y:int, color:tuple):
        t = (math.sin(self._ticks * 0.14) + 1) * 0.5
        r = int(4 + 2 * t)
        s = pygame.Surface((r*2+2, r*2+2), pygame.SRCALPHA)
        pygame.draw.circle(s, (*color, 200), (r+1,r+1), r)
        self.screen.blit(s, (x - r, y - r))

    def _draw_hover_tooltip(self, world, ox: int, oy: int):
        """Show hovered cell details: terrain, owner, water value, crop stage."""
        mx, my = pygame.mouse.get_pos()
        gx = mx - ox
        gy = my - oy

        if gx < 0 or gy < 0 or gx >= GRID_PX_W or gy >= GRID_PX_H:
            return

        col = int(gx // TILE_SIZE)
        row = int(gy // TILE_SIZE)
        if row < 0 or row >= GRID_HEIGHT or col < 0 or col >= GRID_WIDTH:
            return

        cell = world.grid[row][col]

        terrain_map = {
            TERRAIN_LAND: "Land",
            TERRAIN_RIVER: "River",
            TERRAIN_FARM: "Farm",
            TERRAIN_FOREST: "Forest",
            TERRAIN_RESERVOIR: "Reservoir",
            TERRAIN_SOLAR: "Solar Plant",
        }
        terrain_name = terrain_map.get(getattr(cell, "terrain", ""), str(getattr(cell, "terrain", "")))
        owner = getattr(cell, "owner", None)
        owner_text = "None" if owner is None else str(owner)
        water_value = getattr(cell, "water_value", 0)
        terrain_key = getattr(cell, "terrain", "")
        crop_stage = getattr(cell, "crop_stage", 0)
        forest_maturity = getattr(cell, "forest_maturity", 1)

        crop_stage_text = str(crop_stage) if terrain_key == TERRAIN_FARM else "N/A"
        forest_maturity_text = str(forest_maturity) if terrain_key == TERRAIN_FOREST else "N/A"

        lines = [
            f"Cell ({col}, {row})",
            f"Terrain: {terrain_name}",
            f"Owner: {owner_text}",
            f"Water: {water_value}",
            f"Crop Stage: {crop_stage_text}",
            f"Forest Maturity: {forest_maturity_text}",
        ]

        rendered = [self.ui_xs.render(line, False, C_TEXT_BRIGHT if i == 0 else C_TEXT) for i, line in enumerate(lines)]
        pad_x = 8
        pad_y = 6
        line_gap = 4
        tip_w = max(r.get_width() for r in rendered) + pad_x * 2
        tip_h = sum(r.get_height() for r in rendered) + line_gap * (len(rendered) - 1) + pad_y * 2

        tip_x = mx + 14
        tip_y = my + 14
        # Keep tooltip in the grid area so it never hides under the right UI panel.
        grid_right = min(UI_PANEL_X - 6, SCREEN_WIDTH - 6)
        if tip_x + tip_w > grid_right:
            tip_x = mx - tip_w - 14
        bottom_limit = SCREEN_HEIGHT - BOTTOM_TURN_BAR_H - 6
        if tip_y + tip_h > bottom_limit:
            tip_y = my - tip_h - 14
        tip_x = max(6, tip_x)
        tip_y = max(6, min(tip_y, bottom_limit - tip_h))

        box = pygame.Surface((tip_w, tip_h), pygame.SRCALPHA)
        box.fill((12, 16, 28, 230))
        pygame.draw.rect(box, C_BORDER, (0, 0, tip_w, tip_h), 1, border_radius=6)
        pygame.draw.line(box, C_ACCENT, (1, rendered[0].get_height() + pad_y + 2), (tip_w - 2, rendered[0].get_height() + pad_y + 2))

        ty = pad_y
        for rs in rendered:
            box.blit(rs, (pad_x, ty))
            ty += rs.get_height() + line_gap

        self.screen.blit(box, (tip_x, tip_y))

    def _draw_turn_banner(self, world):
        """Bottom-wide banner showing the active agent and its current action."""
        y = SCREEN_HEIGHT - BOTTOM_TURN_BAR_H
        banner = pygame.Surface((SCREEN_WIDTH, BOTTOM_TURN_BAR_H), pygame.SRCALPHA)

        if self._bottom_banner_bg is not None:
            bg = self._bottom_banner_bg
            if bg.get_width() != SCREEN_WIDTH or bg.get_height() != BOTTOM_TURN_BAR_H:
                bg = pygame.transform.scale(bg, (SCREEN_WIDTH, BOTTOM_TURN_BAR_H))
            banner.blit(bg, (0, 0))
            # Subtle dark overlay so text remains legible on busy backgrounds.
            overlay = pygame.Surface((SCREEN_WIDTH, BOTTOM_TURN_BAR_H), pygame.SRCALPHA)
            overlay.fill((8, 10, 18, 108))
            banner.blit(overlay, (0, 0))
        else:
            banner.fill((12, 16, 26, 235))

        pygame.draw.line(banner, (70, 82, 108), (0, 0), (SCREEN_WIDTH, 0), 1)
        pygame.draw.line(banner, (24, 30, 44), (0, BOTTOM_TURN_BAR_H - 1), (SCREEN_WIDTH, BOTTOM_TURN_BAR_H - 1), 1)

        cur_agent = self._turn_banner_agent_id or getattr(world, "current_agent", AGENT_A)
        cur_color = AGENT_A_COLOR if cur_agent == AGENT_A else AGENT_B_COLOR
        opp_agent = AGENT_B if cur_agent == AGENT_A else AGENT_A

        left_box = pygame.Rect(36, 20, 92, 92)
        right_box = pygame.Rect(SCREEN_WIDTH - 128, 20, 92, 92)
        self._draw_turn_avatar(banner, left_box, AGENT_A, active=(cur_agent == AGENT_A), accent=AGENT_A_COLOR)
        self._draw_turn_avatar(banner, right_box, AGENT_B, active=(cur_agent == AGENT_B), accent=AGENT_B_COLOR)

        title_font = self.ui_lg
        subtitle_font = self.ui_sm
        label_font = self.ui_xs

        turn_title = f"Agent {cur_agent} Turn"
        title = title_font.render(turn_title, False, C_TEXT_BRIGHT)
        banner.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2, 12))

        agent_label = self._turn_banner_agent_name.strip()
        if not agent_label:
            agent_type = self._agent_types.get(cur_agent, "")
            if agent_type == "minimax":
                agent_label = "Minimax + Alpha-Beta Decision"
            elif agent_type == "montecarlo":
                agent_label = "Monte Carlo Rollouts"
            elif agent_type == "qlearning":
                agent_label = "Q-Learning Agent"
            elif agent_type == "dqn":
                agent_label = "Deep Q-Network Agent"
            else:
                agent_label = agent_type.title()

        subtitle = subtitle_font.render(agent_label, False, C_TEXT_DIM)
        banner.blit(subtitle, (SCREEN_WIDTH // 2 - subtitle.get_width() // 2, 55))

        action_label = self._turn_banner_action_text.strip() or "Thinking..."
        prefix = label_font.render("Current Action:", False, C_TEXT_DIM)
        value = label_font.render(action_label, False, C_GOLD)
        total_w = prefix.get_width() + 8 + value.get_width()
        ax = SCREEN_WIDTH // 2 - total_w // 2
        ay = 84
        banner.blit(prefix, (ax, ay))
        banner.blit(value, (ax + prefix.get_width() + 8, ay))

        left_label = label_font.render(f"Agent {AGENT_A}", False, AGENT_A_COLOR)
        right_label = label_font.render(f"Agent {AGENT_B}", False, AGENT_B_COLOR)
        banner.blit(left_label, (left_box.centerx - left_label.get_width() // 2, 114))
        banner.blit(right_label, (right_box.centerx - right_label.get_width() // 2, 114))

        # Show each agent's score next to the bottom portraits.
        scores = getattr(world, "scores", {AGENT_A: 0, AGENT_B: 0})
        left_score = label_font.render(f"Score: {scores.get(AGENT_A, 0):.1f}", False, C_TEXT_BRIGHT)
        right_score = label_font.render(f"Score: {scores.get(AGENT_B, 0):.1f}", False, C_TEXT_BRIGHT)
        banner.blit(left_score, (left_box.right + 14, left_box.centery - left_score.get_height() // 2))
        banner.blit(right_score, (right_box.left - right_score.get_width() - 14, right_box.centery - right_score.get_height() // 2))

        self.screen.blit(banner, (0, y))

    def _draw_turn_avatar(self, banner: pygame.Surface, rect: pygame.Rect, agent_id: str, *, active: bool, accent: tuple[int, int, int]):
        frame_col = accent if active else (74, 84, 104)
        fill_col = (18, 22, 34)
        pygame.draw.rect(banner, fill_col, rect, border_radius=18)
        pygame.draw.rect(banner, frame_col, rect, 2 if active else 1, border_radius=18)

        portrait = self._agent_portraits.get(agent_id)
        if portrait is not None:
            scale = min(rect.width - 18, rect.height - 18)
            img = portrait
            if img.get_width() != scale or img.get_height() != scale:
                img = pygame.transform.scale(img, (scale, scale))
            banner.blit(img, (rect.centerx - img.get_width() // 2, rect.centery - img.get_height() // 2))
        else:
            color = AGENT_A_COLOR if agent_id == AGENT_A else AGENT_B_COLOR
            pygame.draw.circle(banner, color, rect.center, 18)

    def _divider(self, x:int, y:int, w:int):
        pygame.draw.line(self.screen, C_BORDER, (x, y), (x+w, y))

    @staticmethod
    def _stability_color(stab: float) -> tuple:
        if stab >= STABILITY_HIGH:     return C_GOOD
        elif stab >= STABILITY_MODERATE: return C_WARN
        elif stab >= STABILITY_LOW:    return (220, 130, 40)
        else:                          return C_DANGER

    # ── CONVENIENCE: spawn action FX from outside ─────────────────────────────

    ACTION_FX = {
        "harvest_crop":   ("+Food",   (180, 240,  80), (100, 200,  60)),
        "plant_forest":   ("+O₂",     ( 80, 230, 140), ( 40, 180,  80)),
        "plant_crop":     ("Planted", (140, 220,  80), ( 80, 160,  40)),
        "build_canal":    ("+Water",  (100, 190, 255), ( 40, 120, 200)),
        "build_solar":    ("+Energy", (255, 220,  80), (200, 160,  40)),
        "plant_reservoir":("+Water",  ( 80, 160, 240), ( 40, 100, 190)),
        "clear_forest":   ("-O₂",     (255, 100,  60), (180,  40,  20)),
    }

    def trigger_action_fx(self, action_name: str, col: int, row: int, agent_id: str | None = None):
        """Spawn FloatingText + particles matching the action type."""
        fx = self.ACTION_FX.get(action_name)
        if fx:
            text, text_color, particle_color = fx
            self.add_float(text, col, row, text_color)
            self.add_particles(col, row, particle_color, count=10)
            # Use standard last-action highlight for played moves.
            self.set_last_action(row, col)
            if agent_id in (AGENT_A, AGENT_B):
                # Action has landed: stop travel and dock in this tile's corner.
                self._agent_target_pos[agent_id] = None
                self._agent_action_cell[agent_id] = None
                self._agent_dock_pending_cell[agent_id] = (col, row)
                self._agent_dock_delay_frames[agent_id] = 30
                self._agent_token_pos[agent_id] = [
                    col * TILE_SIZE + TILE_SIZE / 2,
                    row * TILE_SIZE + TILE_SIZE / 2,
                ]

            if action_name == "harvest_crop":
                self._play_sfx("harvest_chime")
            elif action_name in ("build_canal", "build_solar", "plant_reservoir"):
                self._play_sfx("build_thud")
