"""Tests for Network class."""

import pytest
import duckdb
import pandas as pd
import geopandas as gpd
from pathlib import Path
from unittest.mock import patch
from walkingpandas import Network


def test_network_initialization(sample_network, sample_nodes, sample_edges):
    """Test Network initialization from Parquet files."""
    assert sample_network is not None
    assert sample_network.connection is not None


def test_network_from_gdfs(sample_nodes, sample_edges):
    """Test Network creation from GeoDataFrames."""
    nodes_gdf, _ = sample_nodes
    edges_gdf, _ = sample_edges

    network = Network.from_gdfs(nodes_gdf, edges_gdf)
    assert network is not None


def test_network_get_nodes(sample_network):
    """Test getting nodes from Network."""
    nodes = sample_network.get_nodes()
    assert len(nodes) > 0
    assert 'node_id' in nodes.columns
    assert isinstance(nodes, gpd.GeoDataFrame)
    assert 'geometry' in nodes.columns or len(nodes) == 0


def test_network_get_nodes_as_dataframe(sample_network):
    """Test getting nodes as DataFrame (not GeoDataFrame)."""
    nodes = sample_network.get_nodes(as_geopandas=False)
    assert len(nodes) > 0
    assert 'node_id' in nodes.columns
    assert not isinstance(nodes, gpd.GeoDataFrame)


def test_network_get_edges(sample_network):
    """Test getting edges from Network."""
    edges = sample_network.get_edges()
    assert len(edges) > 0
    assert 'edge_id' in edges.columns
    assert isinstance(edges, gpd.GeoDataFrame)
    assert 'geometry' in edges.columns or len(edges) == 0


def test_network_get_edges_as_dataframe(sample_network):
    """Test getting edges as DataFrame (not GeoDataFrame)."""
    edges = sample_network.get_edges(as_geopandas=False)
    assert len(edges) > 0
    assert 'edge_id' in edges.columns
    assert not isinstance(edges, gpd.GeoDataFrame)


def test_network_save_and_load(sample_network, temp_dir):
    """Test saving and loading Network to/from DuckDB file."""
    db_path = temp_dir / 'test_network.duckdb'

    sample_network.save(db_path)
    assert db_path.exists()

    network_loaded = Network.load(db_path)
    assert network_loaded is not None

    nodes_original = sample_network.get_nodes()
    nodes_loaded = network_loaded.get_nodes()

    assert len(nodes_original) == len(nodes_loaded)
    assert set(nodes_original['node_id']) == set(nodes_loaded['node_id'])

    edges_original = sample_network.get_edges()
    edges_loaded = network_loaded.get_edges()

    assert len(edges_original) == len(edges_loaded)
    assert set(edges_original['edge_id']) == set(edges_loaded['edge_id'])


def test_network_with_persistent_database(sample_nodes, sample_edges, temp_dir):
    """Test Network initialization with persistent database."""
    nodes_gdf, _ = sample_nodes
    edges_gdf, _ = sample_edges

    db_path = temp_dir / 'persistent_network.duckdb'

    network = Network(nodes=nodes_gdf, edges=edges_gdf, database=db_path)
    assert db_path.exists()

    nodes = network.get_nodes()
    assert len(nodes) > 0


def test_network_load_nonexistent_file(temp_dir):
    """Test loading non-existent database file raises error."""
    db_path = temp_dir / 'nonexistent.duckdb'

    with pytest.raises(FileNotFoundError):
        Network.load(db_path)


def test_network_set_nodes_from_dataframe_with_xy(temp_dir):
    """Test set_nodes with DataFrame that has x/y coordinates (integer IDs)."""
    nodes_df = pd.DataFrame([
        {'node_id': 10, 'x': 0, 'y': 0},
        {'node_id': 20, 'x': 1, 'y': 1},
    ])
    network = Network()
    network.set_nodes(nodes_df)
    nodes = network.get_nodes()
    assert len(nodes) == 2
    assert set(nodes['node_id']) == {10, 20}


def test_network_set_nodes_missing_node_id_raises():
    """Test set_nodes with missing node_id raises ValueError."""
    nodes_df = pd.DataFrame([{'x': 0, 'y': 0}])
    network = Network()
    with pytest.raises(ValueError, match="node_id"):
        network.set_nodes(nodes_df)


def test_network_set_nodes_unsupported_type_raises(sample_network):
    """Test set_nodes with unsupported type raises TypeError."""
    with pytest.raises(TypeError, match="Unsupported"):
        sample_network.set_nodes([1, 2, 3])


def test_network_set_edges_unsupported_type_raises(sample_network):
    """Test set_edges with unsupported type raises TypeError."""
    with pytest.raises(TypeError, match="Unsupported"):
        sample_network.set_edges([1, 2, 3])


def test_network_from_networkx(sample_nodes, sample_edges):
    """Test Network creation from NetworkX graph."""
    pytest.importorskip("networkx")
    import networkx as nx

    G = nx.DiGraph()
    G.add_node('A', x=0, y=0)
    G.add_node('B', x=1, y=0)
    G.add_node('C', x=1, y=1)
    G.add_edge('A', 'B', edge_id='AB')
    G.add_edge('B', 'C', edge_id='BC')

    network = Network.from_networkx(G)
    assert network is not None
    nodes = network.get_nodes(as_geopandas=False)
    edges = network.get_edges(as_geopandas=False)
    assert len(nodes) == 3
    assert len(edges) == 2


def test_network_from_networkx_with_geometry(sample_nodes):
    """Test from_networkx when nodes have geometry attribute."""
    pytest.importorskip("networkx")
    import networkx as nx
    from shapely.geometry import Point

    G = nx.DiGraph()
    G.add_node('A', geometry=Point(0, 0))
    G.add_node('B', geometry=Point(1, 0))
    G.add_edge('A', 'B')

    network = Network.from_networkx(G)
    assert network is not None
    nodes = network.get_nodes()
    assert len(nodes) == 2


def test_network_get_nodes_no_geometry(temp_dir):
    """Test get_nodes when geometry is all NULL (empty geom_wkt)."""
    network = Network()
    network._conn.execute("INSERT INTO wp_nodes (node_id, geom) VALUES (100, NULL)")
    nodes = network.get_nodes()
    assert len(nodes) == 1
    assert nodes['node_id'].iloc[0] == 100


def test_network_set_nodes_node_id_only():
    """Test set_nodes with only node_id (no geometry) for topology-only networks."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [10, 20, 30]}))
    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 3
    assert set(nodes["node_id"]) == {10, 20, 30}


def test_network_set_nodes_string_auto_maps():
    """Test set_nodes with string IDs triggers auto-mapping."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": ["A", "B", "C"]}))
    assert network.has_string_mapping
    nodes = network.get_nodes(as_geopandas=False, decode=True)
    assert set(nodes["node_id"]) == {"A", "B", "C"}
    nodes_raw = network.get_nodes(as_geopandas=False, decode=False)
    assert nodes_raw["node_id"].dtype in ('int64', pd.Int64Dtype())


def test_network_from_walks_parquet(sample_walks_simple_paths):
    """Test Network.from_walks with Parquet path infers nodes and edges."""
    _, walks_path = sample_walks_simple_paths
    network = Network.from_walks(walks_path)
    nodes = network.get_nodes(as_geopandas=False)
    edges = network.get_edges(as_geopandas=False)
    assert len(nodes) == 4  # 0, 1, 2, 4
    assert set(nodes["node_id"]) == {0, 1, 2, 4}
    assert len(edges) >= 2
    assert "edge_id" in edges.columns and "source_node" in edges.columns


def test_network_from_walks_dataframe(sample_walks_simple_paths):
    """Test Network.from_walks with DataFrame infers nodes and edges."""
    walks_df, _ = sample_walks_simple_paths
    network = Network.from_walks(walks_df)
    nodes = network.get_nodes(as_geopandas=False)
    edges = network.get_edges(as_geopandas=False)
    assert len(nodes) == 4
    assert set(nodes["node_id"]) == {0, 1, 2, 4}
    assert len(edges) >= 2


def test_network_from_walks_infer_edges_false(sample_walks_simple_paths):
    """Test Network.from_walks with infer_edges=False only infers nodes."""
    _, walks_path = sample_walks_simple_paths
    network = Network.from_walks(walks_path, infer_edges=False)
    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 4
    edges = network.get_edges(as_geopandas=False)
    assert len(edges) == 0


def test_network_from_osmnx_value_error_when_multiple_sources():
    """from_osmnx raises ValueError when more than one source is provided."""
    pytest.importorskip("osmnx")
    with pytest.raises(ValueError, match="exactly one of"):
        Network.from_osmnx(bbox=(1, 2, 3, 4), place="Berlin")
    with pytest.raises(ValueError, match="exactly one of"):
        Network.from_osmnx(place="Berlin", center_point=(52.5, 13.4), dist=1000)
    with pytest.raises(ValueError, match="exactly one of"):
        Network.from_osmnx()


def test_network_from_osmnx_returns_network():
    """from_osmnx(bbox=...) returns a Network with integer node/edge IDs."""
    pytest.importorskip("osmnx")
    pytest.importorskip("networkx")
    import networkx as nx
    from shapely.geometry import LineString

    G = nx.MultiDiGraph()
    G.add_node(1, x=0.0, y=0.0)
    G.add_node(2, x=1.0, y=0.0)
    G.add_edge(1, 2, 0, geometry=LineString([(0.0, 0.0), (1.0, 0.0)]))

    with patch("osmnx.graph_from_bbox", return_value=G):
        network = Network.from_osmnx(bbox=(0, 0, 0, 0), network_type="walk")
    assert network is not None
    nodes = network.get_nodes(as_geopandas=False)
    edges = network.get_edges(as_geopandas=False)
    assert len(nodes) == 2
    assert len(edges) >= 1
    assert "node_id" in nodes.columns
    assert "edge_id" in edges.columns
    assert "source_node" in edges.columns
    assert "target_node" in edges.columns


def test_network_from_osmnx_raises_without_osmnx():
    """When osmnx is not installed, from_osmnx raises ImportError."""
    try:
        import osmnx  # noqa: F401
    except ImportError:
        with pytest.raises(ImportError, match="osmnx is required"):
            Network.from_osmnx(bbox=(37.78, 37.77, -122.41, -122.43))
    else:
        pytest.skip("osmnx is installed; cannot test ImportError path")


def test_network_auto_generate_edge_id():
    """Test that edges without edge_id get auto-generated dense IDs."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1, 2]}))
    edges_df = pd.DataFrame({
        "source_node": [0, 1],
        "target_node": [1, 2],
    })
    network.set_edges(edges_df)
    edges = network.get_edges(as_geopandas=False)
    assert len(edges) == 2
    assert set(edges["edge_id"]) == {0, 1}


def test_network_decode_edge_ids():
    """Test _decode_edge_ids round-trips string-mapped edge and node IDs."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": ["A", "B", "C"]}))
    network.set_edges(pd.DataFrame({
        "edge_id": ["e1", "e2"],
        "source_node": ["A", "B"],
        "target_node": ["B", "C"],
    }))
    assert network.has_string_mapping

    edges = network.get_edges(as_geopandas=False, decode=True)
    assert set(edges["edge_id"]) == {"e1", "e2"}
    assert set(edges["source_node"]) == {"A", "B"}
    assert set(edges["target_node"]) == {"B", "C"}

    edges_raw = network.get_edges(as_geopandas=False, decode=False)
    assert edges_raw["edge_id"].dtype in ("int64", pd.Int64Dtype())


def test_network_get_nodes_no_geometry_fallback():
    """Test get_nodes returns a DataFrame fallback when geometry conversion fails."""
    network = Network()
    network._conn.execute(
        "INSERT INTO wp_nodes (node_id, geom) VALUES (1, NULL)"
    )
    result = network.get_nodes(as_geopandas=True)
    assert len(result) == 1
    assert 1 in result["node_id"].values


def test_network_get_edges_no_geometry_fallback():
    """Test get_edges falls back to plain DataFrame when no geometry."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0],
        "source_node": [0],
        "target_node": [1],
    }))
    result = network.get_edges(as_geopandas=False)
    assert len(result) == 1
    assert 0 in result["edge_id"].values


def test_network_set_edges_string_node_mapping():
    """Test set_edges with string node IDs that go through the mapping."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": ["X", "Y", "Z"]}))
    assert network.has_string_mapping
    network.set_edges(pd.DataFrame({
        "source_node": ["X", "Y"],
        "target_node": ["Y", "Z"],
    }))
    edges = network.get_edges(as_geopandas=False, decode=True)
    assert len(edges) == 2
    assert set(edges["source_node"]) == {"X", "Y"}


def test_network_from_walks_auto_edge_id():
    """Test from_walks auto-generates edge_id when exit_edge column is absent."""
    walks_df = pd.DataFrame({
        "walk_id": ["w1", "w1", "w1"],
        "sequence_idx": [0, 1, 2],
        "node_id": pd.array([10, 20, 30], dtype=pd.Int64Dtype()),
    })
    network = Network.from_walks(walks_df)
    edges = network.get_edges(as_geopandas=False)
    assert len(edges) == 2
    assert "edge_id" in edges.columns
    assert set(edges["edge_id"]) == {0, 1}


def test_network_string_mapping_save_load(temp_dir):
    """Test that string mappings survive save/load."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": ["Station_A", "Station_B"]}))
    edges_df = pd.DataFrame({
        "edge_id": ["link_1"],
        "source_node": ["Station_A"],
        "target_node": ["Station_B"],
    })
    network.set_edges(edges_df)
    assert network.has_string_mapping

    db_path = temp_dir / "mapped.duckdb"
    network.save(db_path)

    loaded = Network.load(db_path)
    assert loaded.has_string_mapping
    nodes = loaded.get_nodes(as_geopandas=False, decode=True)
    assert set(nodes["node_id"]) == {"Station_A", "Station_B"}


# -------------------------------------------------------------------------
# Coverage: Tier 1 — Easy wins
# -------------------------------------------------------------------------


def test_set_nodes_with_geom_column_name():
    """set_nodes recognises a geometry column named 'geom'."""
    from shapely.geometry import Point
    gdf = gpd.GeoDataFrame(
        {"node_id": [0, 1]},
        geometry=[Point(0, 0), Point(1, 1)],
    )
    gdf = gdf.rename_geometry("geom")
    network = Network()
    network.set_nodes(gdf)
    nodes = network.get_nodes()
    assert len(nodes) == 2
    assert "geometry" in nodes.columns


def test_set_nodes_geometry_no_extra_attributes():
    """set_nodes with only node_id + geometry (no extra attribute columns)."""
    from shapely.geometry import Point
    gdf = gpd.GeoDataFrame(
        {"node_id": [10, 20]},
        geometry=[Point(0, 0), Point(1, 1)],
    )
    network = Network()
    network.set_nodes(gdf)
    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 2
    assert set(nodes["node_id"]) == {10, 20}


def test_set_nodes_string_ids_with_geometry():
    """set_nodes with string IDs AND Point geometry covers pending_node_map in geometry path."""
    from shapely.geometry import Point
    gdf = gpd.GeoDataFrame(
        {"node_id": ["alpha", "beta"]},
        geometry=[Point(0, 0), Point(1, 1)],
    )
    network = Network()
    network.set_nodes(gdf)
    assert network.has_string_mapping
    nodes = network.get_nodes(decode=True)
    assert set(nodes["node_id"]) == {"alpha", "beta"}
    assert "geometry" in nodes.columns


def test_set_edges_missing_source_node_raises():
    """set_edges without source_node column raises ValueError."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    with pytest.raises(ValueError, match="source_node"):
        network.set_edges(pd.DataFrame({"target_node": [1]}))


def test_set_edges_missing_target_node_raises():
    """set_edges without target_node column raises ValueError."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    with pytest.raises(ValueError, match="target_node"):
        network.set_edges(pd.DataFrame({"source_node": [0]}))


def test_set_edges_with_geom_column_name():
    """set_edges recognises a geometry column named 'geom'."""
    from shapely.geometry import Point, LineString
    nodes_gdf = gpd.GeoDataFrame(
        {"node_id": [0, 1]},
        geometry=[Point(0, 0), Point(1, 1)],
    )
    edges_gdf = gpd.GeoDataFrame(
        {"edge_id": [0], "source_node": [0], "target_node": [1]},
        geometry=[LineString([(0, 0), (1, 1)])],
    )
    edges_gdf = edges_gdf.rename_geometry("geom")
    network = Network()
    network.set_nodes(nodes_gdf)
    network.set_edges(edges_gdf)
    edges = network.get_edges()
    assert len(edges) == 1
    assert "geometry" in edges.columns


def test_from_networkx_node_without_coordinates():
    """from_networkx falls back to Point(0, 0) for nodes without x/y or geometry."""
    pytest.importorskip("networkx")
    import networkx as nx

    G = nx.DiGraph()
    G.add_node("lonely")
    G.add_node("also_lonely", custom_attr=42)
    G.add_edge("lonely", "also_lonely")

    network = Network.from_networkx(G)
    nodes = network.get_nodes()
    assert len(nodes) == 2


def test_save_overwrite_existing_file(temp_dir):
    """save() overwrites an existing .duckdb file."""
    from shapely.geometry import Point
    nodes = gpd.GeoDataFrame(
        {"node_id": [0, 1]}, geometry=[Point(0, 0), Point(1, 0)]
    )
    db_path = temp_dir / "overwrite.duckdb"

    net1 = Network()
    net1.set_nodes(nodes)
    net1.save(db_path)
    assert db_path.exists()

    net2 = Network()
    net2.set_nodes(nodes)
    net2.save(db_path)
    assert db_path.exists()

    loaded = Network.load(db_path)
    assert len(loaded.get_nodes()) == 2


# -------------------------------------------------------------------------
# Coverage: Tier 2 — Mock-based tests
# -------------------------------------------------------------------------


def test_from_osmnx_place():
    """from_osmnx(place=...) delegates to graph_from_place."""
    pytest.importorskip("osmnx")
    pytest.importorskip("networkx")
    import networkx as nx
    from shapely.geometry import LineString

    G = nx.MultiDiGraph()
    G.add_node(1, x=0.0, y=0.0)
    G.add_node(2, x=1.0, y=0.0)
    G.add_edge(1, 2, 0, geometry=LineString([(0, 0), (1, 0)]))

    with patch("osmnx.graph_from_place", return_value=G) as mock_fn:
        network = Network.from_osmnx(place="Testville")
    mock_fn.assert_called_once()
    assert len(network.get_nodes()) == 2


def test_from_osmnx_center_point():
    """from_osmnx(center_point=..., dist=...) delegates to graph_from_point."""
    pytest.importorskip("osmnx")
    pytest.importorskip("networkx")
    import networkx as nx
    from shapely.geometry import LineString

    G = nx.MultiDiGraph()
    G.add_node(1, x=0.0, y=0.0)
    G.add_node(2, x=1.0, y=0.0)
    G.add_edge(1, 2, 0, geometry=LineString([(0, 0), (1, 0)]))

    with patch("osmnx.graph_from_point", return_value=G) as mock_fn:
        network = Network.from_osmnx(center_point=(47.0, 8.0), dist=500)
    mock_fn.assert_called_once()
    assert len(network.get_nodes()) == 2


def test_from_walks_parquet_without_exit_edge(temp_dir):
    """from_walks falls back to edges_sql_no_exit when Parquet lacks exit_edge."""
    walks_df = pd.DataFrame({
        "walk_id": ["w1", "w1", "w1"],
        "sequence_idx": [0, 1, 2],
        "node_id": pd.array([10, 20, 30], dtype=pd.Int64Dtype()),
    })
    path = temp_dir / "no_exit.parquet"
    walks_df.to_parquet(path, index=False)

    network = Network.from_walks(path)
    edges = network.get_edges(as_geopandas=False)
    assert len(edges) == 2
    assert "edge_id" in edges.columns


def test_get_edges_as_geopandas_topology_only():
    """get_edges(as_geopandas=True) returns GeoDataFrame even with NULL geometry."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0],
        "source_node": [0],
        "target_node": [1],
    }))
    result = network.get_edges(as_geopandas=True)
    assert isinstance(result, gpd.GeoDataFrame)
    assert len(result) == 1


class _FailOnAsWKB:
    """Wrapper around a DuckDB connection that fails the first ST_AsWKB query."""

    def __init__(self, real_conn):
        self._real = real_conn
        self._should_fail = True

    def execute(self, sql, *args, **kwargs):
        if self._should_fail and "ST_AsWKB" in str(sql):
            self._should_fail = False
            raise RuntimeError("Simulated geometry failure")
        return self._real.execute(sql, *args, **kwargs)

    def __getattr__(self, name):
        return getattr(self._real, name)


def test_get_nodes_geometry_conversion_exception():
    """get_nodes falls back to plain DataFrame when geometry conversion fails."""
    network = Network()
    network._conn.execute(
        "INSERT INTO wp_nodes (node_id, geom) VALUES (1, NULL)"
    )
    network._conn = _FailOnAsWKB(network._conn)
    with pytest.warns(UserWarning, match="Could not convert geometry"):
        result = network.get_nodes(as_geopandas=True)
    assert len(result) == 1
    assert 1 in result["node_id"].values


def test_get_edges_geometry_conversion_exception():
    """get_edges falls back to plain DataFrame when geometry conversion fails."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0], "source_node": [0], "target_node": [1],
    }))
    network._conn = _FailOnAsWKB(network._conn)
    with pytest.warns(UserWarning, match="Could not convert geometry"):
        result = network.get_edges(as_geopandas=True)
    assert len(result) == 1
    assert 0 in result["edge_id"].values


# -------------------------------------------------------------------------
# Coverage: from_osm integration
# -------------------------------------------------------------------------


def test_from_osm_integration():
    """from_osm() parses mocked handlers into a complete Network."""
    canned_ways = [
        {
            "way_id": 42,
            "highway": "residential",
            "oneway": "no",
            "name": "Test Rd",
            "nodes": [1, 2, 3],
        }
    ]
    canned_coords = {
        1: (8.54, 47.37),
        2: (8.55, 47.38),
        3: (8.56, 47.39),
    }

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2, 3}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = canned_coords
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        network = Network.from_osm("fake.osm.pbf")

    nodes = network.get_nodes()
    edges = network.get_edges()
    assert len(nodes) == 3
    assert len(edges) == 4
    assert "geometry" in nodes.columns
    assert "geometry" in edges.columns


def test_from_osm_with_highway_filter():
    """from_osm(highway_filter=...) forwards the filter to WayFilterHandler."""
    captured = {}

    class SpyWayHandler:
        def __init__(self, **kw):
            captured.update(kw)
            self.needed_nodes = {1, 2}
            self.ways = [
                {
                    "way_id": 1,
                    "highway": "footway",
                    "oneway": "no",
                    "name": "",
                    "nodes": [1, 2],
                }
            ]
            self.edge_attributes = kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", SpyWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        Network.from_osm("fake.osm.pbf", highway_filter={"footway"})

    assert captured["highway_filter"] == {"footway"}


# -------------------------------------------------------------------------
# Direct Parquet-to-DuckDB ingestion tests
# -------------------------------------------------------------------------


def test_set_nodes_from_parquet_with_geometry(sample_nodes):
    """Direct Parquet path loads nodes with geometry correctly."""
    _, nodes_path = sample_nodes
    network = Network(nodes=nodes_path)
    nodes = network.get_nodes()
    assert len(nodes) == 5
    assert set(nodes["node_id"]) == {0, 1, 2, 3, 4}
    assert isinstance(nodes, gpd.GeoDataFrame)
    assert "geometry" in nodes.columns
    assert nodes["geometry"].notna().all()


def test_set_nodes_from_parquet_xy_columns(temp_dir):
    """Direct Parquet path builds geometry from x/y columns."""
    nodes_df = pd.DataFrame({
        "node_id": [10, 20, 30],
        "x": [0.0, 1.0, 2.0],
        "y": [0.0, 1.0, 2.0],
    })
    path = temp_dir / "nodes_xy.parquet"
    nodes_df.to_parquet(path, index=False)

    network = Network(nodes=path)
    nodes = network.get_nodes()
    assert len(nodes) == 3
    assert set(nodes["node_id"]) == {10, 20, 30}
    assert "geometry" in nodes.columns
    assert nodes["geometry"].notna().all()


def test_set_nodes_from_parquet_no_geometry(temp_dir):
    """Direct Parquet path handles node_id-only files (no geometry)."""
    nodes_df = pd.DataFrame({"node_id": [1, 2, 3]})
    path = temp_dir / "nodes_bare.parquet"
    nodes_df.to_parquet(path, index=False)

    network = Network(nodes=path)
    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 3
    assert set(nodes["node_id"]) == {1, 2, 3}


def test_set_nodes_from_parquet_string_ids(temp_dir):
    """Direct Parquet path auto-maps string node_id values."""
    from shapely.geometry import Point

    gdf = gpd.GeoDataFrame(
        {"node_id": ["StationA", "StationB", "StationC"]},
        geometry=[Point(0, 0), Point(1, 0), Point(1, 1)],
    )
    path = temp_dir / "nodes_string.parquet"
    gdf.to_parquet(path, index=False)

    network = Network(nodes=path)
    assert network.has_string_mapping
    nodes = network.get_nodes(decode=True)
    assert set(nodes["node_id"]) == {"StationA", "StationB", "StationC"}
    nodes_raw = network.get_nodes(as_geopandas=False, decode=False)
    assert nodes_raw["node_id"].dtype in ("int64", pd.Int64Dtype())


def test_set_nodes_from_parquet_extra_attributes(temp_dir):
    """Extra columns are stored as native DuckDB columns."""
    from shapely.geometry import Point

    gdf = gpd.GeoDataFrame(
        {"node_id": [0, 1], "label": ["foo", "bar"], "weight": [1.5, 2.5]},
        geometry=[Point(0, 0), Point(1, 1)],
    )
    path = temp_dir / "nodes_attrs.parquet"
    gdf.to_parquet(path, index=False)

    network = Network(nodes=path)
    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 2
    assert "label" in nodes.columns
    assert "weight" in nodes.columns
    assert list(nodes["label"]) == ["foo", "bar"]


def test_set_edges_from_parquet_with_geometry(sample_nodes, sample_edges):
    """Direct Parquet path loads edges with geometry correctly."""
    _, nodes_path = sample_nodes
    _, edges_path = sample_edges
    network = Network(nodes=nodes_path, edges=edges_path)
    edges = network.get_edges()
    assert len(edges) == 5
    assert isinstance(edges, gpd.GeoDataFrame)
    assert "geometry" in edges.columns
    assert edges["geometry"].notna().all()


def test_set_edges_from_parquet_no_edge_id(temp_dir):
    """Direct Parquet path auto-generates edge_id when absent."""
    nodes_df = pd.DataFrame({"node_id": [0, 1, 2]})
    nodes_path = temp_dir / "nodes_eid.parquet"
    nodes_df.to_parquet(nodes_path, index=False)

    edges_df = pd.DataFrame({
        "source_node": [0, 1],
        "target_node": [1, 2],
    })
    edges_path = temp_dir / "edges_no_eid.parquet"
    edges_df.to_parquet(edges_path, index=False)

    network = Network(nodes=nodes_path, edges=edges_path)
    edges = network.get_edges(as_geopandas=False)
    assert len(edges) == 2
    assert "edge_id" in edges.columns
    assert len(set(edges["edge_id"])) == 2


def test_set_edges_from_parquet_string_ids(temp_dir):
    """Direct Parquet path resolves string edge_id and node references."""
    from shapely.geometry import Point, LineString

    nodes_gdf = gpd.GeoDataFrame(
        {"node_id": ["A", "B", "C"]},
        geometry=[Point(0, 0), Point(1, 0), Point(1, 1)],
    )
    nodes_path = temp_dir / "nodes_str_e.parquet"
    nodes_gdf.to_parquet(nodes_path, index=False)

    edges_gdf = gpd.GeoDataFrame(
        {
            "edge_id": ["e1", "e2"],
            "source_node": ["A", "B"],
            "target_node": ["B", "C"],
        },
        geometry=[
            LineString([(0, 0), (1, 0)]),
            LineString([(1, 0), (1, 1)]),
        ],
    )
    edges_path = temp_dir / "edges_str.parquet"
    edges_gdf.to_parquet(edges_path, index=False)

    network = Network(nodes=nodes_path, edges=edges_path)
    assert network.has_string_mapping

    edges = network.get_edges(decode=True)
    assert set(edges["edge_id"]) == {"e1", "e2"}
    assert set(edges["source_node"]) == {"A", "B"}
    assert set(edges["target_node"]) == {"B", "C"}


def test_roundtrip_parquet_vs_geodataframe(sample_nodes, sample_edges):
    """Parquet direct path produces same results as the GeoDataFrame path."""
    nodes_gdf, nodes_path = sample_nodes
    edges_gdf, edges_path = sample_edges

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

    net_gdf = Network()
    net_gdf.set_nodes(nodes_gdf)
    net_gdf.set_edges(edges_gdf)

    nodes_pq = net_parquet.get_nodes(as_geopandas=False).sort_values("node_id").reset_index(drop=True)
    nodes_gd = net_gdf.get_nodes(as_geopandas=False).sort_values("node_id").reset_index(drop=True)
    assert set(nodes_pq["node_id"]) == set(nodes_gd["node_id"])
    assert len(nodes_pq) == len(nodes_gd)

    edges_pq = net_parquet.get_edges(as_geopandas=False).sort_values("edge_id").reset_index(drop=True)
    edges_gd = net_gdf.get_edges(as_geopandas=False).sort_values("edge_id").reset_index(drop=True)
    assert set(edges_pq["edge_id"]) == set(edges_gd["edge_id"])
    assert set(edges_pq["source_node"]) == set(edges_gd["source_node"])
    assert set(edges_pq["target_node"]) == set(edges_gd["target_node"])
    assert len(edges_pq) == len(edges_gd)


# -------------------------------------------------------------------------
# from_osm streaming Parquet tests
# -------------------------------------------------------------------------


def test_from_osm_streaming():
    """from_osm() streams through temp Parquet instead of GeoDataFrames."""
    canned_ways = [
        {
            "way_id": 42,
            "highway": "residential",
            "oneway": "no",
            "name": "Test Rd",
            "nodes": [1, 2, 3],
        }
    ]
    canned_coords = {
        1: (8.54, 47.37),
        2: (8.55, 47.38),
        3: (8.56, 47.39),
    }

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2, 3}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = canned_coords
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        network = Network.from_osm("fake.osm.pbf")

    nodes = network.get_nodes()
    edges = network.get_edges()
    assert len(nodes) == 3
    assert len(edges) == 4
    assert isinstance(nodes, gpd.GeoDataFrame)
    assert isinstance(edges, gpd.GeoDataFrame)
    assert "geometry" in nodes.columns
    assert "geometry" in edges.columns


# -------------------------------------------------------------------------
# export_parquet tests
# -------------------------------------------------------------------------


def test_export_parquet(sample_nodes, sample_edges, tmp_path):
    """export_parquet() writes valid GeoParquet files."""
    nodes_gdf, _ = sample_nodes
    edges_gdf, _ = sample_edges
    network = Network.from_gdfs(nodes_gdf, edges_gdf)

    export_dir = tmp_path / "exported"
    network.export_parquet(export_dir)

    nodes_pq = export_dir / "nodes.parquet"
    edges_pq = export_dir / "edges.parquet"
    assert nodes_pq.exists()
    assert edges_pq.exists()

    nodes_back = pd.read_parquet(nodes_pq)
    edges_back = pd.read_parquet(edges_pq)
    assert len(nodes_back) == len(nodes_gdf)
    assert len(edges_back) == len(edges_gdf)
    assert "geometry" in nodes_back.columns
    assert "geometry" in edges_back.columns
    assert "node_id" in nodes_back.columns
    assert "edge_id" in edges_back.columns
    assert "source_node" in edges_back.columns
    assert "target_node" in edges_back.columns


def test_export_parquet_creates_directory(sample_nodes, sample_edges, tmp_path):
    """export_parquet() creates the target directory if it doesn't exist."""
    nodes_gdf, _ = sample_nodes
    edges_gdf, _ = sample_edges
    network = Network.from_gdfs(nodes_gdf, edges_gdf)

    nested = tmp_path / "a" / "b" / "c"
    network.export_parquet(nested)
    assert (nested / "nodes.parquet").exists()
    assert (nested / "edges.parquet").exists()


def test_export_import_roundtrip(sample_nodes, sample_edges, tmp_path):
    """Exporting then re-importing via set_nodes/set_edges gives same data."""
    nodes_gdf, _ = sample_nodes
    edges_gdf, _ = sample_edges
    original = Network.from_gdfs(nodes_gdf, edges_gdf)

    export_dir = tmp_path / "roundtrip"
    original.export_parquet(export_dir)

    reimported = Network()
    reimported.set_nodes(str(export_dir / "nodes.parquet"))
    reimported.set_edges(str(export_dir / "edges.parquet"))

    orig_nodes = original.get_nodes(as_geopandas=False).sort_values("node_id").reset_index(drop=True)
    new_nodes = reimported.get_nodes(as_geopandas=False).sort_values("node_id").reset_index(drop=True)
    assert set(orig_nodes["node_id"]) == set(new_nodes["node_id"])
    assert len(orig_nodes) == len(new_nodes)

    orig_edges = original.get_edges(as_geopandas=False).sort_values("edge_id").reset_index(drop=True)
    new_edges = reimported.get_edges(as_geopandas=False).sort_values("edge_id").reset_index(drop=True)
    assert set(orig_edges["edge_id"]) == set(new_edges["edge_id"])
    assert set(orig_edges["source_node"]) == set(new_edges["source_node"])
    assert len(orig_edges) == len(new_edges)


# -------------------------------------------------------------------------
# from_osm bbox tests
# -------------------------------------------------------------------------


def test_from_osm_with_bbox_tuple():
    """from_osm(bbox=tuple) coerces to BoundingBox and filters nodes."""
    canned_ways = [
        {
            "way_id": 42,
            "highway": "residential",
            "oneway": "no",
            "name": "Test Rd",
            "nodes": [1, 2, 3],
        }
    ]
    canned_coords = {
        1: (8.54, 47.37),
        2: (8.55, 47.38),
        3: (8.56, 47.39),
    }

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2, 3}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {
                nid: coord
                for nid, coord in canned_coords.items()
                if bbox is None or bbox.contains(coord[0], coord[1])
            }
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    bbox_tuple = (47.385, 47.375, 8.555, 8.545)

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ), patch(
        "walkingpandas.io.match.osm_utils._osmium_available", return_value=False
    ):
        network = Network.from_osm("fake.osm.pbf", bbox=bbox_tuple)

    nodes = network.get_nodes()
    assert len(nodes) == 1
    assert nodes["node_id"].iloc[0] == 2


def test_from_osm_with_bbox_object():
    """from_osm(bbox=BoundingBox) works directly."""
    from walkingpandas._utils import BoundingBox

    canned_ways = [
        {
            "way_id": 42,
            "highway": "residential",
            "oneway": "no",
            "name": "Test Rd",
            "nodes": [1, 2, 3],
        }
    ]
    canned_coords = {
        1: (8.54, 47.37),
        2: (8.55, 47.38),
        3: (8.56, 47.39),
    }

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2, 3}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {
                nid: coord
                for nid, coord in canned_coords.items()
                if bbox is None or bbox.contains(coord[0], coord[1])
            }
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    bbox = BoundingBox.from_nsew(47.385, 47.375, 8.555, 8.545)

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ), patch(
        "walkingpandas.io.match.osm_utils._osmium_available", return_value=False
    ):
        network = Network.from_osm("fake.osm.pbf", bbox=bbox)

    nodes = network.get_nodes()
    assert len(nodes) == 1


def test_from_osm_with_bbox_osmium_cli():
    """from_osm uses osmium extract + tags-filter when CLI is available."""
    captured_pbf_paths = []

    canned_ways = [
        {
            "way_id": 1,
            "highway": "footway",
            "oneway": "no",
            "name": "",
            "nodes": [1, 2],
        }
    ]

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, path):
            captured_pbf_paths.append(path)

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
            self.node_attrs = {}
        def apply_file(self, path):
            captured_pbf_paths.append(path)

    bbox_tuple = (2.0, -1.0, 2.0, -1.0)

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ), patch(
        "walkingpandas.io.match.osm_utils._osmium_available", return_value=True
    ), patch(
        "walkingpandas.io.match.osm_utils.osmium_extract_bbox", return_value=True
    ), patch(
        "walkingpandas.io.match.osm_utils.osmium_tags_filter", return_value=True
    ):
        Network.from_osm("original.osm.pbf", bbox=bbox_tuple)

    assert len(captured_pbf_paths) == 2
    assert captured_pbf_paths[0] != "original.osm.pbf"


def test_from_osm_highway_prefilter():
    """from_osm calls osmium_tags_filter even without bbox."""
    tags_filter_called = []

    canned_ways = [
        {
            "way_id": 1,
            "highway": "footway",
            "oneway": "no",
            "name": "",
            "nodes": [1, 2],
        }
    ]

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    def spy_tags_filter(input_pbf, output_pbf, *expressions):
        tags_filter_called.append((input_pbf, expressions))
        return True

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ), patch(
        "walkingpandas.io.match.osm_utils._osmium_available", return_value=True
    ), patch(
        "walkingpandas.io.match.osm_utils.osmium_tags_filter", side_effect=spy_tags_filter
    ):
        network = Network.from_osm("big.osm.pbf")

    assert len(tags_filter_called) == 1
    assert tags_filter_called[0][1] == ("w/highway",)
    assert len(network.get_nodes()) == 2


def test_from_osm_without_bbox():
    """from_osm without bbox and without osmium CLI still works."""
    canned_ways = [
        {
            "way_id": 1,
            "highway": "footway",
            "oneway": "no",
            "name": "",
            "nodes": [1, 2],
        }
    ]

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ), patch(
        "walkingpandas.io.match.osm_utils._osmium_available", return_value=False
    ):
        network = Network.from_osm("fake.osm.pbf")

    assert len(network.get_nodes()) == 2
    assert len(network.get_edges()) == 2


# -------------------------------------------------------------------------
# Refactoring: state-desync, fallback hardening
# -------------------------------------------------------------------------


class _FailOnNodeInsert:
    """Wrapper that raises on INSERT INTO wp_nodes."""

    def __init__(self, real_conn):
        self._real = real_conn

    def execute(self, sql, *args, **kwargs):
        if "INSERT INTO wp_nodes" in str(sql):
            raise duckdb.Error("simulated insertion failure")
        return self._real.execute(sql, *args, **kwargs)

    def register(self, *args, **kwargs):
        return self._real.register(*args, **kwargs)

    def __getattr__(self, name):
        return getattr(self._real, name)


class _FailOnEdgeInsert:
    """Wrapper that raises on INSERT INTO wp_edges."""

    def __init__(self, real_conn):
        self._real = real_conn

    def execute(self, sql, *args, **kwargs):
        if "INSERT INTO wp_edges" in str(sql):
            raise duckdb.Error("simulated edge insertion failure")
        return self._real.execute(sql, *args, **kwargs)

    def register(self, *args, **kwargs):
        return self._real.register(*args, **kwargs)

    def __getattr__(self, name):
        return getattr(self._real, name)


def test_auto_map_rollback_does_not_corrupt_state():
    """If the DB transaction rolls back, the mapping tables stay clean."""
    network = Network()
    assert network._conn.execute("SELECT COUNT(*) FROM wp_node_map").fetchone()[0] == 0

    network._conn = _FailOnNodeInsert(network._conn)

    with pytest.raises(duckdb.Error, match="simulated"):
        network.set_nodes(pd.DataFrame({"node_id": ["A", "B", "C"]}))

    assert network._conn.execute("SELECT COUNT(*) FROM wp_node_map").fetchone()[0] == 0


def test_auto_map_edge_rollback_does_not_corrupt_state():
    """If the edge DB transaction rolls back, the mapping tables stay clean."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1, 2]}))
    assert network._conn.execute("SELECT COUNT(*) FROM wp_edge_map").fetchone()[0] == 0

    network._conn = _FailOnEdgeInsert(network._conn)

    with pytest.raises(duckdb.Error, match="simulated"):
        network.set_edges(pd.DataFrame({
            "edge_id": ["e1", "e2"],
            "source_node": [0, 1],
            "target_node": [1, 2],
        }))

    assert network._conn.execute("SELECT COUNT(*) FROM wp_edge_map").fetchone()[0] == 0


def test_set_nodes_parquet_value_error_propagates(temp_dir):
    """A ValueError from the direct Parquet path is not caught by the fallback."""
    nodes_df = pd.DataFrame({"wrong_column": [1, 2, 3]})
    path = temp_dir / "bad_nodes.parquet"
    nodes_df.to_parquet(path, index=False)

    network = Network()
    with pytest.raises(ValueError, match="node_id"):
        network.set_nodes(path)


def test_set_nodes_parquet_fallback_raises_on_large_file(temp_dir):
    """The fallback refuses files larger than _MAX_FALLBACK_SIZE_MB."""
    nodes_df = pd.DataFrame({"node_id": [0, 1]})
    path = temp_dir / "nodes_large.parquet"
    nodes_df.to_parquet(path, index=False)

    network = Network()
    network._MAX_FALLBACK_SIZE_MB = 0

    def broken_parquet(p):
        raise duckdb.Error("simulated DuckDB error")

    network._set_nodes_from_parquet = broken_parquet

    with pytest.raises(RuntimeError, match="too large"):
        network.set_nodes(path)


def test_set_edges_parquet_value_error_propagates(temp_dir):
    """A ValueError from the direct Parquet edge path is not caught."""
    edges_df = pd.DataFrame({"wrong_column": [1, 2]})
    path = temp_dir / "bad_edges.parquet"
    edges_df.to_parquet(path, index=False)

    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    with pytest.raises(ValueError, match="source_node"):
        network.set_edges(path)


# -------------------------------------------------------------------------
# Attributes roundtrip & configurable OSM attributes
# -------------------------------------------------------------------------


def test_export_import_roundtrip_preserves_attributes(tmp_path):
    """Exporting then re-importing preserves original attribute columns."""
    from walkingpandas.io.match.osm_utils import write_network_parquet

    ways = [
        {
            "way_id": 42,
            "highway": "residential",
            "oneway": "no",
            "name": "Main St",
            "nodes": [1, 2, 3],
        }
    ]
    coords = {1: (8.54, 47.37), 2: (8.55, 47.38), 3: (8.56, 47.39)}

    nodes_pq = str(tmp_path / "nodes.parquet")
    edges_pq = str(tmp_path / "edges.parquet")
    write_network_parquet(
        ways, coords, nodes_pq, edges_pq,
        edge_attributes=("way_id", "oneway", "name"),
    )

    original = Network(nodes=nodes_pq, edges=edges_pq)
    orig_edges = original.get_edges(as_geopandas=False)
    assert "way_id" in orig_edges.columns
    assert "oneway" in orig_edges.columns
    assert "name" in orig_edges.columns

    export_dir = tmp_path / "roundtrip"
    original.export_parquet(export_dir)

    reimported = Network()
    reimported.set_nodes(str(export_dir / "nodes.parquet"))
    reimported.set_edges(str(export_dir / "edges.parquet"))

    new_edges = reimported.get_edges(as_geopandas=False)
    assert "way_id" in new_edges.columns
    assert "name" in new_edges.columns


def test_export_import_roundtrip_preserves_node_attributes(tmp_path):
    """Exporting then re-importing preserves node attribute columns."""
    from walkingpandas.io.match.osm_utils import write_network_parquet

    ways = [{"way_id": 1, "highway": "footway", "oneway": "no", "nodes": [1, 2]}]
    coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
    node_attrs = {1: {"highway": "crossing"}, 2: {"highway": ""}}

    nodes_pq = str(tmp_path / "nodes.parquet")
    edges_pq = str(tmp_path / "edges.parquet")
    write_network_parquet(
        ways, coords, nodes_pq, edges_pq, node_attrs=node_attrs,
    )

    original = Network(nodes=nodes_pq, edges=edges_pq)
    orig_nodes = original.get_nodes(as_geopandas=False)
    assert "highway" in orig_nodes.columns

    export_dir = tmp_path / "roundtrip"
    original.export_parquet(export_dir)

    reimported = Network()
    reimported.set_nodes(str(export_dir / "nodes.parquet"))
    reimported_nodes = reimported.get_nodes(as_geopandas=False)
    assert "highway" in reimported_nodes.columns


def test_from_osm_default_edge_attributes():
    """from_osm() with defaults produces edges with way_id and oneway columns."""

    canned_ways = [
        {
            "way_id": 10,
            "highway": "residential",
            "oneway": "yes",
            "name": "Oak St",
            "nodes": [1, 2],
        }
    ]
    canned_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = canned_coords
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        network = Network.from_osm("fake.osm.pbf")

    edges = network.get_edges(as_geopandas=False)
    assert len(edges) > 0
    assert "way_id" in edges.columns
    assert "oneway" in edges.columns
    assert "name" not in edges.columns, "name should not be in default attributes"


def test_from_osm_custom_edge_attributes():
    """from_osm(edge_attributes=...) includes requested tags as columns."""

    canned_ways = [
        {
            "way_id": 10,
            "highway": "residential",
            "oneway": "no",
            "name": "Elm St",
            "maxspeed": "30",
            "nodes": [1, 2],
        }
    ]
    canned_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            ea = _kw.get('edge_attributes') or ("way_id", "oneway")
            self.edge_attributes = tuple(ea)
            _STRUCTURAL = {"way_id", "highway", "oneway", "nodes"}
            extra = {a: canned_ways[0].get(a, "") for a in self.edge_attributes if a not in _STRUCTURAL}
            for w in canned_ways:
                w.update(extra)
            self.ways = canned_ways
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = canned_coords
            self.node_attrs = {}
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        network = Network.from_osm(
            "fake.osm.pbf",
            edge_attributes=("way_id", "oneway", "name", "maxspeed"),
        )

    edges = network.get_edges(as_geopandas=False)
    assert len(edges) > 0
    assert "way_id" in edges.columns
    assert "oneway" in edges.columns
    assert "name" in edges.columns
    assert "maxspeed" in edges.columns


def test_from_osm_node_attributes():
    """from_osm(node_attributes=...) stores node tags as native columns."""

    canned_ways = [
        {"way_id": 1, "highway": "footway", "oneway": "no", "nodes": [1, 2]}
    ]
    canned_coords = {1: (0.0, 0.0), 2: (1.0, 1.0)}
    canned_node_attrs = {
        1: {"highway": "crossing", "crossing": "traffic_signals"},
        2: {"highway": "", "crossing": ""},
    }

    class FakeWayHandler:
        def __init__(self, **_kw):
            self.needed_nodes = {1, 2}
            self.ways = canned_ways
            self.edge_attributes = _kw.get('edge_attributes') or ("way_id", "oneway")
        def apply_file(self, _path):
            pass

    class FakeNodeHandler:
        def __init__(self, _needed, bbox=None, **_kw):
            self.node_coords = canned_coords
            self.node_attrs = canned_node_attrs
        def apply_file(self, _path):
            pass

    with patch(
        "walkingpandas.io.match.osm_utils.WayFilterHandler", FakeWayHandler
    ), patch(
        "walkingpandas.io.match.osm_utils.NodeCoordinateHandler", FakeNodeHandler
    ):
        network = Network.from_osm(
            "fake.osm.pbf",
            node_attributes=("highway", "crossing"),
        )

    nodes = network.get_nodes(as_geopandas=False)
    assert len(nodes) == 2
    assert "highway" in nodes.columns
    assert "crossing" in nodes.columns


# -------------------------------------------------------------------------
# build_indexes / save(index_edges) / load(index_edges)
# -------------------------------------------------------------------------


def _get_edge_index_names(network):
    """Return the set of index names on wp_edges from duckdb_indexes()."""
    rows = network.connection.execute(
        "SELECT index_name FROM duckdb_indexes() WHERE table_name = 'wp_edges'"
    ).fetchall()
    return {r[0] for r in rows}


def test_build_indexes():
    """build_indexes() creates B-tree indexes; second call is a no-op."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0], "source_node": [0], "target_node": [1],
    }))

    assert "edge_source_idx" not in _get_edge_index_names(network)

    network.build_indexes()
    idx_names = _get_edge_index_names(network)
    assert "edge_source_idx" in idx_names
    assert "edge_target_idx" in idx_names

    network.build_indexes()
    assert _get_edge_index_names(network) == idx_names


def test_build_indexes_returns_self():
    """build_indexes() returns the Network instance for chaining."""
    network = Network()
    result = network.build_indexes()
    assert result is network


def test_save_load_default_no_indexes(temp_dir):
    """save() without index_edges produces a file with no edge B-tree indexes."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0], "source_node": [0], "target_node": [1],
    }))

    db_path = temp_dir / "no_idx.duckdb"
    network.save(db_path)

    loaded = Network.load(db_path)
    idx_names = _get_edge_index_names(loaded)
    assert "edge_source_idx" not in idx_names
    assert "edge_target_idx" not in idx_names


def test_save_with_index_edges(temp_dir):
    """save(index_edges=True) bakes indexes into the file; load preserves them."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0], "source_node": [0], "target_node": [1],
    }))

    db_path = temp_dir / "with_idx.duckdb"
    network.save(db_path, index_edges=True)

    loaded = Network.load(db_path)
    idx_names = _get_edge_index_names(loaded)
    assert "edge_source_idx" in idx_names
    assert "edge_target_idx" in idx_names


def test_load_with_index_edges(temp_dir):
    """load(index_edges=True) creates indexes even if file was saved without."""
    network = Network()
    network.set_nodes(pd.DataFrame({"node_id": [0, 1]}))
    network.set_edges(pd.DataFrame({
        "edge_id": [0], "source_node": [0], "target_node": [1],
    }))

    db_path = temp_dir / "load_idx.duckdb"
    network.save(db_path)

    loaded = Network.load(db_path, index_edges=True)
    idx_names = _get_edge_index_names(loaded)
    assert "edge_source_idx" in idx_names
    assert "edge_target_idx" in idx_names
