"""
Tests for the Valhalla map-matching backend.

Covers the public and internal helpers of
``walkingpandas.io.match.backends.valhalla``: polyline6 decoding,
BFS pathfinding, nearest-node lookup, and the full ValhallaMatcher
three-phase pipeline (trace / snap / enrich).

All tests are self-contained and do **not** require pyvalhalla, osmnx,
or network access.  External dependencies are mocked where needed.
"""

from unittest.mock import MagicMock, patch

import geopandas as gpd
import pandas as pd
import pytest
from shapely.geometry import Point

from walkingpandas.io.match.backends.valhalla import (
    ValhallaMatcher,
    _bfs_path,
    _find_closest_node,
    decode_valhalla,
)


# ===================================================================
# Polyline6 helper
# ===================================================================


def _encode_value(value: int) -> str:
    """Encode a single integer as a polyline6 component."""
    value = value << 1
    if value < 0:
        value = ~value
    chars = []
    while value >= 0x20:
        chars.append(chr((0x20 | (value & 0x1F)) + 63))
        value >>= 5
    chars.append(chr(value + 63))
    return "".join(chars)


def _encode_coords(coords):
    """Encode a list of (lon, lat) tuples into polyline6."""
    prev_lat, prev_lon = 0, 0
    parts = []
    for lon, lat in coords:
        lat_int = round(lat * 1e6)
        lon_int = round(lon * 1e6)
        parts.append(_encode_value(lat_int - prev_lat))
        parts.append(_encode_value(lon_int - prev_lon))
        prev_lat, prev_lon = lat_int, lon_int
    return "".join(parts)


# ===================================================================
# Way-structure builder (mirrors conftest's sample_osm_* topology)
# ===================================================================

N1 = (8.5400, 47.3780)
N2 = (8.5402, 47.3782)
N3 = (8.5404, 47.3784)
N4 = (8.5406, 47.3780)


def _build_way_structures():
    """Build _way_graphs, _way_nodes, _edge_map from conftest topology.

    Topology:
        way 100: 1001 --edge 0--> 1002 --edge 1--> 1003
        way 200: 1002 --edge 2--> 1004
    """
    way_graphs = {
        "100": {
            1001: {1002: 0},
            1002: {1001: 0, 1003: 1},
            1003: {1002: 1},
        },
        "200": {
            1002: {1004: 2},
            1004: {1002: 2},
        },
    }
    way_nodes = {
        "100": {1001: N1, 1002: N2, 1003: N3},
        "200": {1002: N2, 1004: N4},
    }
    edge_map = {
        (1001, 1002): 0, (1002, 1001): 0,
        (1002, 1003): 1, (1003, 1002): 1,
        (1002, 1004): 2, (1004, 1002): 2,
    }
    return way_graphs, way_nodes, edge_map


# ===================================================================
# decode_valhalla
# ===================================================================


class TestDecodeValhalla:
    """Tests for the polyline6 decoder."""

    def test_empty_string(self):
        assert decode_valhalla("") == []

    def test_known_encoding(self):
        original = [(8.540000, 47.378000)]
        encoded = _encode_coords(original)
        decoded = decode_valhalla(encoded)

        assert len(decoded) == 1
        assert abs(decoded[0][0] - 8.540000) < 1e-5
        assert abs(decoded[0][1] - 47.378000) < 1e-5

    def test_multiple_points(self):
        original = [(8.5400, 47.3780), (8.5402, 47.3782)]
        encoded = _encode_coords(original)
        decoded = decode_valhalla(encoded)

        assert len(decoded) == 2
        assert abs(decoded[0][1] - 47.3780) < 1e-4
        assert abs(decoded[1][1] - 47.3782) < 1e-4


# ===================================================================
# _bfs_path
# ===================================================================


class TestBfsPath:
    """Tests for the BFS single-way pathfinder."""

    def test_path_exists(self):
        graph = {1001: {1002: 0}, 1002: {1001: 0, 1003: 1}, 1003: {1002: 1}}
        result = _bfs_path(graph, 1001, 1003)
        assert result == [(1001, 1002, 0), (1002, 1003, 1)]

    def test_start_equals_end(self):
        graph = {1001: {1002: 0}}
        assert _bfs_path(graph, 1001, 1001) == []

    def test_no_path(self):
        graph = {1001: {1002: 0}, 1003: {1004: 1}}
        assert _bfs_path(graph, 1001, 1004) is None

    def test_single_edge(self):
        graph = {10: {20: 5}, 20: {10: 5}}
        result = _bfs_path(graph, 10, 20)
        assert result == [(10, 20, 5)]

    def test_start_not_in_graph(self):
        graph = {1001: {1002: 0}}
        assert _bfs_path(graph, 9999, 1002) is None


# ===================================================================
# _find_closest_node
# ===================================================================


class TestFindClosestNode:
    """Tests for the brute-force nearest-node lookup."""

    def test_basic(self):
        nodes = {1001: (0.0, 0.0), 1002: (1.0, 0.0), 1003: (2.0, 0.0)}
        assert _find_closest_node((0.9, 0.0), nodes) == 1002

    def test_exact_match(self):
        nodes = {1001: (5.0, 5.0)}
        assert _find_closest_node((5.0, 5.0), nodes) == 1001

    def test_empty_dict(self):
        assert _find_closest_node((0.0, 0.0), {}) is None

    def test_single_node(self):
        nodes = {42: (10.0, 20.0)}
        assert _find_closest_node((0.0, 0.0), nodes) == 42


# ===================================================================
# ValhallaMatcher helper
# ===================================================================


def _make_matcher_no_setup():
    """Create a ValhallaMatcher without calling _setup()."""
    with patch.object(ValhallaMatcher, "_setup"):
        m = ValhallaMatcher(osm_pbf="/fake/path.pbf")
    m.actor = MagicMock()
    return m


# ===================================================================
# ValhallaMatcher.trace
# ===================================================================


class TestValhallaTrace:
    def test_basic_trace(self):
        matcher = _make_matcher_no_setup()
        matcher.actor.trace_attributes.return_value = {
            "shape": "some_encoded_shape",
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [{"lon": 8.54, "lat": 47.38, "type": "matched"}],
        }

        chunk_df = pd.DataFrame(
            {"trip_id": ["t1", "t1"], "lat": [47.378, 47.379], "lon": [8.54, 8.541]}
        )
        result = matcher.trace(chunk_df)

        assert len(result) == 1
        assert result.iloc[0]["trip_id"] == "t1"
        assert "raw_shape" in result.columns
        assert "valhalla_edges" in result.columns

    def test_trace_missing_columns(self):
        matcher = _make_matcher_no_setup()
        bad_df = pd.DataFrame({"x": [1], "y": [2]})
        with pytest.raises(ValueError, match="missing required columns"):
            matcher.trace(bad_df)

    def test_trace_empty_response(self):
        matcher = _make_matcher_no_setup()
        matcher.actor.trace_attributes.return_value = {"admins": []}

        chunk_df = pd.DataFrame(
            {"trip_id": ["t1"], "lat": [47.378], "lon": [8.54]}
        )
        result = matcher.trace(chunk_df)
        assert result.empty

    def test_trace_exception_logged(self):
        matcher = _make_matcher_no_setup()
        matcher.actor.trace_attributes.side_effect = RuntimeError("boom")

        chunk_df = pd.DataFrame(
            {"trip_id": ["t1"], "lat": [47.378], "lon": [8.54]}
        )
        result = matcher.trace(chunk_df)
        assert result.empty


# ===================================================================
# ValhallaMatcher.snap
# ===================================================================


class TestValhallaSnap:
    def test_snap_empty_traces(self):
        matcher = _make_matcher_no_setup()
        result = matcher.snap(pd.DataFrame(), network=None)
        assert result.empty

    def test_snap_missing_columns(self):
        matcher = _make_matcher_no_setup()
        bad_df = pd.DataFrame({"trip_id": ["t1"]})
        with pytest.raises(ValueError, match="missing required columns"):
            matcher.snap(bad_df)

    def test_snap_with_fixture(self, sample_osm_nodes_gdf):
        """Verify snap produces a valid walk sequence for a known topology."""
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        wg, wn, em = _build_way_structures()
        matcher._way_graphs = wg
        matcher._way_nodes = wn
        matcher._edge_map = em

        encoded = _encode_coords([N1, N2, N3])

        raw_traces = pd.DataFrame([{
            "trip_id": "walk_1",
            "raw_shape": encoded,
            "valhalla_edges": [
                {"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1},
                {"way_id": 100, "begin_shape_index": 1, "end_shape_index": 2},
            ],
            "matched_points": [],
            "original_points": pd.DataFrame(),
        }])

        result = matcher.snap(raw_traces)

        assert not result.empty
        assert set(result.columns) >= {"walk_id", "node_id", "exit_edge", "sequence_idx"}

        node_ids = result["node_id"].tolist()
        assert 1001 in node_ids
        assert 1002 in node_ids
        assert 1003 in node_ids

    def test_snap_skips_unknown_way(self, sample_osm_nodes_gdf):
        """Edges with an unknown way_id should be silently skipped."""
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        wg, wn, em = _build_way_structures()
        matcher._way_graphs = wg
        matcher._way_nodes = wn
        matcher._edge_map = em

        encoded = _encode_coords([N1, N2])

        raw_traces = pd.DataFrame([{
            "trip_id": "walk_1",
            "raw_shape": encoded,
            "valhalla_edges": [
                {"way_id": 999, "begin_shape_index": 0, "end_shape_index": 1},
            ],
            "matched_points": [],
            "original_points": pd.DataFrame(),
        }])

        result = matcher.snap(raw_traces)
        assert result.empty

    def test_snap_inter_chunk_repair(self, sample_osm_nodes_gdf):
        """Gap between two edges on different ways should be repaired."""
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        wg, wn, em = _build_way_structures()
        matcher._way_graphs = wg
        matcher._way_nodes = wn
        matcher._edge_map = em

        encoded = _encode_coords([N1, N2, N3, N2, N4])

        raw_traces = pd.DataFrame([{
            "trip_id": "walk_1",
            "raw_shape": encoded,
            "valhalla_edges": [
                {"way_id": 100, "begin_shape_index": 0, "end_shape_index": 2},
                {"way_id": 200, "begin_shape_index": 3, "end_shape_index": 4},
            ],
            "matched_points": [],
            "original_points": pd.DataFrame(),
        }])

        result = matcher.snap(raw_traces)
        assert not result.empty


# ===================================================================
# ValhallaMatcher.enrich
# ===================================================================


class TestValhallaEnrich:
    def test_enrich_empty(self):
        matcher = _make_matcher_no_setup()
        result = matcher.enrich(pd.DataFrame(), pd.DataFrame())
        assert result.empty

    def test_enrich_timestamps(self, sample_osm_nodes_gdf):
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf

        snapped_df = pd.DataFrame([
            {"walk_id": "w1", "sequence_idx": 0, "node_id": 1001, "exit_edge": 0},
            {"walk_id": "w1", "sequence_idx": 1, "node_id": 1002, "exit_edge": None},
        ])

        orig_points = pd.DataFrame([
            {"lon": 8.5400, "lat": 47.3780, "timestamp": "2024-01-01 08:00:00"},
            {"lon": 8.5402, "lat": 47.3782, "timestamp": "2024-01-01 08:00:10"},
        ])

        raw_traces = pd.DataFrame([{
            "trip_id": "w1",
            "raw_shape": "",
            "valhalla_edges": [],
            "matched_points": [
                {"lon": 8.5400, "lat": 47.3780},
                {"lon": 8.5402, "lat": 47.3782},
            ],
            "original_points": orig_points,
        }])

        result = matcher.enrich(
            snapped_df, raw_traces, node_attributes=["timestamp"]
        )

        assert "timestamp" in result.columns
        ts_vals = result["timestamp"].dropna().tolist()
        assert len(ts_vals) == 2
        assert ts_vals[0] == "2024-01-01 08:00:00"
        assert ts_vals[1] == "2024-01-01 08:00:10"

    def test_enrich_edge_attributes(self, sample_osm_nodes_gdf):
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf

        snapped_df = pd.DataFrame([
            {"walk_id": "w1", "sequence_idx": 0, "node_id": 1001, "exit_edge": 0},
            {"walk_id": "w1", "sequence_idx": 1, "node_id": 1002, "exit_edge": None},
        ])

        orig_points = pd.DataFrame([
            {"lon": 8.5400, "lat": 47.3780, "speed": 1.2},
            {"lon": 8.5402, "lat": 47.3782, "speed": 1.5},
        ])

        raw_traces = pd.DataFrame([{
            "trip_id": "w1",
            "raw_shape": "",
            "valhalla_edges": [],
            "matched_points": [
                {"lon": 8.5400, "lat": 47.3780},
                {"lon": 8.5402, "lat": 47.3782},
            ],
            "original_points": orig_points,
        }])

        result = matcher.enrich(
            snapped_df, raw_traces, edge_attributes=["speed"]
        )

        assert "speed" in result.columns
        assert result.loc[result["exit_edge"].notna(), "speed"].notna().all()

    def test_enrich_no_trace_data(self, sample_osm_nodes_gdf):
        """walk_id not found in raw_traces should pass through unchanged."""
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf

        snapped_df = pd.DataFrame([
            {"walk_id": "unknown", "sequence_idx": 0, "node_id": 1001, "exit_edge": None},
        ])
        raw_traces = pd.DataFrame([{
            "trip_id": "other",
            "raw_shape": "",
            "valhalla_edges": [],
            "matched_points": [],
            "original_points": pd.DataFrame(),
        }])

        result = matcher.enrich(snapped_df, raw_traces, node_attributes=["foo"])
        assert len(result) == 1
        assert "foo" not in result.columns


# ===================================================================
# Full pipeline: trace -> snap -> enrich
# ===================================================================


class TestValhallaFullPipeline:
    """End-to-end trace -> snap -> enrich with mocked Valhalla Actor."""

    def test_pipeline(self, sample_osm_nodes_gdf):
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        wg, wn, em = _build_way_structures()
        matcher._way_graphs = wg
        matcher._way_nodes = wn
        matcher._edge_map = em

        encoded = _encode_coords([N1, N2, N3])

        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [
                {"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1},
                {"way_id": 100, "begin_shape_index": 1, "end_shape_index": 2},
            ],
            "matched_points": [
                {"lon": N1[0], "lat": N1[1], "type": "matched"},
                {"lon": N2[0], "lat": N2[1], "type": "matched"},
                {"lon": N3[0], "lat": N3[1], "type": "matched"},
            ],
        }

        gps_df = pd.DataFrame({
            "trip_id": ["t1", "t1", "t1"],
            "lat": [N1[1], N2[1], N3[1]],
            "lon": [N1[0], N2[0], N3[0]],
            "timestamp": [
                "2024-01-01 08:00:00",
                "2024-01-01 08:00:10",
                "2024-01-01 08:00:20",
            ],
        })

        raw_traces = matcher.trace(gps_df)
        assert not raw_traces.empty

        snapped = matcher.snap(raw_traces)
        assert not snapped.empty
        assert set(snapped.columns) >= {"walk_id", "node_id", "exit_edge", "sequence_idx"}

        enriched = matcher.enrich(
            snapped, raw_traces, node_attributes=["timestamp"]
        )
        assert not enriched.empty
        assert "timestamp" in enriched.columns
