"""
Yoro Codec — Geographic addressing via 2D Hilbert curves.

Bijection between GPS coordinates and compact alphanumeric codes.
Pure Python, zero external dependencies.

Theory: Paul Guindo, Altius Academy SNC.
"""

from __future__ import annotations

import math

from yoro.constants import DOMAINS

ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXY"
BASE = len(ALPHABET)  # 29

CHAR_TO_VAL: dict[str, int] = {c: i for i, c in enumerate(ALPHABET)}


def _code_length(p: int, n: int = 2) -> int:
    """Number of base-29 characters needed to represent a Hilbert index at precision *p*."""
    return math.ceil(n * p * math.log(2) / math.log(BASE))


def _canonical_precision(k: int) -> int:
    """Effective Hilbert order for a code of *k* characters."""
    return int(k * math.log(BASE) / (2 * math.log(2)))


def _snap_precision(p: int) -> int:
    k = _code_length(p)
    return _canonical_precision(k)


# ---------- Hilbert curve primitives ----------

def _rot2d(s: int, x: int, y: int, rx: int, ry: int) -> tuple[int, int]:
    if ry == 0:
        if rx == 1:
            x = s - 1 - x
            y = s - 1 - y
        x, y = y, x
    return x, y


def _xy2d(p: int, x: int, y: int) -> int:
    """(x, y) grid position -> Hilbert distance."""
    n = 1 << p
    d = 0
    s = n >> 1
    while s > 0:
        rx = 1 if (x & s) > 0 else 0
        ry = 1 if (y & s) > 0 else 0
        d += s * s * ((3 * rx) ^ ry)
        x, y = _rot2d(s, x, y, rx, ry)
        s >>= 1
    return d


def _d2xy(p: int, d: int) -> tuple[int, int]:
    """Hilbert distance -> (x, y) grid position."""
    n = 1 << p
    x = y = 0
    s = 1
    while s < n:
        rx = 1 if (d & 2) > 0 else 0
        ry = 1 & (d ^ rx)
        x, y = _rot2d(s, x, y, rx, ry)
        x += s * rx
        y += s * ry
        d >>= 2
        s <<= 1
    return x, y


# ---------- Base-29 encoding ----------

def _int_to_base29(d: int, length: int) -> str:
    if d == 0:
        return ALPHABET[0] * length
    chars: list[str] = []
    val = d
    while val > 0:
        chars.append(ALPHABET[val % BASE])
        val //= BASE
    while len(chars) < length:
        chars.append(ALPHABET[0])
    chars.reverse()
    return "".join(chars)


def _base29_to_int(code: str) -> int:
    d = 0
    for c in code:
        c_upper = c.upper()
        if c_upper not in CHAR_TO_VAL:
            raise ValueError(f"Invalid character in code: '{c}'")
        d = d * BASE + CHAR_TO_VAL[c_upper]
    return d


# ---------- Cell bounds ----------

def _cell_bounds(x: int, y: int, p: int, dom: dict) -> dict[str, float]:
    m = 1 << p
    lat_step = (dom["lat_max"] - dom["lat_min"]) / m
    lon_step = (dom["lon_max"] - dom["lon_min"]) / m
    return {
        "lat_min": round(dom["lat_min"] + y * lat_step, 8),
        "lat_max": round(dom["lat_min"] + (y + 1) * lat_step, 8),
        "lon_min": round(dom["lon_min"] + x * lon_step, 8),
        "lon_max": round(dom["lon_min"] + (x + 1) * lon_step, 8),
    }


# ============================================================
# Public API
# ============================================================

def resolution(p: int, domain: str = "CI") -> float:
    """Approximate spatial resolution in meters for Hilbert order *p* in *domain*."""
    dom = DOMAINS[domain]
    lat_range = dom["lat_max"] - dom["lat_min"]
    lon_range = dom["lon_max"] - dom["lon_min"]
    m = 1 << p
    dlat = lat_range / m
    dlon = lon_range / m
    lat_m = dlat * 111_000
    lon_m = dlon * 111_000 * math.cos(math.radians((dom["lat_min"] + dom["lat_max"]) / 2))
    return max(lat_m, lon_m)


def encode(lat: float, lon: float, precision: int = 12, domain: str = "CI") -> str:
    """Encode GPS coordinates to an Yoro string.

    Args:
        lat: Latitude (WGS 84).
        lon: Longitude (WGS 84).
        precision: Hilbert order (higher = finer grid). Default 12.
        domain: ISO country code or "XX" for global.

    Returns:
        Code string, e.g. ``"CI-4H7A3B"``.

    Raises:
        ValueError: If *domain* is unknown.
    """
    if domain not in DOMAINS:
        raise ValueError(f"Unknown domain: '{domain}'. Available: {list(DOMAINS.keys())}")

    dom = DOMAINS[domain]
    k = _code_length(precision)
    p = _canonical_precision(k)
    m = 1 << p

    x = min(m - 1, int((lon - dom["lon_min"]) / (dom["lon_max"] - dom["lon_min"]) * m))
    y = min(m - 1, int((lat - dom["lat_min"]) / (dom["lat_max"] - dom["lat_min"]) * m))
    x = max(0, x)
    y = max(0, y)

    d = _xy2d(p, x, y)
    code = _int_to_base29(d, k)

    return f"{domain}-{code}"


def decode(code: str) -> dict:
    """Decode an Yoro string to GPS coordinates and cell bounds.

    Args:
        code: Yoro string, e.g. ``"CI-4H7A3B"``.

    Returns:
        Dict with keys: ``lat``, ``lon``, ``precision``, ``domain``, ``bounds``.

    Raises:
        ValueError: If the code format or domain is invalid.
    """
    if "-" not in code:
        raise ValueError("Invalid format: code must contain a dash (e.g. 'CI-4H7A3B')")

    prefix, base29_code = code.split("-", 1)
    prefix = prefix.upper()

    if prefix not in DOMAINS:
        raise ValueError(f"Unknown domain prefix: '{prefix}'")

    dom = DOMAINS[prefix]
    k = len(base29_code)
    p = _canonical_precision(k)
    m = 1 << p

    d = _base29_to_int(base29_code)
    x, y = _d2xy(p, d)

    lon = dom["lon_min"] + (x + 0.5) * (dom["lon_max"] - dom["lon_min"]) / m
    lat = dom["lat_min"] + (y + 0.5) * (dom["lat_max"] - dom["lat_min"]) / m

    bounds = _cell_bounds(x, y, p, dom)

    return {
        "lat": round(lat, 8),
        "lon": round(lon, 8),
        "precision": p,
        "domain": prefix,
        "bounds": bounds,
    }


def get_bounds(code: str) -> dict[str, float]:
    """Return only the cell bounding box for *code*."""
    return decode(code)["bounds"]


def neighbors(code: str) -> list[str]:
    """Return up to 8 neighboring cell codes (edge/corner adjacency).

    Codes on the domain boundary may return fewer than 8 neighbors.
    """
    if "-" not in code:
        raise ValueError("Invalid format")

    prefix, base29_code = code.split("-", 1)
    prefix = prefix.upper()

    if prefix not in DOMAINS:
        raise ValueError(f"Unknown domain prefix: '{prefix}'")

    k = len(base29_code)
    p = _canonical_precision(k)
    m = 1 << p

    d = _base29_to_int(base29_code)
    cx, cy = _d2xy(p, d)

    result: list[str] = []
    for dx, dy in [
        (-1, -1), (-1, 0), (-1, 1),
        (0, -1),           (0, 1),
        (1, -1),  (1, 0),  (1, 1),
    ]:
        nx, ny = cx + dx, cy + dy
        if 0 <= nx < m and 0 <= ny < m:
            nd = _xy2d(p, nx, ny)
            ncode = _int_to_base29(nd, k)
            result.append(f"{prefix}-{ncode}")

    return result


def cells_in_bounds(
    lat_min: float,
    lat_max: float,
    lon_min: float,
    lon_max: float,
    precision: int = 12,
    domain: str = "CI",
    max_cells: int = 2000,
) -> list[dict]:
    """Return all Hilbert cells that intersect a geographic bounding box.

    Each item is ``{"code": "CI-...", "bounds": {...}}``.
    Returns an empty list if the cell count would exceed *max_cells*.
    """
    domain = domain.upper()
    dom = DOMAINS.get(domain)
    if not dom:
        return []

    k = _code_length(precision)
    p = _canonical_precision(k)
    m = 1 << p

    lat_range = dom["lat_max"] - dom["lat_min"]
    lon_range = dom["lon_max"] - dom["lon_min"]
    lat_step = lat_range / m
    lon_step = lon_range / m

    y_min = max(0, int((lat_min - dom["lat_min"]) / lat_step))
    y_max = min(m - 1, int((lat_max - dom["lat_min"]) / lat_step))
    x_min = max(0, int((lon_min - dom["lon_min"]) / lon_step))
    x_max = min(m - 1, int((lon_max - dom["lon_min"]) / lon_step))

    count = (x_max - x_min + 1) * (y_max - y_min + 1)
    if count > max_cells or count <= 0:
        return []

    cells: list[dict] = []
    for y in range(y_min, y_max + 1):
        for x in range(x_min, x_max + 1):
            d = _xy2d(p, x, y)
            cells.append({
                "code": f"{domain}-{_int_to_base29(d, k)}",
                "bounds": {
                    "lat_min": dom["lat_min"] + y * lat_step,
                    "lat_max": dom["lat_min"] + (y + 1) * lat_step,
                    "lon_min": dom["lon_min"] + x * lon_step,
                    "lon_max": dom["lon_min"] + (x + 1) * lon_step,
                },
            })
    return cells


def precision_levels(domain: str = "CI", max_code_length: int = 10) -> list[dict]:
    """Return all canonical precision levels for a domain.

    A canonical precision is a Hilbert order *p* that produces a distinct
    code length *k*.  Because codes use base-29, the mapping from *p* to *k*
    is ``k = ceil(2p * log2 / log29)``.  Several consecutive values of *p*
    map to the same *k* — only the highest *p* for each *k* is canonical,
    i.e. the one that fully exploits the address space of *k* characters.

    Args:
        domain: ISO country code (affects resolution in meters).
        max_code_length: Stop after this many characters (default 10 → ~4 cm).

    Returns:
        List of dicts with keys: ``precision``, ``code_length``, ``grid_size``,
        ``total_cells``, ``resolution_m``.
    """
    if domain not in DOMAINS:
        raise ValueError(f"Unknown domain: '{domain}'")

    levels: list[dict] = []
    for k in range(1, max_code_length + 1):
        p = _canonical_precision(k)
        grid = 1 << p
        res = resolution(p, domain=domain)
        levels.append({
            "precision": p,
            "code_length": k,
            "grid_size": grid,
            "total_cells": grid * grid,
            "resolution_m": round(res, 4),
        })
    return levels


def snap_precision(p: int) -> int:
    """Return the canonical precision that *p* actually resolves to.

    Because the code length is quantized to whole base-29 characters,
    several values of *p* produce the same grid.  This function shows
    which canonical precision is effectively used.

    Example::

        >>> snap_precision(18)
        19          # p=18 and p=19 both use 8-character codes
        >>> snap_precision(15)
        17          # p=15 and p=16 both snap up to canonical p=17
    """
    k = _code_length(p)
    return _canonical_precision(k)


def domain_for_country(country_code: str | None) -> str:
    """Map an ISO country code to an Yoro domain. Falls back to ``"XX"``."""
    code = country_code.upper() if country_code else "CI"
    return code if code in DOMAINS else "XX"
