"""
input_handler.py — Input processing for the Game scene.

Handles:
  - LMB drag-to-pan in EXPLORE state
  - Middle-mouse panning
  - Keyboard shortcuts (WASD, ESC, W, 1/2/3, F5)
  - Placing buildings / drawing wires / bulldozing
  - Refund on demolish (player-built), restriction on consumer demolish
"""

from __future__ import annotations

from typing import Optional

import pygame

from helpers.game_data import (
    BUILDING_TYPES, BULLDOZE_CONSUMER_COST, PlacedBuilding, Wire, MAX_LEVEL, XP_THRESHOLDS,
)
from helpers.keybindings import get_keybindings  # type: ignore[import]
from helpers.sfx import sfx_manager  # type: ignore[import]
from .constants import (
    CAM_SPEED,
    CARD_STRIDE,
    _S, WIRE_TIERS,
    ZOOM_MIN, ZOOM_MAX,
)

# Minimum pixel movement before LMB drag-to-pan kicks in
_PAN_THRESHOLD = 5


def _pt_seg_dist(px: float, py: float,
                 ax: float, ay: float,
                 bx: float, by: float) -> float:
    dx, dy = bx - ax, by - ay
    if dx == 0 and dy == 0:
        return ((px - ax) ** 2 + (py - ay) ** 2) ** 0.5
    t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
    return ((px - ax - t * dx) ** 2 + (py - ay - t * dy) ** 2) ** 0.5


class GameInputMixin:
    """Input handling mixed into Game."""

    # ------------------------------------------------------------------ update helpers

    def _pan_keys(self, dt: float) -> None:
        keys = pygame.key.get_pressed()
        spd = CAM_SPEED * self._zoom  # type: ignore[attr-defined]
        if keys[pygame.K_LEFT]  or keys[pygame.K_a]: self._cam_x -= spd * dt  # type: ignore[attr-defined]
        if keys[pygame.K_RIGHT] or keys[pygame.K_d]: self._cam_x += spd * dt  # type: ignore[attr-defined]
        if keys[pygame.K_UP]    or keys[pygame.K_w]: self._cam_y -= spd * dt  # type: ignore[attr-defined]
        if keys[pygame.K_DOWN]  or keys[pygame.K_s]: self._cam_y += spd * dt  # type: ignore[attr-defined]
        self._clamp_cam()  # type: ignore[attr-defined]

    def _upd_xp(self, dt: float) -> None:
        xps = self._gs.xp_per_second(self._powered)  # type: ignore[attr-defined]
        if xps > 0:
            prev_level = self._gs.level()  # type: ignore[attr-defined]
            self._gs.total_xp += xps * dt  # type: ignore[attr-defined]
            new_level = self._gs.level()  # type: ignore[attr-defined]
            if new_level > prev_level:
                self._on_level_up(new_level)

    def _upd_economy(self, dt: float) -> None:
        net_per_min = self._gs.net_income_per_minute(self._powered, self._polluted)  # type: ignore[attr-defined]
        self._gs.money += net_per_min / 60.0 * dt  # type: ignore[attr-defined]

        # Softlock prevention: emergency loan if deeply in debt
        if self._gs.money < -500.0 and not self._loan_pending:  # type: ignore[attr-defined]
            self._loan_pending = True  # type: ignore[attr-defined]
            self._gs.money += 2000.0  # type: ignore[attr-defined]
            self._flash("Emergency loan: €2,000 (you were bankrupt!)")  # type: ignore[attr-defined]
        elif self._gs.money >= -500.0:  # type: ignore[attr-defined]
            self._loan_pending = False  # type: ignore[attr-defined]

    # ---- Pause ----

    def _upd_pause(self, events: list[pygame.event.Event]) -> Optional[str]:
        for e in events:
            if e.type == pygame.KEYDOWN and e.key == pygame.K_ESCAPE:
                self._resume()
            elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
                if self._pause_resume_r and self._pause_resume_r.collidepoint(e.pos):  # type: ignore[attr-defined]
                    self._resume()
                elif self._pause_settings_r and self._pause_settings_r.collidepoint(e.pos):  # type: ignore[attr-defined]
                    self._save()  # type: ignore[attr-defined]
                    return "settings"
                elif self._pause_quit_r and self._pause_quit_r.collidepoint(e.pos):  # type: ignore[attr-defined]
                    self._save()  # type: ignore[attr-defined]
                    self._want_quit = True  # type: ignore[attr-defined]
        if self._want_quit:  # type: ignore[attr-defined]
            return "back"
        return None

    # ---- Normal play ----

    def _upd_play(self, _dt: float, events: list[pygame.event.Event]) -> Optional[str]:
        mx, my = pygame.mouse.get_pos()
        gah     = self._game_h()  # type: ignore[attr-defined]
        in_game = my < gah

        if in_game and not self._panning and not self._lmb_panning:  # type: ignore[attr-defined]
            gx, gy = self._s2g(mx, my)  # type: ignore[attr-defined]
            self._hov_cell     = (gx, gy)  # type: ignore[attr-defined]
            self._hov_building = self._gs.get_building_at(gx, gy)  # type: ignore[attr-defined]
        else:
            self._hov_cell     = None  # type: ignore[attr-defined]
            self._hov_building = None  # type: ignore[attr-defined]

        self._hov_wire = (  # type: ignore[attr-defined]
            self._nearest_wire(mx, my)
            if self._state == _S.BULLDOZE and in_game  # type: ignore[attr-defined]
            else None
        )

        # Debug menu
        _kb = get_keybindings()
        if self._debug_open:  # type: ignore[attr-defined]
            for e in events:
                if e.type == pygame.KEYDOWN and e.key in (pygame.K_ESCAPE, _kb["debug"]):
                    self._debug_open = False  # type: ignore[attr-defined]
                elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
                    self._handle_debug_click(e.pos)
            return None

        # Level-up overlay: click to dismiss early
        if self._levelup_timer > 0:  # type: ignore[attr-defined]
            for e in events:
                if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
                    self._levelup_timer = 0.0  # type: ignore[attr-defined]
                    break

        for e in events:
            # --- MMB panning ---
            if e.type == pygame.MOUSEBUTTONDOWN and e.button == 2:
                self._panning    = True  # type: ignore[attr-defined]
                self._pan_origin = e.pos  # type: ignore[attr-defined]
                self._cam_origin = (self._cam_x, self._cam_y)  # type: ignore[attr-defined]
            elif e.type == pygame.MOUSEBUTTONUP and e.button == 2:
                self._panning = False  # type: ignore[attr-defined]
            elif e.type == pygame.MOUSEMOTION and self._panning:  # type: ignore[attr-defined]
                dx = e.pos[0] - self._pan_origin[0]  # type: ignore[attr-defined]
                dy = e.pos[1] - self._pan_origin[1]  # type: ignore[attr-defined]
                self._cam_x = self._cam_origin[0] - dx  # type: ignore[attr-defined]
                self._cam_y = self._cam_origin[1] - dy  # type: ignore[attr-defined]
                self._clamp_cam()  # type: ignore[attr-defined]

            # --- LMB drag-to-pan (EXPLORE only) ---
            elif (e.type == pygame.MOUSEBUTTONDOWN and e.button == 1
                  and self._state == _S.EXPLORE  # type: ignore[attr-defined]
                  and in_game
                  and not self._picker_t > 0.2):  # type: ignore[attr-defined]
                # Record potential pan start; decision made on motion
                self._lmb_pan_start  = e.pos  # type: ignore[attr-defined]
                self._lmb_pan_moved  = False  # type: ignore[attr-defined]
                self._lmb_cam_origin = (self._cam_x, self._cam_y)  # type: ignore[attr-defined]
            elif e.type == pygame.MOUSEBUTTONUP and e.button == 1:
                if self._lmb_panning:  # type: ignore[attr-defined]
                    self._lmb_panning = False  # type: ignore[attr-defined]
                    self._lmb_pan_start = None  # type: ignore[attr-defined]
                elif (self._lmb_pan_start is not None  # type: ignore[attr-defined]
                      and not self._lmb_pan_moved  # type: ignore[attr-defined]
                      and self._state == _S.EXPLORE):  # type: ignore[attr-defined]
                    # Short click — treat as normal click
                    self._lmb_pan_start = None  # type: ignore[attr-defined]
                    self._on_lmb(e.pos[0], e.pos[1], in_game)
                    continue
                elif self._lmb_pan_start is None:
                    # Normal LMB up when not in drag-start state
                    pass
            elif e.type == pygame.MOUSEMOTION and self._lmb_pan_start is not None:  # type: ignore[attr-defined]
                dx = e.pos[0] - self._lmb_pan_start[0]  # type: ignore[attr-defined]
                dy = e.pos[1] - self._lmb_pan_start[1]  # type: ignore[attr-defined]
                if not self._lmb_panning and (abs(dx) > _PAN_THRESHOLD or abs(dy) > _PAN_THRESHOLD):  # type: ignore[attr-defined]
                    self._lmb_panning   = True  # type: ignore[attr-defined]
                    self._lmb_pan_moved = True  # type: ignore[attr-defined]
                if self._lmb_panning:  # type: ignore[attr-defined]
                    self._cam_x = self._lmb_cam_origin[0] - dx  # type: ignore[attr-defined]
                    self._cam_y = self._lmb_cam_origin[1] - dy  # type: ignore[attr-defined]
                    self._clamp_cam()  # type: ignore[attr-defined]

            elif e.type == pygame.KEYDOWN:
                if e.key == _kb["pause_time"]:
                    self._time_paused = not self._time_paused  # type: ignore[attr-defined]
                elif e.key == _kb["debug"]:
                    self._debug_open = True  # type: ignore[attr-defined]
                elif e.key == pygame.K_ESCAPE:
                    if self._state == _S.WIRE_TO:  # type: ignore[attr-defined]
                        self._wire_src = None  # type: ignore[attr-defined]
                        self._state    = _S.WIRE_FROM  # type: ignore[attr-defined]
                    elif self._state == _S.WIRE_FROM:  # type: ignore[attr-defined]
                        self._close_picker()
                    elif self._state not in (_S.EXPLORE,):  # type: ignore[attr-defined]
                        self._cancel()
                    else:
                        self._prev_state = self._state  # type: ignore[attr-defined]
                        self._state      = _S.PAUSED  # type: ignore[attr-defined]

                # Keyboard tab shortcuts (rebindable via Controls settings)
                elif e.key == _kb["tab_generators"]:
                    self._toggle_tab("generators")
                elif e.key == _kb["tab_research"]:
                    self._toggle_tab("research")
                elif e.key == _kb["tab_logistics"]:
                    self._toggle_tab("logistics")
                elif e.key == _kb["tab_wires"]:
                    self._toggle_tab("wires")
                elif e.key == _kb["bulldoze"]:
                    self._toggle_tool("bulldoze")  # type: ignore[attr-defined]

            elif e.type == pygame.MOUSEWHEEL:
                if self._state in (_S.WIRE_FROM, _S.WIRE_TO):  # type: ignore[attr-defined]
                    # Scroll to change wire tier; only show unlocked tiers
                    lv = self._gs.level()  # type: ignore[attr-defined]
                    new_tier = self._wire_layer - e.y  # type: ignore[attr-defined]
                    # Clamp to max unlocked tier
                    max_tier = 0
                    for ti, tier_def in enumerate(WIRE_TIERS):
                        tunlock = tier_def[2]
                        if lv >= tunlock:
                            max_tier = ti
                    self._wire_layer = max(0, min(max_tier, new_tier))  # type: ignore[attr-defined]
                elif (self._picker_t > 0.1  # type: ignore[attr-defined]
                        and self._active_tab is not None  # type: ignore[attr-defined]
                        and self._active_tab != "wires"):  # type: ignore[attr-defined]
                    tab = self._active_tab  # type: ignore[attr-defined]
                    self._picker_scroll[tab] = (  # type: ignore[attr-defined]
                        self._scroll_for_tab(tab) - e.x * CARD_STRIDE  # type: ignore[attr-defined]
                    )
                    self._clamp_scroll(tab)  # type: ignore[attr-defined]
                elif in_game:
                    # Zoom towards cursor
                    factor   = 1.12 if e.y > 0 else (1 / 1.12)
                    old_zoom = self._zoom  # type: ignore[attr-defined]
                    new_zoom = max(ZOOM_MIN, min(ZOOM_MAX, old_zoom * factor))
                    if new_zoom != old_zoom:
                        cmx, cmy = pygame.mouse.get_pos()
                        # Keep world position under cursor fixed
                        self._cam_x = (cmx + self._cam_x) * new_zoom / old_zoom - cmx  # type: ignore[attr-defined]
                        self._cam_y = (cmy + self._cam_y) * new_zoom / old_zoom - cmy  # type: ignore[attr-defined]
                        self._zoom  = new_zoom  # type: ignore[attr-defined]
                        self._clamp_cam()  # type: ignore[attr-defined]

            elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
                # Only handle if not in LMB drag-start mode (those are handled above)
                if self._lmb_pan_start is None:  # type: ignore[attr-defined]
                    self._on_lmb(mx, my, in_game)

            elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 3:
                self._on_rmb()

        return None

    # ---- Input handlers ----

    def _on_lmb(self, mx: int, my: int, in_game: bool) -> None:
        # Wire tier buttons (shown in wire mode)
        if self._state in (_S.WIRE_FROM, _S.WIRE_TO):  # type: ignore[attr-defined]
            for tier_idx, rect in self._wire_tier_btn_rects:  # type: ignore[attr-defined]
                if rect.collidepoint(mx, my):
                    lv = self._gs.level()  # type: ignore[attr-defined]
                    tunlock = WIRE_TIERS[tier_idx][2] if tier_idx < len(WIRE_TIERS) else 1
                    if lv < tunlock:
                        self._flash(f"Wire tier requires Level {tunlock}!")
                    else:
                        self._wire_layer = tier_idx  # type: ignore[attr-defined]
                    return

        # Toolbar tabs
        for name, rect in self._tab_rects.items():  # type: ignore[attr-defined]
            if rect.collidepoint(mx, my):
                self._toggle_tab(name)
                return

        # Toolbar tools
        for name, rect in self._tool_rects.items():  # type: ignore[attr-defined]
            if rect.collidepoint(mx, my):
                self._toggle_tool(name)
                return

        # Picker area
        py2   = self._picker_y()  # type: ignore[attr-defined]
        ph2   = int(self._picker_t * 210)  # type: ignore[attr-defined]
        in_pic = py2 <= my < py2 + ph2 and self._picker_t > 0.2  # type: ignore[attr-defined]

        if in_pic:
            tab = self._active_tab  # type: ignore[attr-defined]
            if tab is not None and tab != "wires":
                if self._picker_left_r and self._picker_left_r.collidepoint(mx, my):  # type: ignore[attr-defined]
                    from .constants import CARD_STRIDE as CS
                    self._picker_scroll[tab] = max(0, self._scroll_for_tab(tab) - CS)  # type: ignore[attr-defined]
                    return
                if self._picker_right_r and self._picker_right_r.collidepoint(mx, my):  # type: ignore[attr-defined]
                    self._picker_scroll[tab] = self._scroll_for_tab(tab) + CARD_STRIDE  # type: ignore[attr-defined]
                    self._clamp_scroll(tab)  # type: ignore[attr-defined]
                    return
                cx_start = self._picker_content_x()  # type: ignore[attr-defined]
                cx_end   = cx_start + self._picker_visible_w()  # type: ignore[attr-defined]
                if cx_start <= mx < cx_end:
                    scroll   = self._scroll_for_tab(tab)  # type: ignore[attr-defined]
                    rel_x    = mx - cx_start + scroll
                    card_idx = rel_x // CARD_STRIDE
                    cards    = self._cards_for_tab(tab)  # type: ignore[attr-defined]
                    if 0 <= card_idx < len(cards):
                        tid = cards[card_idx]
                        td  = BUILDING_TYPES.get(tid)
                        if td:
                            if td.unlock_level > self._gs.level():  # type: ignore[attr-defined]
                                self._flash(f"Requires Level {td.unlock_level}!")
                            else:
                                self._select_type(tid)
            return

        if not in_game:
            return

        gx, gy = self._s2g(mx, my)  # type: ignore[attr-defined]

        if self._state == _S.PLACE and self._sel_type:  # type: ignore[attr-defined]
            td = BUILDING_TYPES.get(self._sel_type)  # type: ignore[attr-defined]
            if td and td.max_count > 0 and self._gs.count_of_type(self._sel_type) >= td.max_count:  # type: ignore[attr-defined]
                self._flash(f"Max 1 allowed: {td.name}")
                return
            if td and td.build_cost > 0 and self._gs.money < td.build_cost:  # type: ignore[attr-defined]
                self._flash(f"Can't afford! Need €{td.build_cost:,.0f}")
                sfx_manager.play("error")
                return
            b = self._gs.place_building(self._sel_type, gx, gy)  # type: ignore[attr-defined]
            if b:
                if td and td.build_cost > 0:
                    self._gs.money -= td.build_cost  # type: ignore[attr-defined]
                self._flash(f"Placed {BUILDING_TYPES[self._sel_type].name}")  # type: ignore[attr-defined]
                sfx_manager.play("place")
            else:
                self._flash("Cannot place here!")
                sfx_manager.play("error")

        elif self._state == _S.WIRE_FROM:  # type: ignore[attr-defined]
            b = self._gs.get_building_at(gx, gy)  # type: ignore[attr-defined]
            if b:
                self._wire_src = b.build_id  # type: ignore[attr-defined]
                self._state    = _S.WIRE_TO  # type: ignore[attr-defined]
                self._flash(f"From {b.typedef.name} — click target")

        elif self._state == _S.WIRE_TO:  # type: ignore[attr-defined]
            b = self._gs.get_building_at(gx, gy)  # type: ignore[attr-defined]
            if b and self._wire_src and b.build_id != self._wire_src:  # type: ignore[attr-defined]
                # Check tier unlock level
                tier    = self._wire_layer  # type: ignore[attr-defined]
                tunlock = WIRE_TIERS[tier][2] if tier < len(WIRE_TIERS) else 1
                if self._gs.level() < tunlock:  # type: ignore[attr-defined]
                    self._flash(f"Wire tier requires Level {tunlock}!")
                    return
                w = self._gs.add_wire(self._wire_src, b.build_id, self._wire_layer)  # type: ignore[attr-defined]
                self._wire_src = None  # type: ignore[attr-defined]
                self._state    = _S.WIRE_FROM  # type: ignore[attr-defined]
                self._flash("Wire connected!" if w else "Already connected!")
                if w:
                    sfx_manager.play("wire")
            elif b:
                self._flash("Cannot connect to itself!")

        elif self._state == _S.BULLDOZE:  # type: ignore[attr-defined]
            if self._hov_wire:  # type: ignore[attr-defined]
                self._gs.remove_wire(self._hov_wire)  # type: ignore[attr-defined]
                self._hov_wire = None  # type: ignore[attr-defined]
                self._flash("Wire removed")
            elif self._hov_building:  # type: ignore[attr-defined]
                b  = self._hov_building  # type: ignore[attr-defined]
                td = BUILDING_TYPES.get(b.type_id)
                name = b.typedef.name
                bid  = b.build_id

                if td and not td.player_buildable:
                    # Consumer building — costs money, must keep at least 1
                    consumers = [x for x in self._gs.buildings  # type: ignore[attr-defined]
                                 if BUILDING_TYPES.get(x.type_id)
                                 and BUILDING_TYPES[x.type_id].category == "consumers"]
                    if len(consumers) <= 1:
                        self._flash("Cannot demolish the last building!")
                        return
                    if self._gs.money < BULLDOZE_CONSUMER_COST:  # type: ignore[attr-defined]
                        self._flash(f"Not enough money! Demolish costs €{BULLDOZE_CONSUMER_COST:,.0f}")
                        return
                    self._gs.money -= BULLDOZE_CONSUMER_COST  # type: ignore[attr-defined]
                    self._gs.remove_building(bid)  # type: ignore[attr-defined]
                    if self._sel_building and self._sel_building.build_id == bid:  # type: ignore[attr-defined]
                        self._sel_building = None  # type: ignore[attr-defined]
                    self._hov_building = None  # type: ignore[attr-defined]
                    sfx_manager.play("demolish")
                    self._flash(f"{name} demolished · Cost €{BULLDOZE_CONSUMER_COST:,.0f}")
                else:
                    # Player-built building — refund 50%
                    refund = td.build_cost * 0.5 if td and td.build_cost > 0 else 0.0
                    if refund:
                        self._gs.money += refund  # type: ignore[attr-defined]
                    self._gs.remove_building(bid)  # type: ignore[attr-defined]
                    if self._sel_building and self._sel_building.build_id == bid:  # type: ignore[attr-defined]
                        self._sel_building = None  # type: ignore[attr-defined]
                    self._hov_building = None  # type: ignore[attr-defined]
                    sfx_manager.play("demolish")
                    if refund > 0:
                        self._flash(f"{name} demolished · Refunded €{refund:,.0f}")
                    else:
                        self._flash(f"{name} demolished")

    def _on_rmb(self) -> None:
        if self._state in (_S.PLACE, _S.WIRE_FROM, _S.WIRE_TO):  # type: ignore[attr-defined]
            self._cancel()
        else:
            self._sel_building = None  # type: ignore[attr-defined]

    # ---- Tab / tool toggles ----

    def _toggle_tab(self, name: str) -> None:
        if self._active_tab == name:  # type: ignore[attr-defined]
            self._close_picker()
        else:
            self._active_tab = name  # type: ignore[attr-defined]
            if name == "wires":
                self._picker_tgt = 0.0  # type: ignore[attr-defined]
                self._state    = _S.WIRE_FROM  # type: ignore[attr-defined]
                self._wire_src = None  # type: ignore[attr-defined]
                self._sel_type = None  # type: ignore[attr-defined]
            else:
                self._picker_tgt = 1.0  # type: ignore[attr-defined]
                self._state    = _S.EXPLORE  # type: ignore[attr-defined]
                self._sel_type = None  # type: ignore[attr-defined]
                self._wire_src = None  # type: ignore[attr-defined]

    def _close_picker(self) -> None:
        self._active_tab = None  # type: ignore[attr-defined]
        self._picker_tgt = 0.0   # type: ignore[attr-defined]
        self._state      = _S.EXPLORE  # type: ignore[attr-defined]
        self._sel_type   = None  # type: ignore[attr-defined]
        self._wire_src   = None  # type: ignore[attr-defined]

    def _toggle_tool(self, name: str) -> None:
        if name == "bulldoze":
            if self._state == _S.BULLDOZE:  # type: ignore[attr-defined]
                self._close_picker()
                self._state = _S.EXPLORE  # type: ignore[attr-defined]
            else:
                self._close_picker()
                self._state = _S.BULLDOZE  # type: ignore[attr-defined]

    def _select_type(self, type_id: str) -> None:
        if self._sel_type == type_id and self._state == _S.PLACE:  # type: ignore[attr-defined]
            self._sel_type = None  # type: ignore[attr-defined]
            self._state    = _S.EXPLORE  # type: ignore[attr-defined]
        else:
            self._sel_type = type_id  # type: ignore[attr-defined]
            self._state    = _S.PLACE  # type: ignore[attr-defined]

    def _cancel(self) -> None:
        if self._state == _S.WIRE_TO:  # type: ignore[attr-defined]
            self._wire_src = None  # type: ignore[attr-defined]
            self._state    = _S.WIRE_FROM  # type: ignore[attr-defined]
        elif self._active_tab == "wires":  # type: ignore[attr-defined]
            self._wire_src = None  # type: ignore[attr-defined]
            self._state    = _S.WIRE_FROM  # type: ignore[attr-defined]
        else:
            self._sel_type = None  # type: ignore[attr-defined]
            self._state    = _S.EXPLORE  # type: ignore[attr-defined]

    def _resume(self) -> None:
        self._state = (  # type: ignore[attr-defined]
            self._prev_state if self._prev_state != _S.PAUSED else _S.EXPLORE  # type: ignore[attr-defined]
        )

    # ---- Wire hover ----

    def _nearest_wire(self, sx: int, sy: int, threshold: float = 10.0) -> Optional[Wire]:
        best:   Optional[Wire] = None
        best_d: float          = threshold
        for w in self._gs.wires:  # type: ignore[attr-defined]
            src = self._gs.get_building_by_id(w.from_id)  # type: ignore[attr-defined]
            dst = self._gs.get_building_by_id(w.to_id)  # type: ignore[attr-defined]
            if src is None or dst is None:
                continue
            ax, ay = self._building_center(src)  # type: ignore[attr-defined]
            bx, by = self._building_center(dst)  # type: ignore[attr-defined]
            d = _pt_seg_dist(sx, sy, ax, ay, bx, by)
            if d < best_d:
                best_d = d
                best   = w
        return best

    # ---- Alerts / notifications ----

    def _flash(self, msg: str) -> None:
        self._alert_text  = msg  # type: ignore[attr-defined]
        self._alert_timer = 2.5  # type: ignore[attr-defined]

    def _on_level_up(self, new_level: int) -> None:
        self._levelup_timer = 6.0   # type: ignore[attr-defined]
        self._levelup_level = new_level  # type: ignore[attr-defined]
        self._prev_level    = new_level  # type: ignore[attr-defined]
        sfx_manager.play("levelup")
        # Expand world on level up
        self._expand_world_for_level(new_level)  # type: ignore[attr-defined]

    # ---- Debug ----

    def _handle_debug_click(self, pos: tuple[int, int]) -> None:
        for label, rect in self._debug_btn_rects:  # type: ignore[attr-defined]
            if rect.collidepoint(pos):
                if label == "Reset XP":
                    self._gs.total_xp = 0.0  # type: ignore[attr-defined]
                elif label == "+€10k":
                    self._gs.money += 10_000.0  # type: ignore[attr-defined]
                elif label == "Reset €":
                    self._gs.money = 5_000.0  # type: ignore[attr-defined]
                elif label.startswith("LVL ") or label == "MAX":
                    if label == "MAX":
                        target_lv = MAX_LEVEL
                    else:
                        target_lv = int(label[4:])
                    self._gs.total_xp = XP_THRESHOLDS[target_lv - 1]  # type: ignore[attr-defined]
                elif label == "Close":
                    self._debug_open = False  # type: ignore[attr-defined]
