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

Covers the public and internal helpers of
``walkingpandas.io.match.backends.valhalla``: polyline6 decoding,
topology hole patching, spatial enrichment, distance interpolation,
and the ValhallaMatcher single-pass pipeline (match_trajectory).

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

import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

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

from walkingpandas.io.match.backends.valhalla import (
    ValhallaMatcher,
    decode_valhalla,
    patch_network_holes,
    spatial_enrich,
    interpolate_along_path,
)


# ===================================================================
# 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)


# ===================================================================
# 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


# ===================================================================
# patch_network_holes
# ===================================================================


class TestPatchNetworkHoles:
    """Tests for the topology hole patching helper."""

    def test_no_holes(self):
        edges_df = pd.DataFrame({
            "source_node": [1, 2],
            "target_node": [2, 3],
            "edge_id": [10, 20],
            "way_id": [100, 100],
        })
        result_df = edges_df.copy()
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].notna().all()

    def test_fills_gap(self):
        edges_df = pd.DataFrame({
            "source_node": [1, 2, 3],
            "target_node": [2, 3, 4],
            "edge_id": [10, 20, 30],
            "way_id": [100, 100, 100],
        })
        result_df = pd.DataFrame({
            "source_node": pd.array([1, pd.NA, 3], dtype="Int64"),
            "target_node": pd.array([2, pd.NA, 4], dtype="Int64"),
            "edge_id": pd.array([10, pd.NA, 30], dtype="Int64"),
            "way_id": pd.array([100, pd.NA, 100], dtype="Int64"),
        })
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].notna().all()
        assert len(patched) >= 3

    def test_hole_at_start_left_as_nan(self):
        """NaN block at the very beginning is left unchanged."""
        edges_df = pd.DataFrame({
            "source_node": [1, 2],
            "target_node": [2, 3],
            "edge_id": [10, 20],
            "way_id": [100, 100],
        })
        result_df = pd.DataFrame({
            "source_node": pd.array([pd.NA, 1], dtype="Int64"),
            "target_node": pd.array([pd.NA, 2], dtype="Int64"),
            "edge_id": pd.array([pd.NA, 10], dtype="Int64"),
            "way_id": pd.array([pd.NA, 100], dtype="Int64"),
        })
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].isna().iloc[0]
        assert len(patched) == 2

    def test_hole_at_end_left_as_nan(self):
        """NaN block at the very end is left unchanged."""
        edges_df = pd.DataFrame({
            "source_node": [1, 2],
            "target_node": [2, 3],
            "edge_id": [10, 20],
            "way_id": [100, 100],
        })
        result_df = pd.DataFrame({
            "source_node": pd.array([1, pd.NA], dtype="Int64"),
            "target_node": pd.array([2, pd.NA], dtype="Int64"),
            "edge_id": pd.array([10, pd.NA], dtype="Int64"),
            "way_id": pd.array([100, pd.NA], dtype="Int64"),
        })
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].isna().iloc[-1]
        assert len(patched) == 2

    def test_patch_no_path_leaves_nan_block(self):
        """When there is no path between valid blocks, the NaN block is left as-is."""
        edges_df = pd.DataFrame({
            "source_node": [1, 2, 10, 11],
            "target_node": [2, 3, 11, 12],
            "edge_id": [10, 20, 30, 40],
            "way_id": [100, 100, 200, 200],
        })
        result_df = pd.DataFrame({
            "source_node": pd.array([1, pd.NA, 10], dtype="Int64"),
            "target_node": pd.array([2, pd.NA, 11], dtype="Int64"),
            "edge_id": pd.array([10, pd.NA, 30], dtype="Int64"),
            "way_id": pd.array([100, pd.NA, 200], dtype="Int64"),
        })
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].isna().iloc[1]
        assert len(patched) == 3

    def test_patch_node_not_found_leaves_nan_block(self):
        """When start/end node is not in the graph, NodeNotFound leaves NaN block."""
        edges_df = pd.DataFrame({
            "source_node": [1, 2],
            "target_node": [2, 3],
            "edge_id": [10, 20],
            "way_id": [100, 100],
        })
        result_df = pd.DataFrame({
            "source_node": pd.array([1, pd.NA, 2], dtype="Int64"),
            "target_node": pd.array([2, pd.NA, 3], dtype="Int64"),
            "edge_id": pd.array([10, pd.NA, 20], dtype="Int64"),
            "way_id": pd.array([100, pd.NA, 100], dtype="Int64"),
        })
        result_df.iloc[0, result_df.columns.get_loc("target_node")] = 9999
        result_df = result_df.astype({"source_node": "Int64", "target_node": "Int64", "edge_id": "Int64", "way_id": "Int64"})
        patched = patch_network_holes(result_df, edges_df)
        assert patched["edge_id"].isna().iloc[1]


# ===================================================================
# spatial_enrich
# ===================================================================


class TestSpatialEnrich:
    """Tests for the nearest-attribute enrichment helper."""

    def test_enriches_columns(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1, 2]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"speed": [1.2, 1.5]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        result = spatial_enrich(snapped, raw, ["speed"])
        assert "speed" in result.columns
        assert list(result["speed"]) == [1.2, 1.5]

    def test_empty_columns_noop(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1]},
            geometry=[Point(0.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"speed": [1.0]},
            geometry=[Point(0.0, 0.0)],
            crs="EPSG:4326",
        )
        result = spatial_enrich(snapped, raw, [])
        assert "speed" not in result.columns


# ===================================================================
# interpolate_along_path
# ===================================================================


class TestInterpolateAlongPath:
    """Tests for the distance-based interpolation helper."""

    def test_numeric_interpolation(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1, 2, 3]},
            geometry=[Point(0.0, 0.0), Point(0.5, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"speed": [0.0, 10.0]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        result = interpolate_along_path(snapped, raw, ["speed"])
        assert "speed" in result.columns
        assert result["speed"].iloc[0] == pytest.approx(0.0, abs=0.5)
        assert result["speed"].iloc[-1] == pytest.approx(10.0, abs=0.5)

    def test_datetime_interpolation(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1, 2, 3]},
            geometry=[Point(0.0, 0.0), Point(0.5, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"timestamp": pd.to_datetime(["2024-01-01 08:00:00", "2024-01-01 08:01:00"])},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        result = interpolate_along_path(snapped, raw, ["timestamp"])
        assert pd.api.types.is_datetime64_any_dtype(result["timestamp"])
        assert result["timestamp"].iloc[0] == pd.Timestamp("2024-01-01 08:00:00")
        assert result["timestamp"].iloc[-1] == pd.Timestamp("2024-01-01 08:01:00")

    def test_keep_original(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1, 2], "speed": [99.0, 99.0]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"speed": [0.0, 10.0]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        result = interpolate_along_path(snapped, raw, ["speed"], keep_original=True)
        assert "speed" in result.columns
        assert "speed_interp" in result.columns
        assert list(result["speed"]) == [99.0, 99.0]

    def test_empty_columns_noop(self):
        snapped = gpd.GeoDataFrame(
            {"node_id": [1]},
            geometry=[Point(0.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"speed": [1.0]},
            geometry=[Point(0.0, 0.0)],
            crs="EPSG:4326",
        )
        result = interpolate_along_path(snapped, raw, [])
        assert "speed" not in result.columns

    def test_skips_non_numeric_column_logs_warning(self, caplog):
        """String columns trigger a warning and are not interpolated."""
        snapped = gpd.GeoDataFrame(
            {"node_id": [1, 2, 3]},
            geometry=[Point(0.0, 0.0), Point(0.5, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        raw = gpd.GeoDataFrame(
            {"label": ["a", "b"]},
            geometry=[Point(0.0, 0.0), Point(1.0, 0.0)],
            crs="EPSG:4326",
        )
        with caplog.at_level("WARNING"):
            result = interpolate_along_path(snapped, raw, ["label"])
        assert "Skipping interpolation" in caplog.text
        assert "non-numeric" in caplog.text or "label" in caplog.text
        assert "label" not in result.columns or result["label"].isna().all()


# ===================================================================
# ValhallaMatcher constructor / _ensure_actor
# ===================================================================


class TestValhallaMatcherInit:
    def test_import_error_when_valhalla_missing(self):
        """When valhalla module is not available, _ensure_actor raises ImportError."""
        class FakeValhallaModule:
            def __getattr__(self, name):
                raise ImportError("No module named 'valhalla'")

        network = MagicMock()
        with patch("walkingpandas.io.match.backends.valhalla.ensure_tiles", return_value=Path("/tmp/fake")):
            with patch.dict(sys.modules, {"valhalla": FakeValhallaModule()}):
                with pytest.raises(ImportError, match="pyvalhalla"):
                    ValhallaMatcher(network=network, osm_pbf="/fake/path.pbf")

    def test_kwargs_stored(self):
        """Extra keyword arguments are stored in .kwargs."""
        with patch.object(ValhallaMatcher, "_ensure_actor"):
            matcher = ValhallaMatcher(
                network=MagicMock(),
                osm_pbf="/fake/path.pbf",
                some_extra_arg=42,
            )
        assert matcher.kwargs.get("some_extra_arg") == 42


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


def _make_matcher_no_setup(network=None):
    """Create a ValhallaMatcher without calling _ensure_actor()."""
    if network is None:
        network = MagicMock()
        network.connection = MagicMock()
    with patch.object(ValhallaMatcher, "_ensure_actor"):
        m = ValhallaMatcher(network=network, osm_pbf="/fake/path.pbf")
    m.actor = MagicMock()
    return m


# ===================================================================
# ValhallaMatcher.match_trajectory
# ===================================================================


class TestMatchTrajectory:
    """Tests for the unified match_trajectory pipeline."""

    def test_empty_trajectory(self):
        matcher = _make_matcher_no_setup()
        result = matcher.match_trajectory(pd.DataFrame(), walk_id="w1")
        assert result.empty

    def test_missing_columns(self):
        matcher = _make_matcher_no_setup()
        bad_df = pd.DataFrame({"x": [1], "y": [2]})
        result = matcher.match_trajectory(bad_df, walk_id="w1")
        assert result.empty

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

        gps_df = pd.DataFrame({"lat": [47.378], "lon": [8.54]})
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert result.empty

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

        gps_df = pd.DataFrame({"lat": [47.378], "lon": [8.54]})
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert result.empty

    def test_walk_id_in_output(self):
        """walk_id parameter should appear in the output DataFrame."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])

        network = MagicMock()
        conn = MagicMock()
        network.connection = conn

        conn.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame({
                "source_node": [1001],
                "target_node": [1002],
                "edge_id": [0],
                "way_id": ["100"],
            }),
            nodes_df=pd.DataFrame({
                "node_id": [1001, 1002],
                "lon": [N1[0], N2[0]],
                "lat": [N1[1], N2[1]],
            }),
        )

        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = None
        matcher.interpolate_columns = None
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [
                {"lon": N1[0], "lat": N1[1]},
                {"lon": N2[0], "lat": N2[1]},
            ],
        }

        gps_df = pd.DataFrame({
            "lat": [N1[1], N2[1]],
            "lon": [N1[0], N2[0]],
        })

        result = matcher.match_trajectory(gps_df, walk_id="my_walk")
        assert not result.empty
        assert "walk_id" in result.columns
        assert result["walk_id"].iloc[0] == "my_walk"
        assert set(result.columns) >= {"walk_id", "sequence_idx", "node_id", "exit_edge"}

    def test_auto_generated_walk_id(self):
        """When no walk_id is provided and none in DataFrame, a unique ID is generated."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])

        network = MagicMock()
        conn = MagicMock()
        network.connection = conn

        conn.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame({
                "source_node": [1001],
                "target_node": [1002],
                "edge_id": [0],
                "way_id": ["100"],
            }),
            nodes_df=pd.DataFrame({
                "node_id": [1001, 1002],
                "lon": [N1[0], N2[0]],
                "lat": [N1[1], N2[1]],
            }),
        )

        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = None
        matcher.interpolate_columns = None
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [
                {"lon": N1[0], "lat": N1[1]},
                {"lon": N2[0], "lat": N2[1]},
            ],
        }

        gps_df = pd.DataFrame({
            "lat": [N1[1], N2[1]],
            "lon": [N1[0], N2[0]],
        })

        result = matcher.match_trajectory(gps_df)
        assert not result.empty
        wid = result["walk_id"].iloc[0]
        assert wid is not None
        assert len(wid) == 12
        assert wid != "unknown"

        result2 = matcher.match_trajectory(gps_df)
        assert result2["walk_id"].iloc[0] != wid

    def test_walk_id_from_column_fallback(self):
        """When no walk_id arg is given but DataFrame has a walk_id column, use it."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])

        network = MagicMock()
        conn = MagicMock()
        network.connection = conn

        conn.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame({
                "source_node": [1001],
                "target_node": [1002],
                "edge_id": [0],
                "way_id": ["100"],
            }),
            nodes_df=pd.DataFrame({
                "node_id": [1001, 1002],
                "lon": [N1[0], N2[0]],
                "lat": [N1[1], N2[1]],
            }),
        )

        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = None
        matcher.interpolate_columns = None
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [
                {"lon": N1[0], "lat": N1[1]},
                {"lon": N2[0], "lat": N2[1]},
            ],
        }

        gps_df = pd.DataFrame({
            "walk_id": ["my_walk_from_col", "my_walk_from_col"],
            "lat": [N1[1], N2[1]],
            "lon": [N1[0], N2[0]],
        })

        result = matcher.match_trajectory(gps_df)
        assert not result.empty
        assert result["walk_id"].iloc[0] == "my_walk_from_col"

    def test_empty_way_ids_returns_empty(self):
        """When Valhalla returns no edges with way_id, result is empty."""
        matcher = _make_matcher_no_setup()
        matcher.actor.trace_attributes.return_value = {
            "shape": _encode_coords([(8.54, 47.38)]),
            "edges": [],
            "matched_points": [{"lon": 8.54, "lat": 47.38}],
        }
        gps_df = pd.DataFrame({"lat": [47.38], "lon": [8.54]})
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert result.empty

    def test_candidate_edges_empty_returns_empty(self):
        """When network has no edges for the way_ids, result is empty."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])
        network = MagicMock()
        network.connection = MagicMock()
        network.connection.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame(),
            nodes_df=pd.DataFrame({"node_id": [1001], "lon": [N1[0]], "lat": [N1[1]]}),
        )
        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = None
        matcher.interpolate_columns = None
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100}],
            "matched_points": [{"lon": N1[0], "lat": N1[1]}, {"lon": N2[0], "lat": N2[1]}],
        }
        gps_df = pd.DataFrame({"lat": [N1[1], N2[1]], "lon": [N1[0], N2[0]]})
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert result.empty

    def test_enrich_columns_all_includes_extra_columns(self):
        """When enrich_columns='all', extra trajectory columns are passed to enrichment."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])
        network = MagicMock()
        conn = MagicMock()
        network.connection = conn
        conn.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame({
                "source_node": [1001],
                "target_node": [1002],
                "edge_id": [0],
                "way_id": ["100"],
            }),
            nodes_df=pd.DataFrame({
                "node_id": [1001, 1002],
                "lon": [N1[0], N2[0]],
                "lat": [N1[1], N2[1]],
            }),
        )
        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = "all"
        matcher.interpolate_columns = None
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [{"lon": N1[0], "lat": N1[1]}, {"lon": N2[0], "lat": N2[1]}],
        }
        gps_df = pd.DataFrame({
            "lat": [N1[1], N2[1]],
            "lon": [N1[0], N2[0]],
            "speed": [1.2, 1.5],
        })
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert not result.empty
        assert "speed" in result.columns

    def test_interpolate_columns_branch(self):
        """When interpolate_columns is set, timestamp is interpolated along path."""
        N1 = (8.5400, 47.3780)
        N2 = (8.5402, 47.3782)
        encoded = _encode_coords([N1, N2])
        network = MagicMock()
        conn = MagicMock()
        network.connection = conn
        conn.execute.side_effect = _make_duckdb_side_effect(
            edges_df=pd.DataFrame({
                "source_node": [1001],
                "target_node": [1002],
                "edge_id": [0],
                "way_id": ["100"],
            }),
            nodes_df=pd.DataFrame({
                "node_id": [1001, 1002],
                "lon": [N1[0], N2[0]],
                "lat": [N1[1], N2[1]],
            }),
        )
        matcher = _make_matcher_no_setup(network=network)
        matcher.enrich_columns = None
        matcher.interpolate_columns = ["timestamp"]
        matcher.actor.trace_attributes.return_value = {
            "shape": encoded,
            "edges": [{"way_id": 100, "begin_shape_index": 0, "end_shape_index": 1}],
            "matched_points": [{"lon": N1[0], "lat": N1[1]}, {"lon": N2[0], "lat": N2[1]}],
        }
        gps_df = pd.DataFrame({
            "lat": [N1[1], N2[1]],
            "lon": [N1[0], N2[0]],
            "timestamp": pd.to_datetime(["2024-01-01 08:00:00", "2024-01-01 08:01:00"]),
        })
        result = matcher.match_trajectory(gps_df, walk_id="w1")
        assert not result.empty
        assert "timestamp" in result.columns
        assert pd.api.types.is_datetime64_any_dtype(result["timestamp"])


def _make_duckdb_side_effect(edges_df, nodes_df):
    """Build a side_effect for conn.execute that returns edges then nodes."""
    call_count = {"n": 0}

    def side_effect(query, *args, **kwargs):
        mock_result = MagicMock()
        call_count["n"] += 1
        if "wp_edges" in query:
            mock_result.df.return_value = edges_df
        elif "wp_nodes" in query:
            mock_result.df.return_value = nodes_df
        else:
            mock_result.df.return_value = pd.DataFrame()
        return mock_result

    return side_effect
