"""Tests for OpenStreetMap utilities (osm_utils.py)."""

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

import pytest

from walkingpandas._utils import BoundingBox
from walkingpandas.io.match.osm_utils import (
    _osmium_available,
    geocode_place_to_bbox,
    osmium_extract_bbox,
    osmium_tags_filter,
    write_network_parquet,
    _resolve_direction,
    WayFilterHandler,
    NodeCoordinateHandler,
)


# ---------------------------------------------------------------------------
# Tests for _resolve_direction
# ---------------------------------------------------------------------------


class TestResolveDirection:
    def test_resolve_direction_oneway_no_returns_both(self):
        """Default branch: oneway not in forward/-1 -> (True, True)."""
        assert _resolve_direction("no", force_bidirectional=False) == (True, True)

    def test_resolve_direction_unknown_oneway_returns_both(self):
        """Unknown oneway value falls through to default (True, True)."""
        assert _resolve_direction("something_else", force_bidirectional=False) == (True, True)

    def test_resolve_direction_force_bidirectional_returns_both(self):
        assert _resolve_direction("yes", force_bidirectional=True) == (True, True)

    def test_resolve_direction_oneway_forward(self):
        assert _resolve_direction("yes", force_bidirectional=False) == (True, False)

    def test_resolve_direction_oneway_reverse(self):
        assert _resolve_direction("-1", force_bidirectional=False) == (False, True)


# ---------------------------------------------------------------------------
# Tests for WayFilterHandler
# ---------------------------------------------------------------------------


class _OsmiumImportErrorFake:
    """Fake module that raises ImportError on attribute access."""

    def __getattr__(self, name):
        raise ImportError("No module named 'osmium'")


class TestWayFilterHandler:
    def test_import_error_when_osmium_missing(self):
        with patch.dict(sys.modules, {"osmium": _OsmiumImportErrorFake()}):
            with pytest.raises(ImportError, match="osmium|pyosmium"):
                WayFilterHandler()

    def test_init_and_way_callback(self):
        """WayFilterHandler accepts a way via _way and populates ways and needed_nodes."""
        mock_osmium = MagicMock()
        mock_handler_instance = MagicMock()
        mock_osmium.SimpleHandler.return_value = mock_handler_instance

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = WayFilterHandler()

        fake_way = MagicMock()
        fake_way.tags = {"highway": "residential", "oneway": "no"}
        fake_way.nodes = [MagicMock(ref=r) for r in [100, 200, 300]]
        fake_way.id = 42

        h._way(fake_way)

        assert len(h.ways) == 1
        assert h.ways[0]["way_id"] == 42
        assert h.ways[0]["highway"] == "residential"
        assert h.ways[0]["nodes"] == [100, 200, 300]
        assert h.needed_nodes == {100, 200, 300}

    def test_way_skipped_when_no_highway(self):
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = WayFilterHandler()

        fake_way = MagicMock()
        fake_way.tags = {}
        fake_way.nodes = []
        fake_way.id = 1

        h._way(fake_way)

        assert len(h.ways) == 0

    def test_way_skipped_when_area_yes(self):
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = WayFilterHandler()

        fake_way = MagicMock()
        fake_way.tags = {"highway": "residential", "area": "yes"}
        fake_way.nodes = [MagicMock(ref=1)]
        fake_way.id = 1

        h._way(fake_way)

        assert len(h.ways) == 0

    def test_way_skipped_when_highway_not_in_filter(self):
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = WayFilterHandler(highway_filter={"primary", "secondary"})

        fake_way = MagicMock()
        fake_way.tags = {"highway": "residential", "oneway": "no"}
        fake_way.nodes = [MagicMock(ref=1)]
        fake_way.id = 1

        h._way(fake_way)

        assert len(h.ways) == 0

    def test_apply_file_calls_handler_apply_file(self):
        mock_osmium = MagicMock()
        mock_handler_instance = MagicMock()
        mock_osmium.SimpleHandler.return_value = mock_handler_instance

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = WayFilterHandler()

        h.apply_file("dummy.pbf")

        mock_handler_instance.apply_file.assert_called_once_with("dummy.pbf")


# ---------------------------------------------------------------------------
# Tests for NodeCoordinateHandler
# ---------------------------------------------------------------------------


class TestNodeCoordinateHandler:
    def test_import_error_when_osmium_missing(self):
        with patch.dict(sys.modules, {"osmium": _OsmiumImportErrorFake()}):
            with pytest.raises(ImportError, match="osmium|pyosmium"):
                NodeCoordinateHandler(needed_nodes={1})

    def test_init_and_node_callback_no_bbox(self):
        """NodeCoordinateHandler collects node coords when bbox is None."""
        mock_osmium = MagicMock()
        mock_handler_instance = MagicMock()
        mock_osmium.SimpleHandler.return_value = mock_handler_instance

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = NodeCoordinateHandler(needed_nodes={100})

        fake_node = MagicMock()
        fake_node.id = 100
        fake_node.location = MagicMock(lon=8.54, lat=47.37)
        fake_node.tags = {}

        h._node(fake_node)

        assert h.node_coords == {100: (8.54, 47.37)}

    def test_node_bbox_include(self):
        """Node inside bbox is collected."""
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = NodeCoordinateHandler(
                needed_nodes={100},
                bbox=BoundingBox.from_nsew(north=47.38, south=47.36, east=8.56, west=8.53),
            )

        fake_node = MagicMock()
        fake_node.id = 100
        fake_node.location = MagicMock(lon=8.54, lat=47.37)
        fake_node.tags = {}

        h._node(fake_node)

        assert h.node_coords == {100: (8.54, 47.37)}

    def test_node_bbox_exclude(self):
        """Node outside bbox is not collected."""
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = NodeCoordinateHandler(
                needed_nodes={100},
                bbox=BoundingBox.from_nsew(north=47.30, south=47.28, east=8.50, west=8.48),
            )

        fake_node = MagicMock()
        fake_node.id = 100
        fake_node.location = MagicMock(lon=8.54, lat=47.37)
        fake_node.tags = {}

        h._node(fake_node)

        assert h.node_coords == {}

    def test_node_attributes_populated(self):
        mock_osmium = MagicMock()
        mock_osmium.SimpleHandler.return_value = MagicMock()

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = NodeCoordinateHandler(
                needed_nodes={100},
                node_attributes=("highway",),
            )

        fake_node = MagicMock()
        fake_node.id = 100
        fake_node.location = MagicMock(lon=8.54, lat=47.37)
        fake_node.tags = {"highway": "crossing"}

        h._node(fake_node)

        assert h.node_coords == {100: (8.54, 47.37)}
        assert h.node_attrs == {100: {"highway": "crossing"}}

    def test_apply_file_calls_handler_apply_file(self):
        mock_osmium = MagicMock()
        mock_handler_instance = MagicMock()
        mock_osmium.SimpleHandler.return_value = mock_handler_instance

        with patch.dict(sys.modules, {"osmium": mock_osmium}):
            h = NodeCoordinateHandler(needed_nodes=set())

        h.apply_file("dummy.pbf")

        mock_handler_instance.apply_file.assert_called_once_with("dummy.pbf")


class TestGeocodePlaceToBbox:
    @patch("walkingpandas.io.match.osm_utils.requests.get")
    def test_success_with_bounding_box(self, mock_get):
        mock_response = MagicMock()
        mock_response.json.return_value = [
            {"boundingbox": ["47.32", "47.43", "8.45", "8.63"]}
        ]
        mock_response.raise_for_status = MagicMock()
        mock_get.return_value = mock_response

        bbox = geocode_place_to_bbox("Zurich")

        assert isinstance(bbox, BoundingBox)
        assert bbox.as_nsew() == pytest.approx((47.43, 47.32, 8.63, 8.45))
        mock_get.assert_called_once()

    @patch("walkingpandas.io.match.osm_utils.requests.get")
    def test_no_bounding_box_uses_fallback(self, mock_get):
        mock_response = MagicMock()
        mock_response.json.return_value = [
            {"lat": "47.38", "lon": "8.54", "boundingbox": None}
        ]
        mock_response.raise_for_status = MagicMock()
        mock_get.return_value = mock_response

        bbox = geocode_place_to_bbox("Small Place")

        assert isinstance(bbox, BoundingBox)
        assert bbox.north == pytest.approx(47.39)
        assert bbox.south == pytest.approx(47.37)

    @patch("walkingpandas.io.match.osm_utils.requests.get")
    def test_place_not_found_raises(self, mock_get):
        mock_response = MagicMock()
        mock_response.json.return_value = []
        mock_response.raise_for_status = MagicMock()
        mock_get.return_value = mock_response

        with pytest.raises(ValueError, match="Place not found"):
            geocode_place_to_bbox("Nonexistent Place XYZ")


# ---------------------------------------------------------------------------
# Test data for write_network_parquet
# ---------------------------------------------------------------------------

_THREE_NODE_COORDS = {
    100: (8.54, 47.37),
    200: (8.55, 47.38),
    300: (8.56, 47.39),
}

_TWO_SEGMENT_WAY = {
    "way_id": 1,
    "highway": "residential",
    "oneway": "no",
    "name": "Main St",
    "nodes": [100, 200, 300],
}


# ---------------------------------------------------------------------------
# Tests for write_network_parquet
# ---------------------------------------------------------------------------


class TestWriteNetworkParquet:
    def test_basic_roundtrip(self, tmp_path):
        """Writes nodes and edges Parquet and verifies row counts and columns."""
        import pyarrow.parquet as pq
        import shapely

        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        total_nodes, total_edges = write_network_parquet(
            [_TWO_SEGMENT_WAY],
            _THREE_NODE_COORDS,
            n_path,
            e_path,
            force_bidirectional=True,
        )

        assert total_nodes == 3
        assert total_edges == 4

        nodes_tbl = pq.read_table(n_path)
        assert nodes_tbl.num_rows == 3
        assert set(nodes_tbl.column_names) == {"node_id", "geometry"}
        for wkb_bytes in nodes_tbl.column("geometry").to_pylist():
            geom = shapely.from_wkb(wkb_bytes)
            assert geom.geom_type == "Point"

        edges_tbl = pq.read_table(e_path)
        assert edges_tbl.num_rows == 4
        expected_cols = {
            "edge_id", "source_node", "target_node",
            "geometry", "way_id", "oneway",
        }
        assert set(edges_tbl.column_names) == expected_cols
        for wkb_bytes in edges_tbl.column("geometry").to_pylist():
            geom = shapely.from_wkb(wkb_bytes)
            assert geom.geom_type == "LineString"

    def test_oneway_forward(self, tmp_path):
        way = {**_TWO_SEGMENT_WAY, "oneway": "yes"}
        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        _, total_edges = write_network_parquet(
            [way], _THREE_NODE_COORDS, n_path, e_path,
            force_bidirectional=False,
        )
        assert total_edges == 2

    def test_oneway_reverse(self, tmp_path):
        way = {**_TWO_SEGMENT_WAY, "oneway": "-1"}
        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        _, total_edges = write_network_parquet(
            [way], _THREE_NODE_COORDS, n_path, e_path,
            force_bidirectional=False,
        )
        assert total_edges == 2

    def test_missing_coord_skipped(self, tmp_path):
        partial = {100: (8.54, 47.37), 300: (8.56, 47.39)}
        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        total_nodes, total_edges = write_network_parquet(
            [_TWO_SEGMENT_WAY], partial, n_path, e_path,
        )
        assert total_nodes == 2
        assert total_edges == 0

    def test_empty_input(self, tmp_path):
        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        total_nodes, total_edges = write_network_parquet(
            [], {}, n_path, e_path,
        )
        assert total_nodes == 0
        assert total_edges == 0

    def test_chunking_small_chunk_size(self, tmp_path):
        """Verify that chunk_size < total rows still produces correct output."""
        import pyarrow.parquet as pq

        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        total_nodes, total_edges = write_network_parquet(
            [_TWO_SEGMENT_WAY],
            _THREE_NODE_COORDS,
            n_path,
            e_path,
            force_bidirectional=True,
            chunk_size=1,
        )
        assert total_nodes == 3
        assert total_edges == 4

        assert pq.read_table(n_path).num_rows == 3
        assert pq.read_table(e_path).num_rows == 4


# ---------------------------------------------------------------------------
# Tests for osmium CLI helpers
# ---------------------------------------------------------------------------


class TestOsmiumAvailable:
    def test_returns_true_when_found(self):
        with patch("walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"):
            assert _osmium_available() is True

    def test_returns_false_when_missing(self):
        with patch("walkingpandas.io.match.osm_utils.shutil.which", return_value=None):
            assert _osmium_available() is False


class TestOsmiumTagsFilter:
    def test_returns_false_when_not_installed(self):
        with patch("walkingpandas.io.match.osm_utils.shutil.which", return_value=None):
            assert osmium_tags_filter("in.pbf", "out.pbf", "w/highway") is False

    def test_returns_true_on_success(self):
        with patch(
            "walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"
        ), patch("walkingpandas.io.match.osm_utils.subprocess.run") as mock_run:
            assert osmium_tags_filter("in.pbf", "out.pbf", "w/highway") is True
            args = mock_run.call_args[0][0]
            assert args[:2] == ["osmium", "tags-filter"]
            assert "w/highway" in args
            assert "-o" in args

    def test_multiple_expressions(self):
        with patch(
            "walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"
        ), patch("walkingpandas.io.match.osm_utils.subprocess.run") as mock_run:
            osmium_tags_filter("in.pbf", "out.pbf", "w/highway", "w/railway")
            args = mock_run.call_args[0][0]
            assert "w/highway" in args
            assert "w/railway" in args

    def test_returns_false_on_error(self):
        import subprocess
        with patch(
            "walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"
        ), patch(
            "walkingpandas.io.match.osm_utils.subprocess.run",
            side_effect=subprocess.CalledProcessError(1, "osmium"),
        ):
            assert osmium_tags_filter("in.pbf", "out.pbf", "w/highway") is False


class TestOsmiumExtractBbox:
    def test_returns_false_when_not_installed(self):
        bbox = BoundingBox.from_nsew(47.4, 47.3, 8.6, 8.5)
        with patch("walkingpandas.io.match.osm_utils.shutil.which", return_value=None):
            assert osmium_extract_bbox("in.pbf", "out.pbf", bbox) is False

    def test_returns_true_on_success(self):
        bbox = BoundingBox.from_nsew(47.4, 47.3, 8.6, 8.5)
        with patch(
            "walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"
        ), patch("walkingpandas.io.match.osm_utils.subprocess.run") as mock_run:
            assert osmium_extract_bbox("in.pbf", "out.pbf", bbox) is True
            args = mock_run.call_args[0][0]
            assert args[:2] == ["osmium", "extract"]
            assert "-b" in args
            assert bbox.as_osmium() in args

    def test_returns_false_on_error(self):
        import subprocess
        bbox = BoundingBox.from_nsew(47.4, 47.3, 8.6, 8.5)
        with patch(
            "walkingpandas.io.match.osm_utils.shutil.which", return_value="/usr/bin/osmium"
        ), patch(
            "walkingpandas.io.match.osm_utils.subprocess.run",
            side_effect=subprocess.CalledProcessError(1, "osmium"),
        ):
            assert osmium_extract_bbox("in.pbf", "out.pbf", bbox) is False


# ---------------------------------------------------------------------------
# Tests for custom edge/node attributes in write_network_parquet
# ---------------------------------------------------------------------------


class TestWriteNetworkParquetCustomAttributes:
    def test_custom_edge_attributes_schema(self, tmp_path):
        """Parquet schema reflects the requested edge_attributes."""
        import pyarrow.parquet as pq

        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        write_network_parquet(
            [_TWO_SEGMENT_WAY],
            _THREE_NODE_COORDS,
            n_path,
            e_path,
            edge_attributes=("way_id", "oneway", "name"),
        )

        edges_tbl = pq.read_table(e_path)
        assert "way_id" in edges_tbl.column_names
        assert "oneway" in edges_tbl.column_names
        assert "name" in edges_tbl.column_names
        assert "highway" not in edges_tbl.column_names
        assert all(v == "Main St" for v in edges_tbl.column("name").to_pylist())

    def test_node_attrs_columns_written(self, tmp_path):
        """When node_attrs is provided, each tag becomes a separate column."""
        import pyarrow.parquet as pq

        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        node_attrs = {
            100: {"highway": "crossing"},
            200: {"highway": ""},
            300: {"highway": "traffic_signals"},
        }

        write_network_parquet(
            [_TWO_SEGMENT_WAY],
            _THREE_NODE_COORDS,
            n_path,
            e_path,
            node_attrs=node_attrs,
        )

        nodes_tbl = pq.read_table(n_path)
        assert "highway" in nodes_tbl.column_names
        assert "attributes" not in nodes_tbl.column_names

    def test_no_node_attrs_means_no_extra_columns(self, tmp_path):
        """Without node_attrs, the nodes Parquet only has core columns."""
        import pyarrow.parquet as pq

        n_path = str(tmp_path / "nodes.parquet")
        e_path = str(tmp_path / "edges.parquet")

        write_network_parquet(
            [_TWO_SEGMENT_WAY],
            _THREE_NODE_COORDS,
            n_path,
            e_path,
        )

        nodes_tbl = pq.read_table(n_path)
        assert set(nodes_tbl.column_names) == {"node_id", "geometry"}
