"""World-map visualization for proxy chains.

Renders hop arcs over the bundled neon world map image.  Falls back to a
drawn landmass map if the image file is absent.
"""
from __future__ import annotations

import ipaddress
import logging
import math
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse

from PIL import Image, ImageDraw, ImageFilter, ImageFont

try:
    _RESAMPLE = Image.Resampling.LANCZOS
except AttributeError:
    _RESAMPLE = Image.LANCZOS  # type: ignore[attr-defined]

from .config import normalize_proxy_url, redact_proxy_url
from .exit_intel import ExitIntel, fetch_ip_geo

log = logging.getLogger(__name__)

ChainStatus = Literal["healthy", "connecting", "dead", "idle"]

# Theme palette
_BG            = "#070713"
_GRID          = "#1a1f3a"
_LAND          = "#12182e"
_LAND_EDGE     = "#1e2a4a"
_LINE_HEALTHY  = "#00e5ff"
_LINE_CONNECTING = "#ffd000"
_LINE_DEAD     = "#ff2a6d"
_LINE_IDLE     = "#3052ff"
_YOU           = "#00ff9c"
_HOP           = "#00e5ff"
_EXIT          = "#ffd000"
_LABEL         = "#eaf2ff"
_LABEL_DIM     = "#7a8aab"
_UNKNOWN       = "#2a2f4a"

def _map_img_path() -> Path:
    """Locate world_map.png whether running from source or a frozen PyInstaller bundle."""
    import sys
    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
        return Path(sys._MEIPASS) / "proxy_chain_manager" / "world_map.png"
    return Path(__file__).resolve().parent / "world_map.png"

_cached_map: Image.Image | None = None


def _load_world_map(width: int, height: int) -> Image.Image | None:
    """Return the neon world map resized to (width × height), or None if unavailable."""
    global _cached_map
    try:
        if _cached_map is None:
            _cached_map = Image.open(_map_img_path()).convert("RGB")
        return _cached_map.resize((width, height), _RESAMPLE)
    except Exception as exc:
        log.debug("world_map load failed: %s", exc)
        return None


@dataclass(frozen=True)
class MapPoint:
    role: Literal["you", "hop", "exit"]
    label: str
    lat: float
    lon: float
    detail: str = ""
    hop_index: int = -1


def extract_hop_host(proxy_url: str) -> str | None:
    """Return hostname or IP from a proxy URL."""
    p = urlparse(normalize_proxy_url(proxy_url))
    host = (p.hostname or "").strip()
    return host or None


def resolve_host_ip(host: str, timeout_seconds: float = 4.0) -> str | None:
    """Resolve host to IPv4/IPv6 string. Never raises."""
    h = (host or "").strip()
    if not h:
        return None
    try:
        ipaddress.ip_address(h)
        return h
    except ValueError:
        pass
    try:
        prev = socket.getdefaulttimeout()
        socket.setdefaulttimeout(max(1.0, float(timeout_seconds)))
        try:
            infos = socket.getaddrinfo(h, None, type=socket.SOCK_STREAM)
        finally:
            socket.setdefaulttimeout(prev)
        for info in infos:
            addr = info[4][0]
            try:
                ipaddress.ip_address(addr)
                return addr
            except ValueError:
                continue
    except Exception as e:
        log.debug("resolve_host_ip(%s): %s", h, e)
    return None


def _loc_label(intel: ExitIntel) -> str:
    bits = [b for b in (intel.city, intel.region, intel.country) if b]
    return ", ".join(bits) if bits else intel.country_code or intel.ip or "—"


def _intel_to_point(
    role: Literal["you", "hop", "exit"],
    label: str,
    intel: ExitIntel,
    *,
    hop_index: int = -1,
) -> MapPoint | None:
    if not intel.ok or intel.lat is None or intel.lon is None:
        return None
    return MapPoint(
        role=role,
        label=label,
        lat=float(intel.lat),
        lon=float(intel.lon),
        detail=_loc_label(intel),
        hop_index=hop_index,
    )


def build_chain_map_points(
    hops: list[str],
    *,
    direct_ip: str = "",
    exit_ip: str = "",
    exit_intel: ExitIntel | None = None,
    timeout_seconds: float = 8.0,
) -> list[MapPoint]:
    """Resolve geo for YOU → hop₁ → … → EXIT. Never raises."""
    points: list[MapPoint] = []

    origin_ip = (direct_ip or "").strip()
    if origin_ip and origin_ip != "—":
        you = _intel_to_point("you", "YOU", fetch_ip_geo(origin_ip, timeout_seconds))
        if you:
            points.append(you)

    for i, hop in enumerate(hops):
        host = extract_hop_host(hop)
        if not host:
            continue
        ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
        if not ip:
            continue
        intel = fetch_ip_geo(ip, timeout_seconds)
        pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
        if pt:
            points.append(pt)

    exit_str = (exit_ip or "").strip()
    if exit_str and exit_str != "—":
        if (
            exit_intel
            and exit_intel.ok
            and exit_intel.ip == exit_str
            and exit_intel.lat is not None
            and exit_intel.lon is not None
        ):
            pt = _intel_to_point("exit", "EXIT", exit_intel)
        else:
            pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit_str, timeout_seconds))
        if pt:
            points.append(pt)

    return points


def _project(lat: float, lon: float, width: int, height: int, margin: int) -> tuple[float, float]:
    inner_w = max(1, width - margin * 2)
    inner_h = max(1, height - margin * 2)
    x = margin + (float(lon) + 180.0) / 360.0 * inner_w
    y = margin + (90.0 - float(lat)) / 180.0 * inner_h
    return x, y


def _great_circle_points(
    lat1: float,
    lon1: float,
    lat2: float,
    lon2: float,
    steps: int = 32,
) -> list[tuple[float, float]]:
    """Interpolate along a great circle for curved hop arcs."""
    phi1, lam1 = math.radians(lat1), math.radians(lon1)
    phi2, lam2 = math.radians(lat2), math.radians(lon2)
    d = 2 * math.asin(
        math.sqrt(
            math.sin((phi2 - phi1) / 2) ** 2
            + math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2
        )
    )
    if d < 1e-9:
        return [(lat1, lon1), (lat2, lon2)]
    out: list[tuple[float, float]] = []
    for i in range(steps + 1):
        f = i / steps
        a = math.sin((1 - f) * d) / math.sin(d)
        b = math.sin(f * d) / math.sin(d)
        x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2)
        y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2)
        z = a * math.sin(phi1) + b * math.sin(phi2)
        out.append((
            math.degrees(math.atan2(z, math.sqrt(x * x + y * y))),
            math.degrees(math.atan2(y, x)),
        ))
    return out


def _line_color(status: ChainStatus) -> tuple[int, int, int]:
    table = {
        "healthy":    (0, 229, 255),
        "connecting": (255, 208, 0),
        "dead":       (255, 42, 109),
        "idle":       (48, 82, 255),
    }
    return table.get(status, (48, 82, 255))


def _node_color(role: str) -> tuple[int, int, int]:
    table = {"you": (0, 255, 156), "hop": (0, 229, 255), "exit": (255, 208, 0)}
    return table.get(role, (0, 229, 255))


def _hex_to_rgb(h: str) -> tuple[int, int, int]:
    h = h.lstrip("#")
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


def _draw_glow_line(
    draw: ImageDraw.ImageDraw,
    pts: list[tuple[float, float]],
    color: tuple[int, int, int],
) -> None:
    """Draw neon glow arc: fat dim outer halo → bright core line."""
    if len(pts) < 2:
        return
    r, g, b = color
    # outer halo — wider, dim
    halo = (r // 4, g // 4, b // 4)
    draw.line(pts, fill=halo, width=7)
    # mid glow
    mid = (r // 2, g // 2, b // 2)
    draw.line(pts, fill=mid, width=4)
    # bright core
    draw.line(pts, fill=color, width=2)


def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"):
        try:
            return ImageFont.truetype(name, size)
        except OSError:
            continue
    return ImageFont.load_default()


def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
    """Fallback minimal continent silhouettes."""
    blobs: list[list[tuple[float, float]]] = [
        [(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
        [(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
        [(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)],
        [(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)],
        [(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
        [(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
    ]
    for poly in blobs:
        pts = [_project(lat, lon, width, height, margin) for lat, lon in poly]
        draw.polygon(pts, fill=_LAND, outline=_LAND_EDGE)


def render_chain_map(
    points: list[MapPoint],
    *,
    width: int = 960,
    height: int = 220,
    status: ChainStatus = "idle",
    margin: int = 12,
) -> Image.Image:
    """Render chain hops over the neon world map (or a drawn fallback)."""
    w = max(320, width)
    h = max(120, height)

    # ── background ──────────────────────────────────────────────────────────
    world = _load_world_map(w, h)
    if world is not None:
        # Slightly darken so overlaid lines pop
        bg = Image.new("RGB", (w, h), (0, 0, 0))
        img = Image.blend(world, bg, alpha=0.22)
    else:
        img = Image.new("RGB", (w, h), _BG)
        draw_bg = ImageDraw.Draw(img)
        _draw_landmasses(draw_bg, w, h, margin)
        # grid
        for lat in range(-60, 91, 30):
            y = _project(lat, 0, w, h, margin)[1]
            draw_bg.line([(margin, y), (w - margin, y)], fill=_GRID, width=1)
        for lon in range(-150, 181, 30):
            x = _project(0, lon, w, h, margin)[0]
            draw_bg.line([(x, margin), (x, h - margin)], fill=_GRID, width=1)

    draw = ImageDraw.Draw(img)

    if len(points) < 2:
        font = _load_font(12)
        msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
        # drop shadow
        draw.text((margin + 1, h // 2 - 7), msg, fill=(0, 0, 0), font=font)
        draw.text((margin, h // 2 - 8), msg, fill=_hex_to_rgb(_LABEL_DIM), font=font)
        return img

    line_rgb = _line_color(status)
    projected = [_project(p.lat, p.lon, w, h, margin) for p in points]

    # ── arc lines between consecutive hops ─────────────────────────────────
    for i in range(len(projected) - 1):
        arc = _great_circle_points(
            points[i].lat, points[i].lon,
            points[i + 1].lat, points[i + 1].lon,
        )
        arc_xy = [_project(lat, lon, w, h, margin) for lat, lon in arc]
        _draw_glow_line(draw, arc_xy, line_rgb)

    # ── nodes ───────────────────────────────────────────────────────────────
    label_font  = _load_font(11)
    detail_font = _load_font(9)
    for pt, (x, y) in zip(points, projected):
        nc = _node_color(pt.role)
        r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
        # outer glow ring
        glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3)
        draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow)
        # filled node with dark center
        draw.ellipse((x - r, y - r, x + r, y + r), fill=nc)
        draw.ellipse((x - r + 2, y - r + 2, x + r - 2, y + r - 2), fill=(5, 5, 20))

        tx, ty = int(x + r + 5), int(y - 8)
        # text drop-shadow
        draw.text((tx + 1, ty + 1), pt.label, fill=(0, 0, 0), font=label_font)
        draw.text((tx, ty), pt.label, fill=_hex_to_rgb(_LABEL), font=label_font)
        if pt.detail:
            draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_LABEL_DIM), font=detail_font)

    return img


def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
    """Resize rendered map for the GUI widget."""
    return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)
