"""
Network class for managing static network topology in DuckDB.

Handles nodes and edges tables with spatial indexing, and provides
ingestion methods from various sources (Parquet, GeoDataFrames, NetworkX).
"""

import logging
import os

import duckdb
import geopandas as gpd
import pandas as pd
from pathlib import Path
from typing import Any
import warnings

logger = logging.getLogger(__name__)


class Network:
    """Manages static network topology in DuckDB.

    The Network class handles the static, in-memory portion of the
    walkingpandas architecture. It creates and manages DuckDB tables
    for nodes and edges with spatial indexing.

    Args:
        nodes: Path to Parquet file with nodes, or GeoDataFrame, or None.
        edges: Path to Parquet file with edges, or GeoDataFrame, or None.
        **kwargs: Additional arguments passed to DuckDB connection.

    Examples:
        >>> network = Network(nodes="nodes.parquet", edges="edges.parquet")
        >>> network = Network.from_gdfs(nodes_gdf, edges_gdf)
        >>> network = Network.from_networkx(G)
        >>> network = Network.from_osmnx(place="Berlin, Germany", network_type="walk")
    """

    _MAX_FALLBACK_SIZE_MB = 2048
    _NODES_CORE_COLS = frozenset({"node_id", "geom"})
    _EDGES_CORE_COLS = frozenset({"edge_id", "source_node", "target_node", "geom"})

    def __init__(
        self,
        nodes: str | Path | gpd.GeoDataFrame | None = None,
        edges: str | Path | gpd.GeoDataFrame | None = None,
        database: str | Path | None = None,
        **kwargs: Any,
    ):
        """Initialize Network with nodes and edges.

        Args:
            nodes: Nodes data source.
            edges: Edges data source.
            database: Path to DuckDB database file (.duckdb). If provided,
                data will be persisted to disk. If None, uses in-memory
                database.
            **kwargs: Additional arguments passed to DuckDB connection.
        """
        # Create DuckDB connection (persistent if database path provided)
        if database:
            self._database_path = str(database)
            self._conn = duckdb.connect(self._database_path, **kwargs)
        else:
            self._database_path = None
            self._conn = duckdb.connect(**kwargs)
        
        self._has_string_mapping: bool | None = None

        self._install_spatial()
        self._create_schema()
        
        # Load data if provided
        if nodes is not None or edges is not None:
            if nodes is not None:
                self.set_nodes(nodes)
            if edges is not None:
                self.set_edges(edges)
    
    def _install_spatial(self):
        """Install and load the DuckDB spatial extension."""
        installed = self._conn.execute(
            "SELECT installed FROM duckdb_extensions() "
            "WHERE extension_name = 'spatial'"
        ).fetchone()
        if not installed or not installed[0]:
            try:
                self._conn.execute("INSTALL spatial;")
            except Exception as e:
                warnings.warn(
                    f"Failed to install DuckDB spatial extension: {e}. "
                    f"Spatial operations will not work."
                )
        try:
            self._conn.execute("LOAD spatial;")
        except Exception as e:
            warnings.warn(f"Failed to load spatial extension: {e}")
    
    def _create_schema(self):
        """Create DuckDB tables and indexes for nodes and edges."""
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_nodes (
                node_id BIGINT PRIMARY KEY,
                geom GEOMETRY
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_edges (
                edge_id BIGINT PRIMARY KEY,
                source_node BIGINT,
                target_node BIGINT,
                geom GEOMETRY,
                FOREIGN KEY (source_node) REFERENCES wp_nodes(node_id),
                FOREIGN KEY (target_node) REFERENCES wp_nodes(node_id)
            )
        """)
        
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_node_map (
                int_id BIGINT PRIMARY KEY,
                original_id VARCHAR NOT NULL
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_edge_map (
                int_id BIGINT PRIMARY KEY,
                original_id VARCHAR NOT NULL
            )
        """)

        for table, idx_name in [
            ("wp_nodes", "node_geom_idx"),
            ("wp_edges", "edge_geom_idx"),
        ]:
            try:
                self._conn.execute(
                    f"CREATE SPATIAL INDEX IF NOT EXISTS {idx_name} ON {table} (geom)"
                )
            except Exception:
                pass

    def build_indexes(self) -> "Network":
        """Create B-tree indexes on edge source/target columns.

        Useful for graph-traversal and routing queries.  Safe to call
        multiple times; existing indexes are kept.

        Returns:
            self, for method chaining.
        """
        self._conn.execute(
            "CREATE INDEX IF NOT EXISTS edge_source_idx "
            "ON wp_edges (source_node)"
        )
        self._conn.execute(
            "CREATE INDEX IF NOT EXISTS edge_target_idx "
            "ON wp_edges (target_node)"
        )
        return self

    @staticmethod
    def _coerce_int64_column(series: pd.Series, col_name: str) -> pd.Series:
        """Cast a Series to nullable Int64, raising on non-numeric values."""
        if pd.api.types.is_integer_dtype(series) or series.dtype == pd.Int64Dtype():
            return series.astype(pd.Int64Dtype())
        try:
            return pd.to_numeric(series, errors='raise').astype(pd.Int64Dtype())
        except (ValueError, TypeError) as exc:
            raise ValueError(
                f"Column '{col_name}' must contain integer-like values. "
                f"Got non-numeric data (dtype={series.dtype}). "
                f"Map your identifiers to integers before ingestion, "
                f"or use the string-translation layer (has_string_mapping)."
            ) from exc

    def _coerce_node_ids(
        self, df: pd.DataFrame,
    ) -> tuple[pd.DataFrame, pd.DataFrame | None, dict[str, int]]:
        """Coerce node_id to Int64.

        If the column contains non-numeric strings, builds a translation
        table automatically.

        Returns ``(df, pending_node_map_df_or_None, new_map)``.
        """
        df = df.copy()
        try:
            df['node_id'] = self._coerce_int64_column(df['node_id'], 'node_id')
            return df, None, {}
        except ValueError:
            return self._auto_map_ids(df, 'node_id', 'wp_node_map')

    def _auto_map_ids(
        self,
        df: pd.DataFrame,
        id_column: str,
        map_table: str,
    ) -> tuple[pd.DataFrame, pd.DataFrame | None, dict[str, int]]:
        """Assign dense integer IDs for string values in *id_column*.

        Reads existing mappings from *map_table* in DuckDB.  New strings
        are assigned the next available integer.

        Returns ``(remapped_df, pending_map_df_or_None, new_map)``.
        """
        existing = dict(
            self._conn.execute(
                f"SELECT original_id, int_id FROM {map_table}"
            ).fetchall()
        )
        next_int = (
            self._conn.execute(
                f"SELECT COALESCE(MAX(int_id), -1) + 1 FROM {map_table}"
            ).fetchone()[0]
        )

        unique_vals = df[id_column].unique()
        new_map: dict[str, int] = {}
        for v in unique_vals:
            s = str(v)
            if s not in existing and s not in new_map:
                new_map[s] = next_int
                next_int += 1

        pending_map_df = None
        if new_map:
            pending_map_df = pd.DataFrame(
                [(iid, orig) for orig, iid in new_map.items()],
                columns=['int_id', 'original_id'],
            )

        local_merged = {**existing, **new_map}
        df = df.copy()
        df[id_column] = df[id_column].astype(str).map(local_merged).astype(pd.Int64Dtype())
        return df, pending_map_df, new_map

    def _inspect_parquet_schema(self, path: str) -> dict:
        """Return ``{column_name: column_type}`` for a Parquet file via DuckDB.

        Uses ``DESCRIBE`` on ``read_parquet`` so no data is loaded into
        Python memory.
        """
        escaped = path.replace("'", "''")
        schema_df = self._conn.execute(
            f"DESCRIBE SELECT * FROM read_parquet('{escaped}')"
        ).df()
        return dict(zip(schema_df['column_name'], schema_df['column_type']))

    def _get_attr_columns(self, table: str) -> dict[str, str]:
        """Return ``{col_name: duckdb_type}`` for non-core columns in *table*."""
        core = self._NODES_CORE_COLS if table == "wp_nodes" else self._EDGES_CORE_COLS
        placeholders = ", ".join(f"'{c}'" for c in core)
        rows = self._conn.execute(
            f"SELECT column_name, data_type FROM information_schema.columns "
            f"WHERE table_name = '{table}' AND column_name NOT IN ({placeholders})"
        ).fetchall()
        return {name: dtype for name, dtype in rows}

    def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
        """``ALTER TABLE ADD COLUMN`` for any columns not yet present."""
        existing = self._get_attr_columns(table)
        for col, dtype in columns.items():
            if col.lower() not in {k.lower() for k in existing}:
                self._conn.execute(
                    f'ALTER TABLE {table} ADD COLUMN "{col}" {dtype}'
                )

    @staticmethod
    def _pandas_to_duckdb_type(dtype) -> str:
        """Map a pandas/numpy dtype to a DuckDB type string."""
        import numpy as np
        s = str(dtype).lower()
        if "int" in s:
            return "BIGINT"
        if "float" in s or "double" in s:
            return "DOUBLE"
        if "bool" in s:
            return "BOOLEAN"
        if "datetime" in s or "timestamp" in s:
            return "TIMESTAMP"
        return "VARCHAR"

    def _parquet_geom_expr(
        self, schema: dict, col_lower: dict, *, alias: str = "p", allow_xy: bool = False,
    ) -> tuple:
        """Detect the geometry SQL expression for a Parquet file.

        Returns ``(sql_expression, set_of_source_column_names_lowercase)``.
        """
        for candidate in ("geometry", "geom"):
            if candidate in col_lower:
                real_name = col_lower[candidate]
                col_type = schema[real_name].upper()
                if "GEOMETRY" in col_type:
                    expr = f'{alias}."{real_name}"'
                else:
                    expr = f'ST_GeomFromWKB({alias}."{real_name}")'
                return expr, {real_name.lower()}

        if allow_xy and "x" in col_lower and "y" in col_lower:
            x_col, y_col = col_lower["x"], col_lower["y"]
            return f'ST_Point({alias}."{x_col}", {alias}."{y_col}")', {"x", "y"}

        return "NULL", set()

    # ------------------------------------------------------------------
    # Direct Parquet → DuckDB ingestion (bypasses GeoPandas)
    # ------------------------------------------------------------------

    def _set_nodes_from_parquet(self, path: str):
        """Load nodes directly from Parquet into DuckDB, bypassing GeoPandas.

        Handles integer/string ``node_id``, geometry / geom / x+y / no-geom
        columns, and stores extra columns as native DuckDB columns.
        """
        escaped = path.replace("'", "''")
        schema = self._inspect_parquet_schema(path)
        col_lower = {k.lower(): k for k in schema}

        if "node_id" not in col_lower:
            raise ValueError("nodes Parquet must have 'node_id' column")

        node_id_col = col_lower["node_id"]
        node_id_type = schema[node_id_col].upper()
        is_string_id = any(t in node_id_type for t in ("VARCHAR", "TEXT", "CHAR"))

        read_expr = f"read_parquet('{escaped}')"

        geom_expr, geom_src_cols = self._parquet_geom_expr(
            schema, col_lower, alias="p", allow_xy=True,
        )

        skip_cols = {"node_id"} | geom_src_cols
        attr_cols = [k for k in schema if k.lower() not in skip_cols]

        if attr_cols:
            new_cols = {c: schema[c] for c in attr_cols}
            self._ensure_columns("wp_nodes", new_cols)

        attr_insert_cols = "".join(f', "{c}"' for c in attr_cols)
        attr_select_cols = "".join(f', p."{c}"' for c in attr_cols)

        logger.info("_set_nodes_from_parquet: direct ingestion from %s", path)

        self._conn.execute("BEGIN TRANSACTION")
        try:
            self._conn.execute("DELETE FROM wp_nodes")

            if is_string_id:
                self._conn.execute("DELETE FROM wp_node_map")
                self._conn.execute(f"""
                    INSERT INTO wp_node_map (int_id, original_id)
                    SELECT
                        CAST(ROW_NUMBER() OVER (ORDER BY node_id) - 1 AS BIGINT),
                        CAST(node_id AS VARCHAR)
                    FROM (SELECT DISTINCT "{node_id_col}" AS node_id
                          FROM {read_expr}) sub
                """)
                self._conn.execute(f"""
                    INSERT INTO wp_nodes (node_id, geom{attr_insert_cols})
                    SELECT m.int_id, {geom_expr}{attr_select_cols}
                    FROM {read_expr} p
                    JOIN wp_node_map m
                      ON CAST(p."{node_id_col}" AS VARCHAR) = m.original_id
                """)
            else:
                self._conn.execute(f"""
                    INSERT INTO wp_nodes (node_id, geom{attr_insert_cols})
                    SELECT CAST(p."{node_id_col}" AS BIGINT),
                           {geom_expr}{attr_select_cols}
                    FROM {read_expr} p
                """)

            self._conn.execute("COMMIT")
        except Exception:
            self._conn.execute("ROLLBACK")
            raise

        if is_string_id:
            self._has_string_mapping = None

    def _set_edges_from_parquet(self, path: str):
        """Load edges directly from Parquet into DuckDB, bypassing GeoPandas.

        Handles string/integer IDs for ``edge_id``, ``source_node``, and
        ``target_node`` (resolving through ``wp_node_map`` / ``wp_edge_map``
        as needed), geometry columns, and dynamic attribute columns.
        """
        escaped = path.replace("'", "''")
        schema = self._inspect_parquet_schema(path)
        col_lower = {k.lower(): k for k in schema}

        if "source_node" not in col_lower:
            raise ValueError("edges must have 'source_node' column")
        if "target_node" not in col_lower:
            raise ValueError("edges must have 'target_node' column")

        source_col = col_lower["source_node"]
        target_col = col_lower["target_node"]
        has_edge_id = "edge_id" in col_lower
        edge_id_col = col_lower.get("edge_id")

        read_expr = f"read_parquet('{escaped}')"

        geom_expr, geom_src_cols = self._parquet_geom_expr(
            schema, col_lower, alias="p",
        )

        def _is_str(col_type: str) -> bool:
            upper = col_type.upper()
            return any(t in upper for t in ("VARCHAR", "TEXT", "CHAR"))

        source_is_str = _is_str(schema[source_col])
        target_is_str = _is_str(schema[target_col])
        need_node_resolve = self.has_string_mapping and (source_is_str or target_is_str)

        if (source_is_str or target_is_str) and not self.has_string_mapping:
            raise ValueError(
                "source_node/target_node contain strings but no node string "
                "mapping exists. Call set_nodes() with string IDs first."
            )

        is_string_edge_id = has_edge_id and _is_str(schema[edge_id_col])

        skip_cols = {"edge_id", "source_node", "target_node"} | geom_src_cols
        attr_cols = [k for k in schema if k.lower() not in skip_cols]

        if attr_cols:
            new_cols = {c: schema[c] for c in attr_cols}
            self._ensure_columns("wp_edges", new_cols)

        attr_insert_cols = "".join(f', "{c}"' for c in attr_cols)
        attr_select_cols = "".join(f', p."{c}"' for c in attr_cols)

        if need_node_resolve:
            source_expr = "nm_src.int_id"
            target_expr = "nm_tgt.int_id"
            join_parts = [
                f'JOIN wp_node_map nm_src ON CAST(p."{source_col}" AS VARCHAR) = nm_src.original_id',
                f'JOIN wp_node_map nm_tgt ON CAST(p."{target_col}" AS VARCHAR) = nm_tgt.original_id',
            ]
        else:
            source_expr = f'CAST(p."{source_col}" AS BIGINT)'
            target_expr = f'CAST(p."{target_col}" AS BIGINT)'
            join_parts = []

        if has_edge_id and not is_string_edge_id:
            edge_id_expr = f'CAST(p."{edge_id_col}" AS BIGINT)'
        elif is_string_edge_id:
            edge_id_expr = "em.int_id"
        else:
            edge_id_expr = "CAST(ROW_NUMBER() OVER () - 1 AS BIGINT)"

        logger.info("_set_edges_from_parquet: direct ingestion from %s", path)

        self._conn.execute("BEGIN TRANSACTION")
        try:
            self._conn.execute("DELETE FROM wp_edges")

            if is_string_edge_id:
                self._conn.execute("DELETE FROM wp_edge_map")
                self._conn.execute(f"""
                    INSERT INTO wp_edge_map (int_id, original_id)
                    SELECT
                        CAST(ROW_NUMBER() OVER (ORDER BY eid) - 1 AS BIGINT),
                        CAST(eid AS VARCHAR)
                    FROM (SELECT DISTINCT "{edge_id_col}" AS eid
                          FROM {read_expr}) sub
                """)
                join_parts.append(
                    f'JOIN wp_edge_map em ON CAST(p."{edge_id_col}" AS VARCHAR) = em.original_id'
                )

            join_clause = "\n".join(join_parts)

            self._conn.execute(f"""
                INSERT INTO wp_edges (edge_id, source_node, target_node, geom{attr_insert_cols})
                SELECT {edge_id_expr}, {source_expr}, {target_expr},
                       {geom_expr}{attr_select_cols}
                FROM {read_expr} p
                {join_clause}
            """)

            self._conn.execute("COMMIT")
        except Exception:
            self._conn.execute("ROLLBACK")
            raise

        if is_string_edge_id:
            self._has_string_mapping = None

    def set_nodes(self, nodes: str | Path | gpd.GeoDataFrame | pd.DataFrame):
        """Replace all nodes in the network.

        Args:
            nodes: Nodes data source.
        """
        if isinstance(nodes, (str, Path)):
            try:
                return self._set_nodes_from_parquet(str(nodes))
            except (ValueError, TypeError):
                raise
            except duckdb.Error as exc:
                size_mb = Path(nodes).stat().st_size / (1024 * 1024)
                if size_mb > self._MAX_FALLBACK_SIZE_MB:
                    raise RuntimeError(
                        f"Direct ingestion failed and file is too large for "
                        f"GeoPandas fallback ({size_mb:.0f} MB > "
                        f"{self._MAX_FALLBACK_SIZE_MB} MB)"
                    ) from exc
                logger.warning(
                    "Direct Parquet ingestion failed (%s, %.1f MB), "
                    "falling back to GeoPandas.", exc, size_mb,
                )
                nodes_gdf = gpd.read_parquet(str(nodes))
        elif isinstance(nodes, gpd.GeoDataFrame):
            nodes_gdf = nodes.copy()
        elif isinstance(nodes, pd.DataFrame):
            nodes_gdf = gpd.GeoDataFrame(nodes)
        else:
            raise TypeError(f"Unsupported nodes type: {type(nodes)}")
        
        if 'node_id' not in nodes_gdf.columns:
            raise ValueError("nodes must have 'node_id' column")

        nodes_gdf, pending_node_map, new_node_map = self._coerce_node_ids(nodes_gdf)

        # Handle geometry column
        if 'geometry' in nodes_gdf.columns:
            geom_col = 'geometry'
        elif 'geom' in nodes_gdf.columns:
            geom_col = 'geom'
        else:
            if 'x' in nodes_gdf.columns and 'y' in nodes_gdf.columns:
                from shapely.geometry import Point
                geoms = gpd.GeoSeries(
                    [Point(row['x'], row['y']) for _, row in nodes_gdf.iterrows()],
                    crs=nodes_gdf.crs if hasattr(nodes_gdf, 'crs') else None,
                )
                nodes_gdf = gpd.GeoDataFrame(nodes_gdf, geometry=geoms, crs=geoms.crs)
                geom_col = 'geometry'
            else:
                nodes_prep_clean = nodes_gdf[['node_id']].copy()
                self._conn.register('nodes_temp', nodes_prep_clean)
                self._conn.execute("BEGIN TRANSACTION")
                try:
                    if pending_node_map is not None:
                        self._conn.register('_node_map_tmp', pending_node_map)
                        self._conn.execute("INSERT INTO wp_node_map SELECT * FROM _node_map_tmp")
                    self._conn.execute("DELETE FROM wp_nodes")
                    self._conn.execute("""
                        INSERT INTO wp_nodes (node_id, geom)
                        SELECT node_id, NULL FROM nodes_temp
                    """)
                    self._conn.execute("COMMIT")
                except Exception:
                    self._conn.execute("ROLLBACK")
                    raise
                if new_node_map:
                    self._has_string_mapping = None
                return
        
        # Prepare data for insertion (geometry path)
        import shapely as _shapely

        logger.info("set_nodes: preparing %d nodes ...", len(nodes_gdf))

        attr_cols = [c for c in nodes_gdf.columns if c not in ['node_id', geom_col, 'geometry']]

        geom_values = nodes_gdf[geom_col].values
        geom_wkb = _shapely.to_wkb(geom_values)

        clean_data: dict = {'node_id': nodes_gdf['node_id'].to_numpy(), 'geom_wkb': geom_wkb}
        for c in attr_cols:
            clean_data[c] = nodes_gdf[c].to_numpy()
        nodes_prep_clean = pd.DataFrame(clean_data)
        self._conn.register('nodes_temp', nodes_prep_clean)

        if attr_cols:
            new_cols = {c: self._pandas_to_duckdb_type(nodes_gdf[c].dtype) for c in attr_cols}
            self._ensure_columns("wp_nodes", new_cols)

        attr_insert_cols = "".join(f', "{c}"' for c in attr_cols)
        attr_select_cols = "".join(f', "{c}"' for c in attr_cols)

        logger.info("set_nodes: inserting into DuckDB ...")
        self._conn.execute("BEGIN TRANSACTION")
        try:
            if pending_node_map is not None:
                self._conn.register('_node_map_tmp', pending_node_map)
                self._conn.execute("INSERT INTO wp_node_map SELECT * FROM _node_map_tmp")
            self._conn.execute("DELETE FROM wp_nodes")
            self._conn.execute(f"""
                INSERT INTO wp_nodes (node_id, geom{attr_insert_cols})
                SELECT 
                    node_id,
                    CASE 
                        WHEN geom_wkb IS NOT NULL THEN ST_GeomFromWKB(geom_wkb::BLOB)
                        ELSE NULL
                    END as geom{attr_select_cols}
                FROM nodes_temp
            """)
            self._conn.execute("COMMIT")
        except Exception:
            self._conn.execute("ROLLBACK")
            raise
        if new_node_map:
            self._has_string_mapping = None
    
    def set_edges(self, edges: str | Path | gpd.GeoDataFrame | pd.DataFrame):
        """Replace all edges in the network.

        Args:
            edges: Edges data source.
        """
        if isinstance(edges, (str, Path)):
            try:
                return self._set_edges_from_parquet(str(edges))
            except (ValueError, TypeError):
                raise
            except duckdb.Error as exc:
                size_mb = Path(edges).stat().st_size / (1024 * 1024)
                if size_mb > self._MAX_FALLBACK_SIZE_MB:
                    raise RuntimeError(
                        f"Direct ingestion failed and file is too large for "
                        f"GeoPandas fallback ({size_mb:.0f} MB > "
                        f"{self._MAX_FALLBACK_SIZE_MB} MB)"
                    ) from exc
                logger.warning(
                    "Direct Parquet ingestion failed (%s, %.1f MB), "
                    "falling back to GeoPandas.", exc, size_mb,
                )
                edges_gdf = gpd.read_parquet(str(edges))
        elif isinstance(edges, gpd.GeoDataFrame):
            edges_gdf = edges.copy()
        elif isinstance(edges, pd.DataFrame):
            edges_gdf = gpd.GeoDataFrame(edges)
        else:
            raise TypeError(f"Unsupported edges type: {type(edges)}")
        
        if 'source_node' not in edges_gdf.columns:
            raise ValueError("edges must have 'source_node' column")
        if 'target_node' not in edges_gdf.columns:
            raise ValueError("edges must have 'target_node' column")

        edges_gdf = edges_gdf.copy()

        if self.has_string_mapping:
            node_map = dict(
                self._conn.execute(
                    "SELECT original_id, int_id FROM wp_node_map"
                ).fetchall()
            )
            for col in ('source_node', 'target_node'):
                if pd.api.types.is_string_dtype(edges_gdf[col]) or edges_gdf[col].dtype == object:
                    edges_gdf[col] = edges_gdf[col].astype(str).map(node_map)

        for col in ('source_node', 'target_node'):
            try:
                edges_gdf[col] = self._coerce_int64_column(edges_gdf[col], col)
            except ValueError:
                if not self.has_string_mapping:
                    raise
                node_map = dict(
                    self._conn.execute(
                        "SELECT original_id, int_id FROM wp_node_map"
                    ).fetchall()
                )
                edges_gdf[col] = edges_gdf[col].astype(str).map(node_map)
                edges_gdf[col] = self._coerce_int64_column(edges_gdf[col], col)

        pending_edge_map = None
        new_edge_map: dict[str, int] = {}
        has_edge_id = 'edge_id' in edges_gdf.columns
        if has_edge_id:
            try:
                edges_gdf['edge_id'] = self._coerce_int64_column(edges_gdf['edge_id'], 'edge_id')
            except ValueError:
                edges_gdf, pending_edge_map, new_edge_map = self._auto_map_ids(
                    edges_gdf, 'edge_id', 'wp_edge_map',
                )
        else:
            edges_gdf['edge_id'] = range(len(edges_gdf))
        
        if 'geometry' in edges_gdf.columns:
            geom_col = 'geometry'
        elif 'geom' in edges_gdf.columns:
            geom_col = 'geom'
        else:
            geom_col = None
        
        import shapely as _shapely

        logger.info("set_edges: preparing %d edges ...", len(edges_gdf))

        base_cols = ['edge_id', 'source_node', 'target_node', 'geometry', 'geom']
        attr_cols = [c for c in edges_gdf.columns if c not in base_cols]

        if geom_col:
            geom_wkb = _shapely.to_wkb(edges_gdf[geom_col].values)
        else:
            geom_wkb = pd.array([None] * len(edges_gdf), dtype=object)

        clean_data: dict = {
            'edge_id': edges_gdf['edge_id'].to_numpy(),
            'source_node': edges_gdf['source_node'].to_numpy(),
            'target_node': edges_gdf['target_node'].to_numpy(),
            'geom_wkb': geom_wkb,
        }
        for c in attr_cols:
            clean_data[c] = edges_gdf[c].to_numpy()
        edges_prep_clean = pd.DataFrame(clean_data)
        self._conn.register('edges_temp', edges_prep_clean)

        if attr_cols:
            new_cols = {c: self._pandas_to_duckdb_type(edges_gdf[c].dtype) for c in attr_cols}
            self._ensure_columns("wp_edges", new_cols)

        attr_insert_cols = "".join(f', "{c}"' for c in attr_cols)
        attr_select_cols = "".join(f', "{c}"' for c in attr_cols)

        logger.info("set_edges: inserting into DuckDB ...")
        self._conn.execute("BEGIN TRANSACTION")
        try:
            if pending_edge_map is not None:
                self._conn.register('_edge_map_tmp', pending_edge_map)
                self._conn.execute("INSERT INTO wp_edge_map SELECT * FROM _edge_map_tmp")
            self._conn.execute("DELETE FROM wp_edges")
            self._conn.execute(f"""
                INSERT INTO wp_edges (edge_id, source_node, target_node, geom{attr_insert_cols})
                SELECT 
                    edge_id,
                    source_node,
                    target_node,
                    CASE 
                        WHEN geom_wkb IS NOT NULL THEN ST_GeomFromWKB(geom_wkb::BLOB)
                        ELSE NULL
                    END as geom{attr_select_cols}
                FROM edges_temp
            """)
            self._conn.execute("COMMIT")
        except Exception:
            self._conn.execute("ROLLBACK")
            raise
        if new_edge_map:
            self._has_string_mapping = None
    
    @classmethod
    def from_gdfs(
        cls,
        nodes_gdf: gpd.GeoDataFrame,
        edges_gdf: gpd.GeoDataFrame,
        **kwargs: Any,
    ) -> "Network":
        """Create Network from GeoPandas DataFrames.

        Args:
            nodes_gdf: Nodes GeoDataFrame with 'node_id' column.
            edges_gdf: Edges GeoDataFrame with 'edge_id', 'source_node',
                'target_node' columns.
            **kwargs: Additional arguments for Network initialization.

        Returns:
            Network: New Network instance.
        """
        network = cls(**kwargs)
        network.set_nodes(nodes_gdf)
        network.set_edges(edges_gdf)
        return network
    
    @classmethod
    def from_networkx(cls, G: Any, **kwargs: Any) -> "Network":
        """Create Network from NetworkX graph.

        Args:
            G: NetworkX graph (DiGraph or Graph).
            **kwargs: Additional arguments for Network initialization.

        Returns:
            Network: New Network instance.
        """
        try:
            import networkx as nx
        except ImportError:
            raise ImportError("networkx is required for from_networkx(). Install with: pip install walkingpandas[networkx]")
        
        try:
            from shapely.geometry import Point, LineString
        except ImportError:
            raise ImportError("shapely is required for from_networkx(). Install with: pip install shapely")

        node_list = list(G.nodes())
        node_to_int = {n: i for i, n in enumerate(node_list)}

        nodes_data = []
        for nx_id, attrs in G.nodes(data=True):
            if 'x' in attrs and 'y' in attrs:
                geom = Point(attrs['x'], attrs['y'])
            elif 'geometry' in attrs:
                geom = attrs['geometry']
            else:
                geom = Point(0, 0)
            extra = {k: v for k, v in attrs.items() if k not in ['x', 'y', 'geometry']}
            extra['_original_id'] = str(nx_id)
            nodes_data.append({
                'node_id': node_to_int[nx_id],
                'geometry': geom,
                **extra,
            })
        nodes_gdf = gpd.GeoDataFrame(nodes_data, geometry='geometry')

        edges_data = []
        for edge_counter, (u, v, attrs) in enumerate(G.edges(data=True)):
            if 'geometry' in attrs:
                geom = attrs['geometry']
            else:
                u_node = G.nodes[u]
                v_node = G.nodes[v]
                if 'x' in u_node and 'y' in u_node and 'x' in v_node and 'y' in v_node:
                    geom = LineString([
                        (u_node['x'], u_node['y']),
                        (v_node['x'], v_node['y'])
                    ])
                else:
                    geom = None
            original_edge_key = attrs.get('edge_id', f"{u}_{v}")
            extra = {k: v for k, v in attrs.items() if k not in ['geometry', 'edge_id']}
            extra['_original_edge_id'] = str(original_edge_key)
            edges_data.append({
                'edge_id': edge_counter,
                'source_node': node_to_int[u],
                'target_node': node_to_int[v],
                'geometry': geom,
                **extra,
            })
        edges_gdf = gpd.GeoDataFrame(edges_data, geometry='geometry')

        return cls.from_gdfs(nodes_gdf, edges_gdf, **kwargs)

    @classmethod
    def from_osmnx(
        cls,
        *,
        bbox: tuple[float, float, float, float] | None = None,
        place: str | None = None,
        center_point: tuple[float, float] | None = None,
        dist: float | None = None,
        network_type: str = "all",
        simplify: bool = False,
        **kwargs: Any,
    ) -> "Network":
        """Create a Network from an OSM street network fetched via OSMnx.

        Requires the optional dependency ``osmnx``
        (e.g. ``pip install walkingpandas[osmnx]``).

        Args:
            bbox: Bounding box as (north, south, east, west) in lat/lon,
                matching OSMnx.
            place: Geocodable place name (e.g. "San Francisco, CA").
            center_point: (lat, lon) center for graph_from_point. Requires
                ``dist``.
            dist: Distance in metres from ``center_point`` for
                graph_from_point.
            network_type: OSMnx network type: "walk", "drive", "bike",
                "all", etc.
            simplify: Passed through to OSMnx.
            **kwargs: Passed to ``cls.from_networkx(G, **kwargs)``.

        Returns:
            Network: New Network instance.

        Raises:
            ImportError: If ``osmnx`` is not installed.
            ValueError: If not exactly one of bbox, place, or
                (center_point and dist) is provided.

        Examples:
            >>> network = Network.from_osmnx(place="San Francisco, CA", network_type="walk")
            >>> network = Network.from_osmnx(bbox=(37.8, 37.7, -122.3, -122.5), network_type="walk")
        """
        try:
            import osmnx as ox
        except ImportError:
            raise ImportError(
                "osmnx is required for from_osmnx(). "
                "Install with: pip install walkingpandas[osmnx] or pip install osmnx"
            )
        if bbox is not None:
            if place is not None or center_point is not None or dist is not None:
                raise ValueError("Provide exactly one of: bbox, place, or (center_point and dist).")
            # OSMnx expects (left, bottom, right, top) = (min_lon, min_lat, max_lon, max_lat)
            # We accept (north, south, east, west) = (lat_max, lat_min, lon_max, lon_min)
            north, south, east, west = bbox[0], bbox[1], bbox[2], bbox[3]
            bbox_osmnx = (west, south, east, north)
            G = ox.graph_from_bbox(bbox_osmnx, network_type=network_type, simplify=simplify)
        elif place is not None:
            if center_point is not None or dist is not None:
                raise ValueError("Provide exactly one of: bbox, place, or (center_point and dist).")
            G = ox.graph_from_place(place, network_type=network_type, simplify=simplify)
        elif center_point is not None and dist is not None:
            G = ox.graph_from_point(center_point, dist, network_type=network_type, simplify=simplify)
        else:
            raise ValueError("Provide exactly one of: bbox, place, or (center_point and dist).")
        return cls.from_networkx(G, **kwargs)

    @classmethod
    def from_osm(
        cls,
        osm_pbf: str | Path,
        *,
        bbox: "BoundingBox | tuple[float, float, float, float] | None" = None,
        force_bidirectional: bool = True,
        highway_filter: set | None = None,
        edge_attributes: tuple[str, ...] | None = None,
        node_attributes: tuple[str, ...] | None = None,
        **kwargs: Any,
    ) -> "Network":
        """Create a Network by parsing an OSM PBF file with pyosmium.

        Uses a memory-efficient two-pass strategy:

        * **Pass 1** scans ways, keeps those with a ``highway`` tag, and
          collects the set of referenced node IDs.
        * **Pass 2** scans nodes and stores coordinates only for the IDs
          collected in pass 1.

        Every consecutive pair of nodes in a way becomes a directed edge
        with a ``LineString`` geometry.  Bidirectional edges are generated
        based on the ``oneway`` tag and the *force_bidirectional* flag.

        When *bbox* is provided, the network is limited to nodes within
        that geographic rectangle.  If the ``osmium`` command-line tool
        is installed, it is used to pre-filter the PBF file for a
        significant speed-up on large files.  Otherwise the filtering
        happens in Python during Pass 2.

        Args:
            osm_pbf: Path to an ``.osm.pbf`` file.
            bbox: Geographic bounding box.  Accepts a
                :class:`~walkingpandas.BoundingBox` instance or a plain
                4-tuple interpreted as ``(north, south, east, west)``
                (matching :meth:`from_osmnx`).
            force_bidirectional: If ``True`` (the default), every segment
                produces edges in both directions regardless of the
                ``oneway`` tag.  Set to ``False`` for car routing where
                one-way restrictions matter.
            highway_filter: If provided, only ways whose ``highway`` tag
                value is in this set will be kept.  ``None`` means accept
                all highway types.
            edge_attributes: OSM way tags to store as edge attribute
                columns.  Defaults to ``("way_id", "oneway")``.
            node_attributes: Optional tuple of OSM node tag names to
                collect as node attributes (e.g. ``("highway",
                "crossing")``).  ``None`` (default) stores no node tags.
            **kwargs: Forwarded to the :class:`Network` constructor
                (e.g. ``database=...``).

        Returns:
            A new Network populated with nodes and edges.

        Raises:
            ImportError: If ``pyosmium`` is not installed.
        """
        import shutil
        import tempfile

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

        if bbox is not None and not isinstance(bbox, BoundingBox):
            bbox = BoundingBox.from_nsew(*bbox)

        effective_pbf = str(osm_pbf)
        tmp_filtered_dir = tempfile.mkdtemp()

        try:
            step = effective_pbf

            if bbox is not None and _osmium_available():
                clipped = os.path.join(tmp_filtered_dir, "bbox.osm.pbf")
                if osmium_extract_bbox(step, clipped, bbox):
                    step = clipped

            if highway_filter is None and _osmium_available():
                filtered = os.path.join(tmp_filtered_dir, "highways.osm.pbf")
                if osmium_tags_filter(step, filtered, "w/highway"):
                    step = filtered

            if step != effective_pbf:
                logger.info("Using osmium-filtered PBF: %s", step)
            effective_pbf = step

            logger.info("Pass 1: scanning ways in %s ...", effective_pbf)
            way_handler = WayFilterHandler(
                highway_filter=highway_filter,
                edge_attributes=edge_attributes,
            )
            way_handler.apply_file(effective_pbf)
            logger.info(
                "Pass 1 complete: %d ways, %d needed nodes",
                len(way_handler.ways),
                len(way_handler.needed_nodes),
            )

            logger.info("Pass 2: collecting node coordinates ...")
            node_handler = NodeCoordinateHandler(
                way_handler.needed_nodes,
                bbox=bbox,
                node_attributes=node_attributes,
            )
            node_handler.apply_file(effective_pbf)
            logger.info(
                "Pass 2 complete: %d node coordinates collected",
                len(node_handler.node_coords),
            )

            tmp_dir = tempfile.mkdtemp()
            try:
                nodes_pq = os.path.join(tmp_dir, "nodes.parquet")
                edges_pq = os.path.join(tmp_dir, "edges.parquet")

                logger.info("Writing to temp Parquet (streaming) ...")
                write_network_parquet(
                    way_handler.ways,
                    node_handler.node_coords,
                    nodes_pq,
                    edges_pq,
                    force_bidirectional=force_bidirectional,
                    edge_attributes=way_handler.edge_attributes,
                    node_attrs=node_handler.node_attrs if node_attributes else None,
                )
                del way_handler, node_handler

                logger.info("Inserting into DuckDB ...")
                network = cls(**kwargs)
                network.set_nodes(nodes_pq)
                network.set_edges(edges_pq)
                logger.info("Network ready.")
                return network
            finally:
                shutil.rmtree(tmp_dir, ignore_errors=True)
        finally:
            shutil.rmtree(tmp_filtered_dir, ignore_errors=True)

    @classmethod
    def from_walks(
        cls,
        walks: str | Path | pd.DataFrame,
        *,
        database: str | Path | None = None,
        infer_edges: bool = True,
        **kwargs: Any,
    ) -> "Network":
        """Infer a Network from walk data (observed nodes and edges).

        Extracts distinct node_id as nodes and, if *infer_edges* is True,
        infers edges from consecutive steps (node_id -> next node_id). The
        resulting network has no geometry; spatial filters on WalkFrame will
        not match until geometry is added to the network.

        Args:
            walks: Path/glob to Parquet file(s) with walk data, or in-memory
                DataFrame with columns walk_id, sequence_idx, node_id (and
                optionally exit_edge).
            database: Path to persistent DuckDB file. If None, uses in-memory
                connection.
            infer_edges: If True, infer edges from consecutive steps; if
                False, only infer nodes.
            **kwargs: Additional arguments for Network initialization.

        Returns:
            Network: New Network instance with wp_nodes (and optionally
                wp_edges) populated.
        """
        network = cls(database=database, **kwargs)
        if isinstance(walks, pd.DataFrame):
            nodes_df = pd.DataFrame({"node_id": walks["node_id"].unique()})
            network.set_nodes(nodes_df)
            if infer_edges:
                w = walks.sort_values(["walk_id", "sequence_idx"]).copy()
                w["target_node"] = w.groupby("walk_id")["node_id"].shift(-1)
                w = w.dropna(subset=["target_node"])
                w["source_node"] = w["node_id"]
                if "exit_edge" in w.columns:
                    w["edge_id"] = w["exit_edge"]
                edges_df = w[["source_node", "target_node"]].drop_duplicates().reset_index(drop=True)
                if "edge_id" in w.columns:
                    deduped = w.drop_duplicates(subset=["source_node", "target_node"])
                    edges_df = deduped[["source_node", "target_node", "edge_id"]].reset_index(drop=True)
                    edges_df["edge_id"] = edges_df["edge_id"].where(
                        edges_df["edge_id"].notna(),
                        pd.array(range(len(edges_df)), dtype=pd.Int64Dtype()),
                    )
                else:
                    edges_df["edge_id"] = range(len(edges_df))
                network.set_edges(edges_df)
            return network
        path = str(walks)
        nodes_df = network._conn.execute(
            "SELECT DISTINCT node_id FROM read_parquet(?)", [path]
        ).df()
        network.set_nodes(nodes_df)
        if not infer_edges:
            return network
        edges_sql_with_exit = """
            WITH w AS (
                SELECT walk_id, sequence_idx, node_id, exit_edge,
                       LEAD(node_id) OVER (PARTITION BY walk_id ORDER BY sequence_idx) AS target_node
                FROM read_parquet(?)
            ),
            distinct_edges AS (
                SELECT DISTINCT
                    node_id AS source_node,
                    target_node,
                    exit_edge
                FROM w
                WHERE target_node IS NOT NULL
            )
            SELECT
                source_node,
                target_node,
                COALESCE(exit_edge, CAST(row_number() OVER () - 1 AS BIGINT)) AS edge_id
            FROM distinct_edges
        """
        edges_sql_no_exit = """
            WITH w AS (
                SELECT walk_id, sequence_idx, node_id,
                       LEAD(node_id) OVER (PARTITION BY walk_id ORDER BY sequence_idx) AS target_node
                FROM read_parquet(?)
            ),
            distinct_edges AS (
                SELECT DISTINCT
                    node_id AS source_node,
                    target_node
                FROM w
                WHERE target_node IS NOT NULL
            )
            SELECT
                source_node,
                target_node,
                CAST(row_number() OVER () - 1 AS BIGINT) AS edge_id
            FROM distinct_edges
        """
        try:
            edges_df = network._conn.execute(edges_sql_with_exit, [path]).df()
        except Exception:
            edges_df = network._conn.execute(edges_sql_no_exit, [path]).df()
        if not edges_df.empty:
            network.set_edges(edges_df)
        return network

    @property
    def connection(self):
        """The underlying DuckDB connection."""
        return self._conn

    @property
    def has_string_mapping(self) -> bool:
        """Whether the network has a string-to-integer translation table."""
        if self._has_string_mapping is None:
            row = self._conn.execute(
                "SELECT EXISTS(SELECT 1 FROM wp_node_map LIMIT 1)"
            ).fetchone()
            self._has_string_mapping = bool(row[0])
        return self._has_string_mapping

    def _decode_node_ids(self, df):
        """Replace integer ``node_id`` with original string labels via SQL JOIN."""
        if not self.has_string_mapping or 'node_id' not in df.columns:
            return df
        if not pd.api.types.is_numeric_dtype(df['node_id']):
            return df
        self._conn.register('_tmp_decode_n', df)
        try:
            select_parts = []
            for col in df.columns:
                if col == 'node_id':
                    select_parts.append('nm.original_id AS node_id')
                else:
                    select_parts.append(f't."{col}"')
            sql = (
                f"SELECT {', '.join(select_parts)} "
                f"FROM _tmp_decode_n t "
                f"LEFT JOIN wp_node_map nm ON t.node_id = nm.int_id"
            )
            result = self._conn.execute(sql).df()
        finally:
            try:
                self._conn.unregister('_tmp_decode_n')
            except Exception:
                pass
        return result

    def _decode_edge_ids(self, df):
        """Replace integer edge/source/target IDs with original labels via SQL JOIN."""
        if not self.has_string_mapping:
            return df
        self._conn.register('_tmp_decode_e', df)
        try:
            select_parts = []
            join_parts = []
            for col in df.columns:
                is_numeric = pd.api.types.is_numeric_dtype(df[col])
                if col == 'edge_id' and is_numeric:
                    select_parts.append('em.original_id AS edge_id')
                    join_parts.append(
                        'LEFT JOIN wp_edge_map em ON t.edge_id = em.int_id')
                elif col in ('source_node', 'target_node') and is_numeric:
                    alias = 'nm_src' if col == 'source_node' else 'nm_tgt'
                    select_parts.append(f'{alias}.original_id AS {col}')
                    join_parts.append(
                        f'LEFT JOIN wp_node_map {alias} ON t.{col} = {alias}.int_id')
                else:
                    select_parts.append(f't."{col}"')
            sql = (
                f"SELECT {', '.join(select_parts)} "
                f"FROM _tmp_decode_e t {' '.join(join_parts)}"
            )
            result = self._conn.execute(sql).df()
        finally:
            try:
                self._conn.unregister('_tmp_decode_e')
            except Exception:
                pass
        return result

    @staticmethod
    def _wkb_column_to_geometry(df: pd.DataFrame) -> pd.DataFrame:
        """Convert a ``geom_wkb`` column to a Shapely ``geometry`` column.

        Uses vectorized :func:`shapely.from_wkb` for performance. Drops the
        original ``geom_wkb`` column.
        """
        from shapely import from_wkb
        df = df.copy()
        mask = df["geom_wkb"].notna()
        df["geometry"] = None
        if mask.any():
            wkb_bytes = df.loc[mask, "geom_wkb"].apply(
                lambda b: bytes(b) if isinstance(b, bytearray) else b
            )
            df.loc[mask, "geometry"] = from_wkb(wkb_bytes)
        return df.drop(columns=["geom_wkb"])

    def get_nodes(self, as_geopandas: bool = True, decode: bool = True) -> gpd.GeoDataFrame | pd.DataFrame:
        """Get nodes as GeoDataFrame or DataFrame.

        Args:
            as_geopandas: If True, return GeoDataFrame; otherwise DataFrame.
            decode: If True and a string mapping exists, return original
                string IDs.

        Returns:
            GeoDataFrame or DataFrame.
        """
        if as_geopandas:
            try:
                result = self._conn.execute(
                    "SELECT * EXCLUDE (geom), ST_AsWKB(geom) AS geom_wkb FROM wp_nodes"
                ).df()

                if decode:
                    result = self._decode_node_ids(result)
                if len(result) > 0 and result['geom_wkb'].notna().any():
                    result = self._wkb_column_to_geometry(result)
                    return gpd.GeoDataFrame(result, geometry='geometry', crs="EPSG:4326")
                else:
                    result = result.drop(columns=['geom_wkb'], errors='ignore')
                    return gpd.GeoDataFrame(result)
            except Exception as e:
                warnings.warn(f"Could not convert geometry: {e}. Returning DataFrame without geometry.")
                result = self._conn.execute("SELECT * EXCLUDE (geom) FROM wp_nodes").df()
                if decode:
                    result = self._decode_node_ids(result)
                return result
        else:
            result = self._conn.execute("SELECT * FROM wp_nodes").df()
            if decode:
                result = self._decode_node_ids(result)
            return result
    
    def get_edges(self, as_geopandas: bool = True, decode: bool = True) -> gpd.GeoDataFrame | pd.DataFrame:
        """Get edges as GeoDataFrame or DataFrame.

        Args:
            as_geopandas: If True, return GeoDataFrame; otherwise DataFrame.
            decode: If True and a string mapping exists, return original
                string IDs.

        Returns:
            GeoDataFrame or DataFrame.
        """
        if as_geopandas:
            try:
                result = self._conn.execute(
                    "SELECT * EXCLUDE (geom), ST_AsWKB(geom) AS geom_wkb FROM wp_edges"
                ).df()

                if decode:
                    result = self._decode_edge_ids(result)
                if len(result) > 0 and result['geom_wkb'].notna().any():
                    result = self._wkb_column_to_geometry(result)
                    return gpd.GeoDataFrame(result, geometry='geometry', crs="EPSG:4326")
                else:
                    result = result.drop(columns=['geom_wkb'], errors='ignore')
                    return gpd.GeoDataFrame(result)
            except Exception as e:
                warnings.warn(f"Could not convert geometry: {e}. Returning DataFrame without geometry.")
                result = self._conn.execute("SELECT * EXCLUDE (geom) FROM wp_edges").df()
                if decode:
                    result = self._decode_edge_ids(result)
                return result
        else:
            result = self._conn.execute("SELECT * FROM wp_edges").df()
            if decode:
                result = self._decode_edge_ids(result)
            return result
    
    def save(self, database_path: str | Path, *, index_edges: bool = False):
        """Save Network to a DuckDB database file.

        Uses DuckDB ATTACH to copy tables directly in-engine, avoiding
        intermediate temporary files.

        Args:
            database_path: Path to save the .duckdb database file.
            index_edges: If ``True``, create B-tree indexes on
                ``source_node`` and ``target_node`` in the saved file.
                This avoids the need to call :meth:`build_indexes` after
                :meth:`load`.

        Examples:
            >>> network = Network(nodes=nodes_gdf, edges=edges_gdf)
            >>> network.save("my_network.duckdb")
        """
        database_path = Path(database_path)
        if database_path.exists():
            database_path.unlink()

        src_db = self._conn.execute("SELECT current_database()").fetchone()[0]
        self._conn.execute(f"ATTACH '{database_path}' AS target_db")
        try:
            self._conn.execute("BEGIN TRANSACTION")
            try:
                # Clone node/edge tables with dynamic schema
                for table in ('wp_nodes', 'wp_edges'):
                    self._conn.execute(
                        f"CREATE TABLE target_db.{table} AS "
                        f"SELECT * FROM {src_db}.{table} WHERE 1=0"
                    )
                self._conn.execute(
                    "ALTER TABLE target_db.wp_nodes "
                    "ADD PRIMARY KEY (node_id)"
                )
                self._conn.execute(
                    "ALTER TABLE target_db.wp_edges "
                    "ADD PRIMARY KEY (edge_id)"
                )
                # Fixed-schema mapping tables
                self._conn.execute("""
                    CREATE TABLE target_db.wp_node_map (
                        int_id BIGINT PRIMARY KEY, original_id VARCHAR NOT NULL)
                """)
                self._conn.execute("""
                    CREATE TABLE target_db.wp_edge_map (
                        int_id BIGINT PRIMARY KEY, original_id VARCHAR NOT NULL)
                """)
                for table in ('wp_nodes', 'wp_edges', 'wp_node_map', 'wp_edge_map'):
                    self._conn.execute(
                        f"INSERT INTO target_db.{table} "
                        f"SELECT * FROM {src_db}.{table}"
                    )
                if index_edges:
                    self._conn.execute(
                        "CREATE INDEX edge_source_idx "
                        "ON target_db.wp_edges (source_node)"
                    )
                    self._conn.execute(
                        "CREATE INDEX edge_target_idx "
                        "ON target_db.wp_edges (target_node)"
                    )
                self._conn.execute("COMMIT")
            except Exception:
                self._conn.execute("ROLLBACK")
                raise
        finally:
            self._conn.execute("DETACH target_db")

    def export_parquet(self, directory: str | Path):
        """Export nodes and edges to GeoParquet files via DuckDB COPY.

        The exported files use WKB geometry and are compatible with
        GeoPandas, QGIS, and DuckDB's ``read_parquet()``.  They can also
        be loaded back via ``set_nodes(path)`` / ``set_edges(path)``.

        Args:
            directory: Target directory.  Will be created if it does not
                exist.  Files written: ``nodes.parquet``, ``edges.parquet``.
        """
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)

        nodes_path = str(directory / "nodes.parquet")
        edges_path = str(directory / "edges.parquet")

        self._conn.execute(
            f"COPY (SELECT node_id, geom AS geometry, "
            f"* EXCLUDE (node_id, geom) "
            f"FROM wp_nodes) TO '{nodes_path}' (FORMAT PARQUET)"
        )
        self._conn.execute(
            f"COPY (SELECT edge_id, source_node, target_node, "
            f"geom AS geometry, "
            f"* EXCLUDE (edge_id, source_node, target_node, geom) "
            f"FROM wp_edges) TO '{edges_path}' (FORMAT PARQUET)"
        )
        logger.info("Exported to %s", directory)

    @classmethod
    def load(
        cls,
        database_path: str | Path,
        *,
        index_edges: bool = False,
        **kwargs: Any,
    ) -> "Network":
        """Load Network from a DuckDB database file.

        Args:
            database_path: Path to the .duckdb database file.
            index_edges: If ``True``, create B-tree indexes on
                ``source_node`` and ``target_node`` after loading
                (equivalent to calling :meth:`build_indexes`).
                Indexes already present in the file are preserved
                regardless of this flag.
            **kwargs: Additional arguments passed to DuckDB connection.

        Returns:
            Network: Network instance loaded from database.

        Examples:
            >>> network = Network.load("my_network.duckdb")
            >>> network = Network.load("my_network.duckdb", index_edges=True)
        """
        database_path = Path(database_path)
        if not database_path.exists():
            raise FileNotFoundError(f"Database file not found: {database_path}")

        network = cls(database=str(database_path), **kwargs)
        if index_edges:
            network.build_indexes()
        return network
