"""
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 duckdb
import geopandas as gpd
import pandas as pd
from pathlib import Path
from typing import Any, Optional, Tuple, Union
import warnings


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")
    """
    
    def __init__(
        self,
        nodes: Optional[Union[str, Path, gpd.GeoDataFrame]] = None,
        edges: Optional[Union[str, Path, gpd.GeoDataFrame]] = None,
        database: Optional[Union[str, Path]] = 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._node_original_to_int: dict = {}
        self._node_int_to_original: dict = {}
        self._edge_original_to_int: dict = {}
        self._edge_int_to_original: dict = {}

        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.add_nodes(nodes)
            if edges is not None:
                self.add_edges(edges)
    
    def _install_spatial(self):
        """Install and load the DuckDB spatial extension."""
        try:
            self._conn.execute("INSTALL spatial;")
        except Exception:
            # Extension may already be installed
            pass
        
        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,
                attributes JSON
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_edges (
                edge_id BIGINT PRIMARY KEY,
                source_node BIGINT,
                target_node BIGINT,
                geom GEOMETRY,
                attributes JSON,
                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
            )
        """)
        try:
            self._conn.execute("CREATE INDEX IF NOT EXISTS node_geom_idx ON wp_nodes USING GIST(geom)")
        except Exception:
            # Fallback: regular index or skip if not supported
            try:
                self._conn.execute("CREATE SPATIAL INDEX IF NOT EXISTS node_geom_idx ON wp_nodes (geom)")
            except Exception:
                pass
        
        try:
            self._conn.execute("CREATE INDEX IF NOT EXISTS edge_geom_idx ON wp_edges USING GIST(geom)")
        except Exception:
            try:
                self._conn.execute("CREATE SPATIAL INDEX IF NOT EXISTS edge_geom_idx ON wp_edges (geom)")
            except Exception:
                pass
    
    @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) -> pd.DataFrame:
        """Coerce node_id to Int64.

        If the column contains non-numeric strings, builds a translation
        table automatically.
        """
        df = df.copy()
        try:
            df['node_id'] = self._coerce_int64_column(df['node_id'], 'node_id')
        except ValueError:
            df = self._auto_map_node_ids(df)
        return df

    def _auto_map_node_ids(self, df: pd.DataFrame) -> pd.DataFrame:
        """Assign dense integer IDs for string node_id values.

        Persists the mapping in ``wp_node_map``.
        """
        unique_vals = df['node_id'].unique()
        existing = self._node_original_to_int
        next_int = max(existing.values(), default=-1) + 1
        new_map = {}
        for v in unique_vals:
            s = str(v)
            if s not in existing:
                existing[s] = next_int
                new_map[s] = next_int
                next_int += 1
        if new_map:
            map_df = pd.DataFrame(
                [(iid, orig) for orig, iid in new_map.items()],
                columns=['int_id', 'original_id'],
            )
            self._conn.register('_node_map_tmp', map_df)
            self._conn.execute("INSERT INTO wp_node_map SELECT * FROM _node_map_tmp")
            self._node_original_to_int.update(new_map)
            self._node_int_to_original.update({iid: orig for orig, iid in new_map.items()})

        df['node_id'] = df['node_id'].astype(str).map(self._node_original_to_int).astype(pd.Int64Dtype())
        return df

    def _auto_map_edge_ids(self, df: pd.DataFrame) -> pd.DataFrame:
        """Assign dense integer IDs for string edge_id values.

        Persists the mapping in ``wp_edge_map``.
        """
        existing = self._edge_original_to_int
        next_int = max(existing.values(), default=-1) + 1
        new_map = {}
        for v in df['edge_id'].unique():
            s = str(v)
            if s not in existing:
                existing[s] = next_int
                new_map[s] = next_int
                next_int += 1
        if new_map:
            map_df = pd.DataFrame(
                [(iid, orig) for orig, iid in new_map.items()],
                columns=['int_id', 'original_id'],
            )
            self._conn.register('_edge_map_tmp', map_df)
            self._conn.execute("INSERT INTO wp_edge_map SELECT * FROM _edge_map_tmp")
            self._edge_original_to_int.update(new_map)
            self._edge_int_to_original.update({iid: orig for orig, iid in new_map.items()})

        df = df.copy()
        df['edge_id'] = df['edge_id'].astype(str).map(self._edge_original_to_int).astype(pd.Int64Dtype())
        return df

    def add_nodes(self, nodes: Union[str, Path, gpd.GeoDataFrame, pd.DataFrame]):
        """Add nodes to the network.

        Args:
            nodes: Nodes data source.
        """
        if isinstance(nodes, (str, Path)):
            # Load from Parquet
            nodes_gdf = gpd.read_parquet(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 = 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:
            # Create point geometry if coordinates exist
            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:
                # Topology-only: node_id only (no geometry); spatial filters will not match until geometry is added
                nodes_prep_clean = nodes_gdf[['node_id']].copy()
                nodes_prep_clean['geom_wkt'] = None
                nodes_prep_clean['attributes'] = None
                self._conn.register('nodes_temp', nodes_prep_clean)
                self._conn.execute("DELETE FROM wp_nodes")
                self._conn.execute("""
                    INSERT INTO wp_nodes (node_id, geom, attributes)
                    SELECT node_id, NULL, NULL FROM nodes_temp
                """)
                return
        
        # Prepare data for insertion (geometry path)
        nodes_prep = nodes_gdf[['node_id', geom_col]].copy()
        nodes_prep = nodes_prep.rename(columns={geom_col: 'geom'})
        
        # Extract attributes (all other columns)
        attr_cols = [c for c in nodes_gdf.columns if c not in ['node_id', geom_col, 'geometry']]
        if attr_cols:
            import json
            nodes_prep['attributes'] = [
                json.dumps(row) for row in nodes_gdf[attr_cols].to_dict('records')
            ]
        else:
            nodes_prep['attributes'] = None
        
        # Insert into DuckDB
        # Convert geometry to WKT for DuckDB spatial extension
        nodes_prep = nodes_prep.copy()
        if 'geom' in nodes_prep.columns:
            # Convert geometry to WKT string
            nodes_prep['geom_wkt'] = nodes_prep['geom'].apply(
                lambda g: g.wkt if g is not None and hasattr(g, 'wkt') else None
            )
        else:
            nodes_prep['geom_wkt'] = None
        
        # Register temporary table
        nodes_prep_clean = nodes_prep[['node_id', 'geom_wkt', 'attributes']].copy()
        self._conn.register('nodes_temp', nodes_prep_clean)
        self._conn.execute("DELETE FROM wp_nodes")  # Clear existing
        
        # Insert using ST_GeomFromText
        self._conn.execute("""
            INSERT INTO wp_nodes (node_id, geom, attributes)
            SELECT 
                node_id,
                CASE 
                    WHEN geom_wkt IS NOT NULL THEN ST_GeomFromText(geom_wkt)
                    ELSE NULL
                END as geom,
                attributes
            FROM nodes_temp
        """)
    
    def add_edges(self, edges: Union[str, Path, gpd.GeoDataFrame, pd.DataFrame]):
        """Add edges to the network.

        Args:
            edges: Edges data source.
        """
        if isinstance(edges, (str, Path)):
            # Load from Parquet
            edges_gdf = gpd.read_parquet(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 = self._node_original_to_int
            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
                edges_gdf[col] = edges_gdf[col].astype(str).map(self._node_original_to_int)
                edges_gdf[col] = self._coerce_int64_column(edges_gdf[col], col)

        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 = self._auto_map_edge_ids(edges_gdf)
        else:
            edges_gdf['edge_id'] = range(len(edges_gdf))
        
        # Handle geometry column
        if 'geometry' in edges_gdf.columns:
            geom_col = 'geometry'
        elif 'geom' in edges_gdf.columns:
            geom_col = 'geom'
        else:
            # No geometry: store NULL (topology-only edges, e.g. from Network.from_walks)
            geom_col = None
        
        # Prepare data for insertion
        if geom_col:
            edges_prep = edges_gdf[['edge_id', 'source_node', 'target_node', geom_col]].copy()
            edges_prep = edges_prep.rename(columns={geom_col: 'geom'})
        else:
            edges_prep = edges_gdf[['edge_id', 'source_node', 'target_node']].copy()
            edges_prep['geom'] = None
        
        # Extract attributes
        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 attr_cols:
            import json
            edges_prep['attributes'] = [
                json.dumps(row) for row in edges_gdf[attr_cols].to_dict('records')
            ]
        else:
            edges_prep['attributes'] = None
        
        # Insert into DuckDB
        # Convert geometry to WKT for DuckDB spatial extension
        edges_prep = edges_prep.copy()
        if 'geom' in edges_prep.columns:
            edges_prep['geom_wkt'] = edges_prep['geom'].apply(
                lambda g: g.wkt if g is not None and hasattr(g, 'wkt') else None
            )
        else:
            edges_prep['geom_wkt'] = None
        
        edges_prep_clean = pd.DataFrame({
            'edge_id': edges_prep['edge_id'].tolist(),
            'source_node': edges_prep['source_node'].tolist(),
            'target_node': edges_prep['target_node'].tolist(),
            'geom_wkt': edges_prep['geom_wkt'].tolist(),
            'attributes': edges_prep['attributes'].tolist(),
        })
        self._conn.register('edges_temp', edges_prep_clean)
        self._conn.execute("DELETE FROM wp_edges")  # Clear existing
        
        # Insert using ST_GeomFromText
        self._conn.execute("""
            INSERT INTO wp_edges (edge_id, source_node, target_node, geom, attributes)
            SELECT 
                edge_id,
                source_node,
                target_node,
                CASE 
                    WHEN geom_wkt IS NOT NULL THEN ST_GeomFromText(geom_wkt)
                    ELSE NULL
                END as geom,
                attributes
            FROM edges_temp
        """)
    
    @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.add_nodes(nodes_gdf)
        network.add_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")
        
        import json as _json

        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: Optional[Tuple[float, float, float, float]] = None,
        place: Optional[str] = None,
        center_point: Optional[Tuple[float, float]] = None,
        dist: Optional[float] = 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_walks(
        cls,
        walks: Union[str, Path, pd.DataFrame],
        *,
        database: Optional[Union[str, Path]] = 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.add_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.add_edges(edges_df)
            return network
        path = str(walks)
        nodes_df = network._conn.execute(
            "SELECT DISTINCT node_id FROM read_parquet(?)", [path]
        ).df()
        network.add_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.add_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."""
        return bool(self._node_original_to_int)

    def _rebuild_mapping_caches(self):
        """Reload in-memory mapping caches from the DuckDB map tables."""
        rows = self._conn.execute("SELECT int_id, original_id FROM wp_node_map").fetchall()
        self._node_original_to_int = {orig: iid for iid, orig in rows}
        self._node_int_to_original = {iid: orig for iid, orig in rows}
        rows = self._conn.execute("SELECT int_id, original_id FROM wp_edge_map").fetchall()
        self._edge_original_to_int = {orig: iid for iid, orig in rows}
        self._edge_int_to_original = {iid: orig for iid, orig in rows}
    
    def _decode_node_ids(self, df):
        """If a string mapping exists, replace integer node_id with original labels."""
        if self.has_string_mapping and 'node_id' in df.columns:
            df = df.copy()
            df['node_id'] = df['node_id'].map(
                lambda v: self._node_int_to_original.get(int(v), v) if pd.notna(v) else v
            )
        return df

    def _decode_edge_ids(self, df):
        """If a string mapping exists, replace integer edge/source/target with original labels."""
        if not self.has_string_mapping:
            return df
        df = df.copy()
        for col in ('edge_id',):
            if col in df.columns:
                df[col] = df[col].map(
                    lambda v: self._edge_int_to_original.get(int(v), v) if pd.notna(v) else v
                )
        for col in ('source_node', 'target_node'):
            if col in df.columns:
                df[col] = df[col].map(
                    lambda v: self._node_int_to_original.get(int(v), v) if pd.notna(v) else v
                )
        return df

    def get_nodes(self, as_geopandas: bool = True, decode: bool = True) -> Union[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 
                        node_id,
                        ST_AsText(geom) as geom_wkt,
                        attributes
                    FROM wp_nodes
                """).df()

                if len(result) > 0 and result['geom_wkt'].notna().any():
                    from shapely import wkt
                    result['geometry'] = result['geom_wkt'].apply(
                        lambda w: wkt.loads(w) if w and pd.notna(w) else None
                    )
                    result = result.drop(columns=['geom_wkt'])
                    if decode:
                        result = self._decode_node_ids(result)
                    return gpd.GeoDataFrame(result, geometry='geometry')
                else:
                    result = self._conn.execute("SELECT node_id, attributes FROM wp_nodes").df()
                    if decode:
                        result = self._decode_node_ids(result)
                    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 node_id, attributes 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) -> Union[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 
                        edge_id,
                        source_node,
                        target_node,
                        ST_AsText(geom) as geom_wkt,
                        attributes
                    FROM wp_edges
                """).df()

                if len(result) > 0 and result['geom_wkt'].notna().any():
                    from shapely import wkt
                    result['geometry'] = result['geom_wkt'].apply(
                        lambda w: wkt.loads(w) if w and pd.notna(w) else None
                    )
                    result = result.drop(columns=['geom_wkt'])
                    if decode:
                        result = self._decode_edge_ids(result)
                    return gpd.GeoDataFrame(result, geometry='geometry')
                else:
                    result = self._conn.execute("SELECT edge_id, source_node, target_node, attributes FROM wp_edges").df()
                    if decode:
                        result = self._decode_edge_ids(result)
                    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 edge_id, source_node, target_node, attributes 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: Union[str, Path]):
        """Save Network to a DuckDB database file.

        Creates a persistent DuckDB database file containing the network
        topology (nodes and edges tables).

        Args:
            database_path: Path to save the .duckdb database file.

        Examples:
            >>> network = Network(nodes=nodes_gdf, edges=edges_gdf)
            >>> network.save("my_network.duckdb")
        """
        database_path = Path(database_path)
        
        # Create a new connection to the database file
        save_conn = duckdb.connect(str(database_path))
        
        # Install spatial extension
        try:
            save_conn.execute("INSTALL spatial;")
        except Exception:
            pass
        try:
            save_conn.execute("LOAD spatial;")
        except Exception:
            pass
        
        save_conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_nodes (
                node_id BIGINT PRIMARY KEY,
                geom GEOMETRY,
                attributes JSON
            )
        """)
        save_conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_edges (
                edge_id BIGINT PRIMARY KEY,
                source_node BIGINT,
                target_node BIGINT,
                geom GEOMETRY,
                attributes JSON,
                FOREIGN KEY (source_node) REFERENCES wp_nodes(node_id),
                FOREIGN KEY (target_node) REFERENCES wp_nodes(node_id)
            )
        """)
        save_conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_node_map (
                int_id BIGINT PRIMARY KEY,
                original_id VARCHAR NOT NULL
            )
        """)
        save_conn.execute("""
            CREATE TABLE IF NOT EXISTS wp_edge_map (
                int_id BIGINT PRIMARY KEY,
                original_id VARCHAR NOT NULL
            )
        """)

        import tempfile
        import os

        with tempfile.TemporaryDirectory() as temp_dir:
            temp_nodes_path = os.path.join(temp_dir, 'nodes_temp.parquet')
            temp_edges_path = os.path.join(temp_dir, 'edges_temp.parquet')

            self._conn.execute(f"COPY (SELECT * FROM wp_nodes) TO '{temp_nodes_path}' (FORMAT PARQUET)")
            self._conn.execute(f"COPY (SELECT * FROM wp_edges) TO '{temp_edges_path}' (FORMAT PARQUET)")

            save_conn.execute("DELETE FROM wp_edges")
            save_conn.execute("DELETE FROM wp_nodes")
            save_conn.execute(f"COPY wp_nodes FROM '{temp_nodes_path}' (FORMAT PARQUET)")
            save_conn.execute(f"COPY wp_edges FROM '{temp_edges_path}' (FORMAT PARQUET)")

            for map_table in ('wp_node_map', 'wp_edge_map'):
                count = self._conn.execute(f"SELECT count(*) FROM {map_table}").fetchone()[0]
                if count > 0:
                    temp_map = os.path.join(temp_dir, f'{map_table}.parquet')
                    self._conn.execute(f"COPY (SELECT * FROM {map_table}) TO '{temp_map}' (FORMAT PARQUET)")
                    save_conn.execute(f"DELETE FROM {map_table}")
                    save_conn.execute(f"COPY {map_table} FROM '{temp_map}' (FORMAT PARQUET)")

        save_conn.close()
    
    @classmethod
    def load(cls, database_path: Union[str, Path], **kwargs: Any) -> "Network":
        """Load Network from a DuckDB database file.

        Args:
            database_path: Path to the .duckdb database file.
            **kwargs: Additional arguments passed to DuckDB connection.

        Returns:
            Network: Network instance loaded from database.

        Examples:
            >>> network = Network.load("my_network.duckdb")
        """
        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)
        network._rebuild_mapping_caches()
        return network
