"""Location quad: a slippy-map image centred on the device's IP-geolocated position,
with a marker. Drag to pan, scroll to zoom, double-click to recentre.

The position comes from the shared LocationService (backend/geolocate.py), which
traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it
through a public geolocation API. The "Locate via IP" feature toggle gates that
lookup entirely; when off, this quad shows a placeholder instead. "Satellite view"
switches the tiles between the dark night map and Esri satellite imagery.

We render a static map (backend/staticmap.py) rather than an interactive libshumate
map: Shumate does not paint tiles in this environment (the official shumate-demo
shows the same blank map), while tile downloads themselves work fine — so panning is
done by re-rendering at a new centre. The Weather quad consumes the same service.
"""

from __future__ import annotations

import math
import sys

import gi

gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, GLib, Gtk  # noqa: E402

from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR, CACHE_DIR

_SCRIPT = str(BACKEND_DIR / "staticmap.py")
_TILE = 256


def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
    n = 2 ** zoom
    y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n
    x = (lon + 180.0) / 360.0 * n
    return x * _TILE, y * _TILE


def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]:
    n = 2 ** zoom
    lon = px / (_TILE * n) * 360.0 - 180.0
    lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n)))))
    return lat, lon


class _MapView(Gtk.Box):
    def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int],
                 tag: str, show_info: bool):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self.add_css_class("map-view")
        self.ctx = ctx
        self.zoom = zoom
        self.size = size
        self._out = str(CACHE_DIR / f"map_{tag}.png")
        self._loc: tuple[float, float] | None = None    # geolocated position (marker)
        self._view: tuple[float, float] | None = None    # current view centre
        self._drag_from: tuple[float, float] | None = None
        self._render_pending = False

        self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True)
        self.picture.add_css_class("map-picture")
        self.append(self.picture)

        self._info = None
        if show_info:
            self._info = Gtk.Label(label="Locating…", xalign=0.0)
            self._info.add_css_class("map-info")
            self.append(self._info)

        self._wire_gestures()
        ctx.services.location.subscribe(self._on_location)

    def _style(self) -> str:
        return "satellite" if self.ctx.feature("satellite", False) else "dark"

    # -- data / render -----------------------------------------------------
    def _on_location(self, data: dict) -> None:
        self._loc = (data["lat"], data["lon"])
        self._view = (data["lat"], data["lon"])   # recentre on a fresh fix
        self._render()
        if self._info is not None:
            city = data.get("city") or "Unknown"
            country = data.get("country") or ""
            self._info.set_text(f"{city}, {country}  ·  {data['lat']:.3f}, {data['lon']:.3f}"
                                f"   ·   drag to pan · scroll to zoom · double-click to recentre")

    def _render(self) -> None:
        if self._view is None:
            return
        vlat, vlon = self._view
        mlat, mlon = self._loc or self._view
        w, h = self.size
        run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom),
                  str(w), str(h), self._out, self._style(), str(mlat), str(mlon)],
                 self._on_rendered)

    def _schedule_render(self) -> None:
        # coalesce the flood of drag/scroll updates into ~11 renders/sec
        if self._render_pending:
            return
        self._render_pending = True

        def go() -> bool:
            self._render_pending = False
            self._render()
            return GLib.SOURCE_REMOVE
        GLib.timeout_add(90, go)

    def _on_rendered(self, ok: bool, out: str, err: str) -> None:
        if not ok:
            return
        # load a fresh texture; set_filename would ignore an unchanged path on re-pan
        try:
            self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out))
        except GLib.Error:
            self.picture.set_filename(self._out)

    # -- gestures: drag to pan, scroll to zoom, double-click to recentre ----
    def _wire_gestures(self) -> None:
        drag = Gtk.GestureDrag()
        drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view))
        drag.connect("drag-update", self._on_drag)
        self.picture.add_controller(drag)

        scroll = Gtk.EventControllerScroll(
            flags=Gtk.EventControllerScrollFlags.VERTICAL)
        scroll.connect("scroll", self._on_scroll)
        self.picture.add_controller(scroll)

        click = Gtk.GestureClick()
        click.connect("pressed", self._on_click)
        self.picture.add_controller(click)

    def _on_drag(self, _gesture, ox: float, oy: float) -> None:
        if self._drag_from is None:
            return
        cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom)
        # drag right → the view centre moves left (map content follows the cursor)
        self._view = _px_latlon(cx - ox, cy - oy, self.zoom)
        self._schedule_render()

    def _on_scroll(self, _controller, _dx: float, dy: float) -> bool:
        self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1)))
        self._schedule_render()
        return True

    def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None:
        if n_press >= 2 and self._loc is not None:
            self._view = self._loc
            self._schedule_render()


def _placeholder() -> Gtk.Widget:
    box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
                  valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
    box.add_css_class("map-view")
    box.append(Gtk.Label(label="IP location is off"))
    return box


def build(ctx: ModuleContext) -> ModuleInstance:
    # "Locate via IP" gates geolocation entirely: when off we neither traceroute nor
    # hit any geolocation API, and just show a placeholder. Toggling it rebuilds the
    # card (via QuadCard), so flipping it back on re-triggers the lookup.
    if not ctx.feature("ip_locate", True):
        return ModuleInstance(compact=_placeholder(), expanded=None)
    ctx.services.location.get()  # kick off geolocation (traceroute → API) if not started
    compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False)
    expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True)
    return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False)


SPEC = ModuleSpec(
    id="location",
    title="Location",
    icon="",  # nf-fa-map_marker
    build=build,
    features=[Feature("ip_locate", "Locate via IP", True),
              Feature("satellite", "Satellite view", False)],
)
