"""
Tests for the map-matching module (walkingpandas.io.match).

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

import math
from unittest.mock import MagicMock, patch

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

from walkingpandas.io.match.osm_utils import generate_cache_key
from walkingpandas.io.match.backends.valhalla import (
    ValhallaMatcher,
    decode_valhalla,
    is_close,
    _find_edge_path,
    _filter_edges_by_way_ids,
)
from walkingpandas.io.match.gps import GPSDataset
from walkingpandas.io.match.core import match_to_disk, match_trajectories


# ===================================================================
# Pure function tests
# ===================================================================


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

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

    def test_known_encoding(self):
        """Encode a known (lat=47.378, lon=8.540) point by hand.

        polyline6 encoding for a single point (47378000, 8540000):
          lat:  47378000 -> encode
          lon:   8540000 -> encode
        We verify round-trip instead of hard-coding the encoded bytes.
        """
        original = [(8.540000, 47.378000)]

        def _encode_value(value: int) -> str:
            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)

        lat_int = round(original[0][1] * 1e6)
        lon_int = round(original[0][0] * 1e6)
        encoded = _encode_value(lat_int) + _encode_value(lon_int)

        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):
        """Two identical points should decode with correct delta encoding."""
        def _encode_value(value: int) -> str:
            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)

        lat1, lon1 = round(47.3780 * 1e6), round(8.5400 * 1e6)
        lat2, lon2 = round(47.3782 * 1e6), round(8.5402 * 1e6)

        encoded = (
            _encode_value(lat1) + _encode_value(lon1)
            + _encode_value(lat2 - lat1) + _encode_value(lon2 - lon1)
        )

        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


class TestIsClose:
    def test_identical_points(self):
        assert is_close((1.0, 2.0), (1.0, 2.0))

    def test_within_tolerance(self):
        assert is_close((1.0, 2.0), (1.0 + 1e-6, 2.0))

    def test_outside_tolerance(self):
        assert not is_close((1.0, 2.0), (1.001, 2.0))

    def test_custom_tolerance(self):
        assert is_close((0.0, 0.0), (0.005, 0.0), tol=0.01)
        assert not is_close((0.0, 0.0), (0.02, 0.0), tol=0.01)

    def test_uses_only_first_two_coords(self):
        assert is_close((1.0, 2.0, 999.0), (1.0, 2.0, -999.0))


class TestGenerateCacheKey:
    def test_deterministic(self):
        k1 = generate_cache_key(bbox=(47.4, 47.3, 8.6, 8.5))
        k2 = generate_cache_key(bbox=(47.4, 47.3, 8.6, 8.5))
        assert k1 == k2

    def test_different_bbox(self):
        k1 = generate_cache_key(bbox=(47.4, 47.3, 8.6, 8.5))
        k2 = generate_cache_key(bbox=(48.0, 47.0, 9.0, 8.0))
        assert k1 != k2

    def test_place(self):
        k = generate_cache_key(place="Zurich")
        assert isinstance(k, str) and len(k) == 16

    def test_no_args_raises(self):
        with pytest.raises(ValueError):
            generate_cache_key()


# ===================================================================
# GPSDataset tests
# ===================================================================


class TestGPSDataset:
    def test_csv_bbox(self, sample_gps_csv):
        csv_path, raw_df = sample_gps_csv
        ds = GPSDataset(csv_path)
        north, south, east, west = ds.estimate_bbox()

        assert north == pytest.approx(raw_df["lat"].max())
        assert south == pytest.approx(raw_df["lat"].min())
        assert east == pytest.approx(raw_df["lon"].max())
        assert west == pytest.approx(raw_df["lon"].min())

    def test_csv_unique_trips(self, sample_gps_csv):
        csv_path, _ = sample_gps_csv
        ds = GPSDataset(csv_path)
        trips = ds.get_unique_trip_ids()
        assert set(trips) == {"trip_1", "trip_2"}

    def test_csv_load_trips(self, sample_gps_csv):
        csv_path, raw_df = sample_gps_csv
        ds = GPSDataset(csv_path)
        loaded = ds.load_trips(["trip_1"])

        assert set(loaded.columns) >= {"trip_id", "timestamp", "lon", "lat"}
        assert len(loaded) == len(raw_df[raw_df["trip_id"] == "trip_1"])

    def test_parquet(self, temp_dir, sample_gps_csv):
        _, raw_df = sample_gps_csv
        pq_path = temp_dir / "gps.parquet"
        raw_df.to_parquet(pq_path, index=False)

        ds = GPSDataset(pq_path)
        assert set(ds.get_unique_trip_ids()) == {"trip_1", "trip_2"}

    def test_load_empty_trips(self, sample_gps_csv):
        csv_path, _ = sample_gps_csv
        ds = GPSDataset(csv_path)
        empty = ds.load_trips([])
        assert empty.empty

    def test_empty_path_raises(self):
        with pytest.raises(ValueError, match="at least one path"):
            GPSDataset([])


# ===================================================================
# DFS pathfinding tests
# ===================================================================


class TestFindEdgePath:
    """Tests for _find_edge_path (formerly get_linestrings_for_chunk)."""

    def test_simple_chain(self, sample_osm_edges_gdf):
        """Walk along way 100: N1 -> N2 -> N3."""
        n1 = (8.5400, 47.3780)
        n3 = (8.5404, 47.3784)
        chunk = ("100", n1, n3)

        result = _find_edge_path(chunk, sample_osm_edges_gdf)

        assert result is not None
        assert len(result) == 2
        assert result[0] == (1001, 1002, 0)
        assert result[1] == (1002, 1003, 1)

    def test_single_edge(self, sample_osm_edges_gdf):
        """Walk along way 200: N2 -> N4 (single edge)."""
        n2 = (8.5402, 47.3782)
        n4 = (8.5406, 47.3780)
        chunk = ("200", n2, n4)

        result = _find_edge_path(chunk, sample_osm_edges_gdf)

        assert result is not None
        assert len(result) == 1
        assert result[0][2] == 2

    def test_no_valid_path(self, sample_osm_edges_gdf):
        """No path exists for a non-existent way ID."""
        chunk = ("999", (0.0, 0.0), (1.0, 1.0))
        result = _find_edge_path(chunk, sample_osm_edges_gdf)
        assert result is None

    def test_missing_columns_raises(self):
        gdf = gpd.GeoDataFrame({"foo": [1]}, geometry=[Point(0, 0)])
        gdf.index = pd.MultiIndex.from_tuples([("a", "b", 0)])
        with pytest.raises(KeyError, match="way_id.*osmid"):
            _find_edge_path(("100", (0, 0), (1, 1)), gdf)


# ===================================================================
# Vectorised edge filter tests
# ===================================================================


class TestFilterEdgesByWayIds:
    def test_scalar_osmid(self, sample_osm_edges_gdf):
        result = _filter_edges_by_way_ids(sample_osm_edges_gdf, {"100"})
        assert len(result) == 2

    def test_no_match(self, sample_osm_edges_gdf):
        result = _filter_edges_by_way_ids(sample_osm_edges_gdf, {"999"})
        assert result.empty

    def test_list_osmid(self):
        gdf = gpd.GeoDataFrame(
            {"osmid": [[100, 200], [300]]},
            geometry=[LineString([(0, 0), (1, 1)]), LineString([(1, 1), (2, 2)])],
            crs="EPSG:4326",
        )
        gdf.index = pd.MultiIndex.from_tuples([("a", "b", 0), ("c", "d", 0)])
        result = _filter_edges_by_way_ids(gdf, {"200"})
        assert len(result) == 1

    def test_comma_separated_osmid(self):
        gdf = gpd.GeoDataFrame(
            {"osmid": ["100, 200", "300"]},
            geometry=[LineString([(0, 0), (1, 1)]), LineString([(1, 1), (2, 2)])],
            crs="EPSG:4326",
        )
        gdf.index = pd.MultiIndex.from_tuples([("a", "b", 0), ("c", "d", 0)])
        result = _filter_edges_by_way_ids(gdf, {"200"})
        assert len(result) == 1


# ===================================================================
# ValhallaMatcher integration tests (mocked Actor)
# ===================================================================


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


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


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, sample_osm_edges_gdf):
        """Verify snap produces a valid walk sequence for a known topology."""
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        matcher._edges_gdf = sample_osm_edges_gdf

        n1 = (8.5400, 47.3780)
        n2 = (8.5402, 47.3782)
        n3 = (8.5404, 47.3784)

        def _encode_value(value: int) -> str:
            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)

        lat1, lon1 = round(n1[1] * 1e6), round(n1[0] * 1e6)
        lat2, lon2 = round(n2[1] * 1e6), round(n2[0] * 1e6)
        lat3, lon3 = round(n3[1] * 1e6), round(n3[0] * 1e6)

        encoded = (
            _encode_value(lat1) + _encode_value(lon1)
            + _encode_value(lat2 - lat1) + _encode_value(lon2 - lon1)
            + _encode_value(lat3 - lat2) + _encode_value(lon3 - lon2)
        )

        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 "walk_id" in result.columns
        assert "node_id" in result.columns
        assert "exit_edge" in result.columns

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


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"


# ===================================================================
# match_to_disk / match_trajectories tests (io/match/core.py)
# ===================================================================


class TestMatchToDisk:
    def test_happy_path(self, temp_dir, sample_gps_csv, sample_nodes, sample_edges):
        csv_path, _ = sample_gps_csv
        _, nodes_path = sample_nodes
        _, edges_path = sample_edges

        from walkingpandas import Network

        network = Network(nodes=nodes_path, edges=edges_path)
        dataset = GPSDataset(csv_path)

        backend = MagicMock()
        backend.trace.return_value = pd.DataFrame([
            {"trip_id": "trip_1", "raw_shape": "", "valhalla_edges": [], "matched_points": []},
        ])
        enriched = pd.DataFrame({
            "walk_id": ["trip_1", "trip_1"],
            "sequence_idx": [0, 1],
            "node_id": pd.array([0, 1], dtype=pd.Int64Dtype()),
            "exit_edge": pd.array([0, None], dtype=pd.Int64Dtype()),
        })
        backend.snap.return_value = enriched
        backend.enrich.return_value = enriched

        out_dir = temp_dir / "matched"
        wf = match_to_disk(dataset, backend, network, out_dir, chunk_size_trips=10)

        assert wf is not None
        backend.trace.assert_called()
        backend.snap.assert_called()

    def test_empty_match_raises(self, temp_dir, sample_gps_csv, sample_nodes, sample_edges):
        csv_path, _ = sample_gps_csv
        _, nodes_path = sample_nodes
        _, edges_path = sample_edges

        from walkingpandas import Network

        network = Network(nodes=nodes_path, edges=edges_path)
        dataset = GPSDataset(csv_path)

        backend = MagicMock()
        backend.trace.return_value = pd.DataFrame()
        backend.snap.return_value = pd.DataFrame()
        backend.enrich.return_value = pd.DataFrame()

        with pytest.raises(ValueError, match="No trajectories"):
            match_to_disk(dataset, backend, network, temp_dir / "out", chunk_size_trips=10)


class TestMatchTrajectories:
    def test_happy_path(self, temp_dir, sample_gps_csv, sample_nodes, sample_edges):
        csv_path, _ = sample_gps_csv
        _, nodes_path = sample_nodes
        _, edges_path = sample_edges

        from walkingpandas import Network

        network = Network(nodes=nodes_path, edges=edges_path)

        backend = MagicMock()
        backend.region = (47.4, 47.3, 8.6, 8.5)
        backend.get_network.return_value = network
        enriched = pd.DataFrame({
            "walk_id": ["trip_1", "trip_1"],
            "sequence_idx": [0, 1],
            "node_id": pd.array([0, 1], dtype=pd.Int64Dtype()),
            "exit_edge": pd.array([0, None], dtype=pd.Int64Dtype()),
        })
        backend.trace.return_value = pd.DataFrame([
            {"trip_id": "trip_1", "raw_shape": "", "valhalla_edges": [], "matched_points": []},
        ])
        backend.snap.return_value = enriched
        backend.enrich.return_value = enriched

        wf = match_trajectories(
            gps_data=str(csv_path),
            backend=backend,
            output_dir=str(temp_dir / "out"),
            network=network,
        )
        assert wf is not None

    def test_auto_region_and_network(self, temp_dir, sample_gps_csv, sample_nodes, sample_edges):
        csv_path, _ = sample_gps_csv
        _, nodes_path = sample_nodes
        _, edges_path = sample_edges

        from walkingpandas import Network

        network = Network(nodes=nodes_path, edges=edges_path)

        backend = MagicMock()
        backend.region = None
        backend.get_network.return_value = network
        enriched = pd.DataFrame({
            "walk_id": ["trip_1", "trip_1"],
            "sequence_idx": [0, 1],
            "node_id": pd.array([0, 1], dtype=pd.Int64Dtype()),
            "exit_edge": pd.array([0, None], dtype=pd.Int64Dtype()),
        })
        backend.trace.return_value = pd.DataFrame([
            {"trip_id": "trip_1", "raw_shape": "", "valhalla_edges": [], "matched_points": []},
        ])
        backend.snap.return_value = enriched
        backend.enrich.return_value = enriched

        wf = match_trajectories(
            gps_data=str(csv_path),
            backend=backend,
            output_dir=str(temp_dir / "out"),
        )
        backend.set_region.assert_called_once()
        backend.get_network.assert_called_once()


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

    def test_pipeline(self, sample_osm_nodes_gdf, sample_osm_edges_gdf):
        matcher = _make_matcher_no_setup()
        matcher._nodes_gdf = sample_osm_nodes_gdf
        matcher._edges_gdf = sample_osm_edges_gdf

        n1 = (8.5400, 47.3780)
        n2 = (8.5402, 47.3782)
        n3 = (8.5404, 47.3784)

        def _encode_value(value: int) -> str:
            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)

        lat1, lon1 = round(n1[1] * 1e6), round(n1[0] * 1e6)
        lat2, lon2 = round(n2[1] * 1e6), round(n2[0] * 1e6)
        lat3, lon3 = round(n3[1] * 1e6), round(n3[0] * 1e6)

        encoded = (
            _encode_value(lat1) + _encode_value(lon1)
            + _encode_value(lat2 - lat1) + _encode_value(lon2 - lon1)
            + _encode_value(lat3 - lat2) + _encode_value(lon3 - lon2)
        )

        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

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