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

General-purpose tests applicable to any BaseMatcher backend.  All tests
use ``MagicMock`` backends or shared infrastructure and do **not** import
anything from ``walkingpandas.io.match.backends.valhalla``.
"""

from unittest.mock import MagicMock

import pandas as pd
import pytest

from walkingpandas.io.match.base import BaseMatcher
from walkingpandas._utils import generate_cache_key
from walkingpandas.io.match.gps import GPSDataset
from walkingpandas.io.match.core import match_trajectories


# ===================================================================
# Concrete matcher for testing BaseMatcher.match()
# ===================================================================


class ConcreteMatcher(BaseMatcher):
    """Minimal concrete BaseMatcher for testing match() without valhalla."""

    def __init__(self, network=None, match_one=None):
        self._network = network if network is not None else MagicMock()
        self._match_one = match_one if match_one is not None else lambda traj, wid: pd.DataFrame()

    @property
    def network(self):
        return self._network

    def match_trajectory(self, trajectory, walk_id=None):
        return self._match_one(trajectory, walk_id)


# ===================================================================
# Shared infrastructure tests
# ===================================================================


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)
        bbox = ds.estimate_bbox()

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

    def test_estimate_bbox_empty_returns_zero_bbox(self, temp_dir):
        """Empty dataset causes estimate_bbox to return zero bbox."""
        empty_df = pd.DataFrame(columns=["walk_id", "timestamp", "lon", "lat"])
        pq_path = temp_dir / "empty_gps.parquet"
        empty_df.to_parquet(pq_path, index=False)
        ds = GPSDataset(pq_path)
        bbox = ds.estimate_bbox()
        assert bbox.north == 0.0
        assert bbox.south == 0.0
        assert bbox.east == 0.0
        assert bbox.west == 0.0

    def test_estimate_bbox_with_buffer_expands_bbox(self, sample_gps_csv):
        """estimate_bbox(buffer=...) expands the bbox by the given metres."""
        csv_path, _ = sample_gps_csv
        ds = GPSDataset(csv_path)
        bbox_no_buffer = ds.estimate_bbox()
        bbox_buffered = ds.estimate_bbox(buffer=500)
        assert bbox_buffered.north > bbox_no_buffer.north
        assert bbox_buffered.south < bbox_no_buffer.south
        assert bbox_buffered.east > bbox_no_buffer.east
        assert bbox_buffered.west < bbox_no_buffer.west

    def test_load_walks_no_args_loads_all(self, sample_gps_csv):
        """load_walks() with no argument loads all walks."""
        csv_path, raw_df = sample_gps_csv
        ds = GPSDataset(csv_path)
        loaded = ds.load_walks()
        assert len(loaded) == len(raw_df)
        assert set(loaded.columns) >= {"walk_id", "timestamp", "lon", "lat"}
        assert set(loaded["walk_id"]) == {"walk_1", "walk_2"}

    def test_csv_unique_walks(self, sample_gps_csv):
        csv_path, _ = sample_gps_csv
        ds = GPSDataset(csv_path)
        walks = ds.get_unique_walk_ids()
        assert set(walks) == {"walk_1", "walk_2"}

    def test_csv_load_walks(self, sample_gps_csv):
        csv_path, raw_df = sample_gps_csv
        ds = GPSDataset(csv_path)
        loaded = ds.load_walks(["walk_1"])

        assert set(loaded.columns) >= {"walk_id", "timestamp", "lon", "lat"}
        assert len(loaded) == len(raw_df[raw_df["walk_id"] == "walk_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_walk_ids()) == {"walk_1", "walk_2"}

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

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


# ===================================================================
# BaseMatcher.match() tests (exercises real match() implementation)
# ===================================================================


class TestBaseMatcherMatch:
    """Tests that run the real BaseMatcher.match() via ConcreteMatcher."""

    def test_match_empty_dataframe_returns_empty(self):
        network = MagicMock()
        matcher = ConcreteMatcher(network=network)
        result = matcher.match(pd.DataFrame())
        assert result.empty

    def test_match_single_group_success(self):
        network = MagicMock()
        one_row = pd.DataFrame({
            "walk_id": ["w1", "w1"],
            "sequence_idx": [0, 1],
            "node_id": [1, 2],
            "exit_edge": [0, None],
        })
        matcher = ConcreteMatcher(
            network=network,
            match_one=lambda traj, wid: one_row.copy() if not traj.empty else pd.DataFrame(),
        )
        trajectories = pd.DataFrame({
            "walk_id": ["g1", "g1"],
            "lat": [47.0, 47.1],
            "lon": [8.0, 8.1],
        })
        result = matcher.match(trajectories)
        assert len(result) == 2
        assert result["walk_id"].iloc[0] == "w1"

    def test_match_multiple_groups_concatenates(self):
        network = MagicMock()

        def match_one(traj, wid):
            n = len(traj)
            return pd.DataFrame({
                "walk_id": [wid] * n,
                "sequence_idx": range(n),
                "node_id": list(range(n)),
                "exit_edge": [None] * n,
            })

        matcher = ConcreteMatcher(network=network, match_one=match_one)
        trajectories = pd.DataFrame({
            "walk_id": ["a", "a", "b", "b"],
            "lat": [47.0, 47.1, 47.2, 47.3],
            "lon": [8.0, 8.1, 8.2, 8.3],
        })
        result = matcher.match(trajectories)
        assert len(result) == 4
        assert list(result["walk_id"]) == ["a", "a", "b", "b"]

    def test_match_one_group_fails_logs_and_continues(self, caplog):
        network = MagicMock()
        good_row = pd.DataFrame({
            "walk_id": ["w2"],
            "sequence_idx": [0],
            "node_id": [1],
            "exit_edge": [None],
        })

        def match_one(traj, wid):
            if wid == "fail":
                raise RuntimeError("match failed")
            return good_row.copy()

        matcher = ConcreteMatcher(network=network, match_one=match_one)
        trajectories = pd.DataFrame({
            "walk_id": ["fail", "fail", "ok", "ok"],
            "lat": [47.0, 47.1, 47.2, 47.3],
            "lon": [8.0, 8.1, 8.2, 8.3],
        })
        with caplog.at_level("WARNING"):
            result = matcher.match(trajectories)
        assert len(result) == 1
        assert result["walk_id"].iloc[0] == "w2"
        assert "Failed to match walk" in caplog.text and "fail" in caplog.text

    def test_match_all_groups_fail_returns_empty(self, caplog):
        network = MagicMock()

        def match_one_raise(traj, wid):
            raise RuntimeError("boom")

        matcher = ConcreteMatcher(network=network, match_one=match_one_raise)
        trajectories = pd.DataFrame({
            "walk_id": ["x", "x"],
            "lat": [47.0, 47.1],
            "lon": [8.0, 8.1],
        })
        with caplog.at_level("WARNING"):
            result = matcher.match(trajectories)
        assert result.empty

    def test_match_all_groups_return_empty_returns_empty(self):
        network = MagicMock()
        matcher = ConcreteMatcher(
            network=network,
            match_one=lambda traj, wid: pd.DataFrame(),
        )
        trajectories = pd.DataFrame({
            "walk_id": ["x", "x"],
            "lat": [47.0, 47.1],
            "lon": [8.0, 8.1],
        })
        result = matcher.match(trajectories)
        assert result.empty

    def test_match_custom_walk_id_col(self):
        network = MagicMock()
        matcher = ConcreteMatcher(
            network=network,
            match_one=lambda traj, wid: pd.DataFrame({
                "walk_id": [wid],
                "sequence_idx": [0],
                "node_id": [1],
                "exit_edge": [None],
            }),
        )
        trajectories = pd.DataFrame({
            "custom_id": ["my_walk", "my_walk"],
            "lat": [47.0, 47.1],
            "lon": [8.0, 8.1],
        })
        result = matcher.match(trajectories, walk_id_col="custom_id")
        assert len(result) == 1
        assert result["walk_id"].iloc[0] == "my_walk"

    def test_network_property(self):
        network = MagicMock()
        matcher = ConcreteMatcher(network=network)
        assert matcher.network is network


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


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)
        dataset = GPSDataset(csv_path)

        backend = MagicMock()
        matched = pd.DataFrame({
            "walk_id": ["walk_1", "walk_1"],
            "sequence_idx": [0, 1],
            "node_id": pd.array([0, 1], dtype=pd.Int64Dtype()),
            "exit_edge": pd.array([0, None], dtype=pd.Int64Dtype()),
        })
        backend.match.return_value = matched

        out_dir = temp_dir / "matched"
        wf = match_trajectories(dataset, backend, network, out_dir, chunk_size_walks=10)

        assert wf is not None
        backend.match.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.match.return_value = pd.DataFrame()

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