"""Tests for the core Yoro codec — pure Python, no Django required."""

import pytest

import yoro
from yoro.codec import (
    _base29_to_int,
    _canonical_precision,
    _code_length,
    _d2xy,
    _int_to_base29,
    _xy2d,
    cells_in_bounds,
    decode,
    domain_for_country,
    encode,
    get_bounds,
    neighbors,
    precision_levels,
    resolution,
    snap_precision,
)
from yoro.constants import DOMAINS


# ---------- Encode / decode round-trip ----------

class TestEncodeDecode:
    """The fundamental invariant: encode(decode(code)) ≈ code."""

    @pytest.mark.parametrize("domain", ["CI", "BF", "FR", "XX", "US"])
    def test_roundtrip_multiple_domains(self, domain):
        dom = DOMAINS[domain]
        lat = (dom["lat_min"] + dom["lat_max"]) / 2
        lon = (dom["lon_min"] + dom["lon_max"]) / 2

        code = encode(lat, lon, precision=12, domain=domain)
        decoded = decode(code)

        assert decoded["domain"] == domain
        # Tolerance scales with domain size: XX (global) has much coarser cells
        lat_range = dom["lat_max"] - dom["lat_min"]
        tol = max(0.01, lat_range / (1 << 12) * 2)
        assert abs(decoded["lat"] - lat) < tol
        assert abs(decoded["lon"] - lon) < tol

    def test_abidjan(self):
        code = encode(5.345, -4.028, domain="CI")
        assert code.startswith("CI-")
        decoded = decode(code)
        assert abs(decoded["lat"] - 5.345) < 0.01
        assert abs(decoded["lon"] - (-4.028)) < 0.01

    def test_ouagadougou(self):
        code = encode(12.3714, -1.5197, domain="BF")
        assert code.startswith("BF-")
        decoded = decode(code)
        assert abs(decoded["lat"] - 12.3714) < 0.01

    def test_global_domain(self):
        code = encode(48.8566, 2.3522, domain="XX")
        assert code.startswith("XX-")
        decoded = decode(code)
        assert abs(decoded["lat"] - 48.8566) < 0.5  # lower precision for global

    @pytest.mark.parametrize("precision", [4, 8, 12, 16])
    def test_precision_levels(self, precision):
        code = encode(6.8, -5.3, precision=precision, domain="CI")
        decoded = decode(code)
        assert decoded["precision"] >= 1

    def test_encode_returns_string(self):
        result = encode(6.8, -5.3)
        assert isinstance(result, str)
        assert "-" in result

    def test_code_is_deterministic(self):
        c1 = encode(6.8, -5.3, domain="CI", precision=12)
        c2 = encode(6.8, -5.3, domain="CI", precision=12)
        assert c1 == c2


# ---------- Decode validation ----------

class TestDecode:
    def test_invalid_format_no_dash(self):
        with pytest.raises(ValueError, match="dash"):
            decode("CI4H7A3B")

    def test_unknown_domain(self):
        with pytest.raises(ValueError, match="Unknown"):
            decode("ZZ-4H7A3B")

    def test_invalid_character(self):
        with pytest.raises(ValueError, match="Invalid character"):
            decode("CI-000O00")  # 'O' is excluded from alphabet

    def test_decode_returns_bounds(self):
        code = encode(6.8, -5.3, domain="CI")
        decoded = decode(code)
        bounds = decoded["bounds"]
        assert bounds["lat_min"] < bounds["lat_max"]
        assert bounds["lon_min"] < bounds["lon_max"]
        assert bounds["lat_min"] <= decoded["lat"] <= bounds["lat_max"]
        assert bounds["lon_min"] <= decoded["lon"] <= bounds["lon_max"]


# ---------- Encode validation ----------

class TestEncode:
    def test_unknown_domain_raises(self):
        with pytest.raises(ValueError, match="Unknown domain"):
            encode(6.8, -5.3, domain="ZZ")

    def test_boundary_coordinates(self):
        dom = DOMAINS["CI"]
        code = encode(dom["lat_min"], dom["lon_min"], domain="CI")
        assert code.startswith("CI-")

        code = encode(dom["lat_max"] - 0.001, dom["lon_max"] - 0.001, domain="CI")
        assert code.startswith("CI-")

    def test_coordinates_outside_domain_clamped(self):
        # Should not raise — clamped to grid boundary
        code = encode(0.0, 0.0, domain="CI")
        assert code.startswith("CI-")


# ---------- Neighbors ----------

class TestNeighbors:
    def test_neighbors_returns_list(self):
        code = encode(6.8, -5.3, domain="CI")
        nbrs = neighbors(code)
        assert isinstance(nbrs, list)
        assert len(nbrs) <= 8

    def test_center_cell_has_8_neighbors(self):
        code = encode(7.5, -5.5, domain="CI")
        nbrs = neighbors(code)
        assert len(nbrs) == 8

    def test_all_neighbors_are_different(self):
        code = encode(7.5, -5.5, domain="CI")
        nbrs = neighbors(code)
        assert len(nbrs) == len(set(nbrs))

    def test_neighbor_is_nearby(self):
        code = encode(7.5, -5.5, domain="CI")
        original = decode(code)
        for n in neighbors(code):
            nd = decode(n)
            assert abs(nd["lat"] - original["lat"]) < 0.1
            assert abs(nd["lon"] - original["lon"]) < 0.1

    def test_invalid_format(self):
        with pytest.raises(ValueError):
            neighbors("NOPE")


# ---------- Resolution ----------

class TestResolution:
    def test_higher_precision_smaller_resolution(self):
        r4 = resolution(4, "CI")
        r8 = resolution(8, "CI")
        r12 = resolution(12, "CI")
        assert r4 > r8 > r12

    def test_returns_meters(self):
        r = resolution(12, "CI")
        assert r > 0
        assert r < 100_000  # sanity check


# ---------- Cells in bounds ----------

class TestCellsInBounds:
    def test_returns_cells(self):
        cells = cells_in_bounds(6.7, 6.9, -5.4, -5.2, precision=4, domain="CI")
        assert len(cells) > 0
        for cell in cells:
            assert "code" in cell
            assert "bounds" in cell

    def test_respects_max_cells(self):
        cells = cells_in_bounds(4.0, 11.0, -9.0, -2.0, precision=12, domain="CI", max_cells=10)
        assert cells == []  # too many cells

    def test_unknown_domain_returns_empty(self):
        cells = cells_in_bounds(0, 1, 0, 1, domain="ZZ")
        assert cells == []


# ---------- get_bounds ----------

class TestGetBounds:
    def test_returns_dict(self):
        code = encode(6.8, -5.3, domain="CI")
        bounds = get_bounds(code)
        assert "lat_min" in bounds
        assert "lon_max" in bounds


# ---------- domain_for_country ----------

class TestDomainForCountry:
    def test_known_country(self):
        assert domain_for_country("CI") == "CI"
        assert domain_for_country("ci") == "CI"
        assert domain_for_country("BF") == "BF"

    def test_unknown_falls_back_to_xx(self):
        assert domain_for_country("JP") == "XX"
        assert domain_for_country("BR") == "XX"

    def test_none_defaults_to_ci(self):
        assert domain_for_country(None) == "CI"


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

class TestHilbertPrimitives:
    def test_xy2d_d2xy_roundtrip(self):
        for p in [4, 8, 12]:
            for d in [0, 1, 100, (1 << (2 * p)) - 1]:
                x, y = _d2xy(p, d)
                assert _xy2d(p, x, y) == d

    def test_base29_roundtrip(self):
        for val in [0, 1, 29, 841, 100_000]:
            s = _int_to_base29(val, 6)
            assert _base29_to_int(s) == val


# ---------- Precision levels ----------

class TestPrecisionLevels:
    def test_returns_list(self):
        levels = precision_levels(domain="CI")
        assert isinstance(levels, list)
        assert len(levels) > 0

    def test_each_level_has_required_keys(self):
        for lv in precision_levels(domain="CI"):
            assert "precision" in lv
            assert "code_length" in lv
            assert "grid_size" in lv
            assert "total_cells" in lv
            assert "resolution_m" in lv

    def test_precision_increases_with_code_length(self):
        levels = precision_levels(domain="CI")
        precisions = [lv["precision"] for lv in levels]
        assert precisions == sorted(precisions)

    def test_resolution_decreases(self):
        levels = precision_levels(domain="CI")
        resolutions = [lv["resolution_m"] for lv in levels]
        for i in range(1, len(resolutions)):
            assert resolutions[i] < resolutions[i - 1]

    def test_grid_size_is_power_of_two(self):
        for lv in precision_levels(domain="CI"):
            g = lv["grid_size"]
            assert g & (g - 1) == 0  # power of 2

    def test_total_cells_is_grid_squared(self):
        for lv in precision_levels(domain="CI"):
            assert lv["total_cells"] == lv["grid_size"] ** 2

    def test_code_length_increments_by_one(self):
        levels = precision_levels(domain="CI", max_code_length=8)
        lengths = [lv["code_length"] for lv in levels]
        assert lengths == list(range(1, 9))

    def test_unknown_domain_raises(self):
        with pytest.raises(ValueError):
            precision_levels(domain="ZZ")

    def test_p19_is_canonical(self):
        levels = precision_levels(domain="CI", max_code_length=10)
        precisions = [lv["precision"] for lv in levels]
        assert 19 in precisions

    def test_default_12_is_canonical(self):
        levels = precision_levels(domain="CI")
        precisions = [lv["precision"] for lv in levels]
        assert 12 in precisions


class TestSnapPrecision:
    def test_canonical_returns_self(self):
        # p=12 is canonical (k=5)
        assert snap_precision(12) == 12

    def test_non_canonical_snaps(self):
        # p=10 and p=11 both snap to p=12 (k=5)
        assert snap_precision(10) == 12
        assert snap_precision(11) == 12

    def test_p18_snaps_to_p19(self):
        assert snap_precision(18) == 19
        assert snap_precision(19) == 19

    def test_p15_snaps_to_p17(self):
        assert snap_precision(15) == 17
        assert snap_precision(16) == 17
        assert snap_precision(17) == 17


# ---------- Types ----------

class TestTypes:
    def test_bounds_as_dict(self):
        from yoro.types import Bounds
        b = Bounds(lat_min=6.0, lat_max=7.0, lon_min=-5.0, lon_max=-4.0)
        d = b.as_dict()
        assert d["lat_min"] == 6.0

    def test_bounds_as_bbox_tuple(self):
        from yoro.types import Bounds
        b = Bounds(lat_min=6.0, lat_max=7.0, lon_min=-5.0, lon_max=-4.0)
        assert b.as_bbox_tuple() == (-5.0, 6.0, -4.0, 7.0)


# ---------- Package metadata ----------

class TestMeta:
    def test_version(self):
        assert yoro.__version__

    def test_domains_available(self):
        assert "CI" in yoro.DOMAINS
        assert "XX" in yoro.DOMAINS
