# ruff: noqa: E402,F841,INP001,PLR2004,S101
"""Test suite for the package."""

from __future__ import annotations

# use agg backend so you don't need a display on CI
# do this first before pyplot is imported by anything
import matplotlib as mpl

mpl.use("Agg")

import bz2
import gzip
import logging as lg
import os
import tempfile
from collections import OrderedDict
from pathlib import Path

import geopandas as gpd
import networkx as nx
import numpy as np
import pandas as pd
import pytest
from lxml import etree
from requests.exceptions import ConnectionError as RequestsConnectionError
from shapely import Point
from shapely import Polygon
from shapely import wkt
from typeguard import suppress_type_checks

import osmnx as ox

ox.settings.log_console = True
ox.settings.log_file = True
ox.settings.use_cache = True
ox.settings.data_folder = ".temp/data"
ox.settings.logs_folder = ".temp/logs"
ox.settings.imgs_folder = ".temp/imgs"
ox.settings.cache_folder = ".temp/cache"

# define queries to use throughout tests
location_point = (37.791427, -122.410018)
address = "Transamerica Pyramid, 600 Montgomery Street, San Francisco, California, USA"
place1 = {"city": "Piedmont", "state": "California", "country": "USA"}
polygon_wkt = (
    "POLYGON ((-122.262 37.869, -122.255 37.869, -122.255 37.874, "
    "-122.262 37.874, -122.262 37.869))"
)
polygon = ox.utils_geo.buffer_geometry(geom=wkt.loads(polygon_wkt), dist=1)


@pytest.mark.xdist_group(name="group1")
def test_logging() -> None:
    """Test the logger."""
    ox.utils.log("test a fake default message")
    ox.utils.log("test a fake debug", level=lg.DEBUG)
    ox.utils.log("test a fake info", level=lg.INFO)
    ox.utils.log("test a fake warning", level=lg.WARNING)
    ox.utils.log("test a fake error", level=lg.ERROR)

    ox.utils.citation(style="apa")
    ox.utils.citation(style="bibtex")
    ox.utils.citation(style="ieee")
    ox.utils.ts(style="iso8601")
    ox.utils.ts(style="date")
    ox.utils.ts(style="time")


@pytest.mark.xdist_group(name="group1")
def test_exceptions() -> None:
    """Test the custom errors."""
    message = "testing exception"

    with pytest.raises(ox._errors.ResponseStatusCodeError):
        raise ox._errors.ResponseStatusCodeError(message)

    with pytest.raises(ox._errors.CacheOnlyInterruptError):
        raise ox._errors.CacheOnlyInterruptError(message)

    with pytest.raises(ox._errors.InsufficientResponseError):
        raise ox._errors.InsufficientResponseError(message)

    with pytest.raises(ox._errors.GraphSimplificationError):
        raise ox._errors.GraphSimplificationError(message)


@pytest.mark.xdist_group(name="group1")
def test_geocoder() -> None:
    """Test retrieving elements by place name and OSM ID."""
    city = ox.geocode_to_gdf("R2999176", by_osmid=True)
    city = ox.geocode_to_gdf(place1, which_result=1)
    city_projected = ox.projection.project_gdf(city, to_crs="epsg:3395")

    # test geocoding a bad query: should raise exception
    with pytest.raises(ox._errors.InsufficientResponseError):
        _ = ox.geocode("!@#$%^&*")

    with pytest.raises(ox._errors.InsufficientResponseError):
        _ = ox.geocode_to_gdf(query="AAAZZZ")

    # fails to geocode to a (Multi)Polygon
    with pytest.raises(TypeError):
        _ = ox.geocode_to_gdf("Civic Center, San Francisco, California, USA")


@pytest.mark.xdist_group(name="group1")
def test_stats() -> None:
    """Test generating graph stats."""
    # create graph, add a new node, add bearings, project it
    G = ox.graph_from_place(place1, network_type="all")
    G.add_node(0, x=location_point[1], y=location_point[0], street_count=0)
    G_proj = ox.project_graph(G)
    G_proj = ox.distance.add_edge_lengths(G_proj, edges=tuple(G_proj.edges)[0:3])

    # calculate stats
    cspn = ox.stats.count_streets_per_node(G)
    stats = ox.basic_stats(G)
    stats = ox.basic_stats(G, area=1000)
    stats = ox.basic_stats(G_proj, area=1000, clean_int_tol=15)

    # test cleaning and rebuilding graph
    G_clean = ox.consolidate_intersections(G_proj, tolerance=10, rebuild_graph=True, dead_ends=True)
    G_clean = ox.consolidate_intersections(
        G_proj,
        tolerance=10,
        rebuild_graph=True,
        reconnect_edges=False,
    )
    G_clean = ox.consolidate_intersections(G_proj, tolerance=10, rebuild_graph=False)
    G_clean = ox.consolidate_intersections(G_proj, tolerance=50000, rebuild_graph=True)

    # try consolidating an empty graph
    G = nx.MultiDiGraph(crs="epsg:4326")
    G_clean = ox.consolidate_intersections(G, rebuild_graph=True)
    G_clean = ox.consolidate_intersections(G, rebuild_graph=False)

    # test passing dict of tolerances to consolidate_intersections
    tols: dict[int, float]
    # every node present
    tols = dict.fromkeys(G_proj.nodes, 5)
    G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True)
    # one node missing
    tols.popitem()
    G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True)
    # one node 0
    tols[next(iter(tols))] = 0
    G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True)


@pytest.mark.xdist_group(name="group1")
def test_bearings() -> None:
    """Test bearings and orientation entropy."""
    G = ox.graph_from_place(place1, network_type="all")
    G.add_node(0, x=location_point[1], y=location_point[0], street_count=0)
    _ = ox.bearing.calculate_bearing(0, 0, 1, 1)
    G = ox.add_edge_bearings(G)
    G_proj = ox.project_graph(G)

    # calculate entropy
    Gu = ox.convert.to_undirected(G)
    entropy = ox.bearing.orientation_entropy(Gu, weight="length")
    fig, ax = ox.plot.plot_orientation(Gu, area=True, title="Title")
    fig, ax = ox.plot.plot_orientation(Gu, ax=ax, area=False, title="Title")

    # test support of edge bearings for directed and undirected graphs
    G = nx.MultiDiGraph(crs="epsg:4326")
    G.add_node("point_1", x=0.0, y=0.0)
    G.add_node("point_2", x=0.0, y=1.0)  # latitude increases northward
    G.add_edge("point_1", "point_2", weight=2.0)
    G = ox.distance.add_edge_lengths(G)
    G = ox.add_edge_bearings(G)
    with pytest.warns(UserWarning, match="edge bearings will be directional"):
        bearings, weights = ox.bearing._extract_edge_bearings(G, min_length=0, weight=None)
    assert list(bearings) == [0.0]  # north
    assert list(weights) == [1.0]
    bearings, weights = ox.bearing._extract_edge_bearings(
        ox.convert.to_undirected(G),
        min_length=0,
        weight="weight",
    )
    assert list(bearings) == [0.0, 180.0]  # north and south
    assert list(weights) == [2.0, 2.0]

    # test _bearings_distribution split bin implementation
    bin_counts, bin_centers = ox.bearing._bearings_distribution(
        G,
        num_bins=1,
        min_length=0,
        weight=None,
    )
    assert list(bin_counts) == [1.0]
    assert list(bin_centers) == [0.0]
    bin_counts, bin_centers = ox.bearing._bearings_distribution(
        G,
        num_bins=2,
        min_length=0,
        weight=None,
    )
    assert list(bin_counts) == [1.0, 0.0]
    assert list(bin_centers) == [0.0, 180.0]


@pytest.mark.xdist_group(name="group1")
def test_osm_xml() -> None:
    """Test working with .osm XML data."""
    # test loading a graph from a local .osm xml (and bz2 and gzip) file
    node_id = 53098262
    neighbor_ids = 53092170, 53060438, 53027353, 667744075

    # read the contents of the bzip2 file
    path_bz2 = "tests/input_data/West-Oakland.osm.bz2"
    with bz2.open(path_bz2, mode="rb") as f:
        file_contents = f.read()

    # write the contents to a .osm file
    path_osm_temp = path_bz2.strip(".bz2")
    with Path(path_osm_temp).open("wb") as f:
        f.write(file_contents)

    # write the contents to a gzip file
    path_gz_temp = path_osm_temp + ".gz"
    with gzip.open(path_gz_temp, mode="wb") as f:
        f.write(file_contents)

    # load and test graph_from_xml across the .osm, .bz2, and .gz files
    for filepath in (path_bz2, path_gz_temp, path_osm_temp):
        G = ox.graph_from_xml(filepath)
        assert node_id in G.nodes

        for neighbor_id in neighbor_ids:
            edge_key = (node_id, neighbor_id, 0)
            assert neighbor_id in G.nodes
            assert edge_key in G.edges
            assert G.edges[edge_key]["name"] in {"8th Street", "Willow Street"}

    # delete the temporary .osm and .gz files
    Path.unlink(Path(path_osm_temp))
    Path.unlink(Path(path_gz_temp))

    # test OSM xml saving
    G = ox.graph_from_point(location_point, dist=500, network_type="drive", simplify=False)
    fp = Path(ox.settings.data_folder) / "graph.osm"
    ox.io.save_graph_xml(G, filepath=fp, way_tag_aggs={"lanes": "sum"})

    # validate saved XML against XSD schema
    xsd_filepath = "./tests/input_data/osm_schema.xsd"
    parser = etree.XMLParser(schema=etree.XMLSchema(file=xsd_filepath))
    _ = etree.parse(fp, parser=parser)

    # test roundabout handling
    default_all_oneway = ox.settings.all_oneway
    ox.settings.all_oneway = True
    default_overpass_settings = ox.settings.overpass_settings
    ox.settings.overpass_settings += '[date:"2023-04-01T00:00:00Z"]'
    point = (39.0290346, -84.4696884)
    G = ox.graph_from_point(point, dist=500, dist_type="bbox", network_type="drive", simplify=False)
    ox.io.save_graph_xml(G)
    _ = etree.parse(fp, parser=parser)

    # raise error if trying to save a simplified graph
    with pytest.raises(ox._errors.GraphSimplificationError):
        ox.io.save_graph_xml(ox.simplification.simplify_graph(G))

    # save a projected/consolidated graph as OSM XML
    Gc = ox.simplification.consolidate_intersections(ox.projection.project_graph(G))
    nx.set_node_attributes(Gc, 0, name="uid")
    ox.io.save_graph_xml(Gc, fp)  # issues UserWarning
    Gc = ox.graph.graph_from_xml(fp)  # issues UserWarning
    _ = etree.parse(fp, parser=parser)

    # restore settings
    ox.settings.overpass_settings = default_overpass_settings
    ox.settings.all_oneway = default_all_oneway


@pytest.mark.xdist_group(name="group1")
def test_elevation() -> None:
    """Test working with elevation data."""
    G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike")

    # add node elevations from Google (fails without API key)
    with pytest.raises(ox._errors.InsufficientResponseError):
        _ = ox.elevation.add_node_elevations_google(G, api_key="", batch_size=350)

    # add node elevations from Open Topo Data (works without API key)
    ox.settings.elevation_url_template = (
        "https://api.opentopodata.org/v1/aster30m?locations={locations}&key={key}"
    )
    _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=1)

    # same thing again, to hit the cache
    _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=0)

    # add node elevations from a single raster file (some nodes will be null)
    rasters = list(Path("tests/input_data").glob("elevation*.tif"))
    G = ox.elevation.add_node_elevations_raster(G, rasters[0], cpus=1)
    assert pd.notna(pd.Series(dict(G.nodes(data="elevation")))).any()

    # add node elevations from multiple raster files (no nodes should be null)
    G = ox.elevation.add_node_elevations_raster(G, rasters)
    assert pd.notna(pd.Series(dict(G.nodes(data="elevation")))).all()

    # consolidate nodes with elevation (by default will aggregate via mean)
    G = ox.simplification.consolidate_intersections(G)

    # add edge grades and their absolute values
    G = ox.add_edge_grades(G, add_absolute=True)


@pytest.mark.xdist_group(name="group1")
def test_routing() -> None:
    """Test working with speed, travel time, and routing."""
    G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike")

    # give each edge speed and travel time attributes
    G = ox.add_edge_speeds(G)
    G = ox.add_edge_speeds(G, hwy_speeds={"motorway": 100})
    G = ox.add_edge_travel_times(G)

    # test value cleaning
    assert ox.routing._clean_maxspeed("100,2") == 100.2
    assert ox.routing._clean_maxspeed("100.2") == 100.2
    assert ox.routing._clean_maxspeed("100 km/h") == 100.0
    assert ox.routing._clean_maxspeed("100 mph") == pytest.approx(160.934)
    assert ox.routing._clean_maxspeed("60|100") == 80
    assert ox.routing._clean_maxspeed("60|100 mph") == pytest.approx(128.7472)
    assert ox.routing._clean_maxspeed("signal") is None
    assert ox.routing._clean_maxspeed("100;70") is None
    assert ox.routing._clean_maxspeed("FR:urban") == 50.0

    # test collapsing multiple mph values to single kph value
    assert ox.routing._collapse_multiple_maxspeed_values(["25 mph", "30 mph"], np.mean) == 44.25685

    # test collapsing invalid values: should return None
    assert ox.routing._collapse_multiple_maxspeed_values(["mph", "kph"], np.mean) is None

    orig_x = np.array([-122.404771])
    dest_x = np.array([-122.401429])
    orig_y = np.array([37.794302])
    dest_y = np.array([37.794987])
    orig_node = int(ox.distance.nearest_nodes(G, orig_x, orig_y)[0])
    dest_node = int(ox.distance.nearest_nodes(G, dest_x, dest_y)[0])

    # test non-numeric weight, should raise ValueError
    with pytest.raises(ValueError, match="contains non-numeric values"):
        route1 = ox.shortest_path(G, orig_node, dest_node, weight="highway")

    # mismatch iterable and non-iterable orig/dest, should raise TypeError
    msg = "must either both be iterable or neither must be iterable"
    with pytest.raises(TypeError, match=msg):
        route2 = ox.shortest_path(G, orig_node, [dest_node])  # type: ignore[call-overload]

    # mismatch lengths of orig/dest, should raise ValueError
    msg = "must be of equal length"
    with pytest.raises(ValueError, match=msg):
        route2 = ox.shortest_path(G, [orig_node] * 2, [dest_node] * 3)

    # test missing weight (should raise warning)
    route3 = ox.shortest_path(G, orig_node, dest_node, weight="time")
    # test good weight
    route4 = ox.routing.shortest_path(G, orig_node, dest_node, weight="travel_time")
    route5 = ox.shortest_path(G, orig_node, dest_node, weight="travel_time")
    assert route5 is not None

    route_edges = ox.routing.route_to_gdf(G, route5, weight="travel_time")

    fig, ax = ox.plot_graph_route(G, route5, save=True)

    # test multiple origins-destinations
    n = 5
    nodes = np.array(G.nodes)
    origs = [int(x) for x in np.random.default_rng().choice(nodes, size=n, replace=True)]
    dests = [int(x) for x in np.random.default_rng().choice(nodes, size=n, replace=True)]
    paths1 = ox.shortest_path(G, origs, dests, weight="length", cpus=1)
    paths2 = ox.shortest_path(G, origs, dests, weight="length", cpus=2)
    paths3 = ox.shortest_path(G, origs, dests, weight="length", cpus=None)
    assert paths1 == paths2 == paths3

    # test k shortest paths
    routes = ox.routing.k_shortest_paths(G, orig_node, dest_node, k=2, weight="travel_time")
    fig, ax = ox.plot_graph_routes(G, list(routes))

    # test great circle and euclidean distance calculators
    assert ox.distance.great_circle(0, 0, 1, 1) == pytest.approx(157249.6034105)
    assert ox.distance.euclidean(0, 0, 1, 1) == pytest.approx(1.4142135)


@pytest.mark.xdist_group(name="group1")
def test_plots() -> None:
    """Test visualization methods."""
    G = ox.graph_from_point(location_point, dist=500, network_type="drive")
    Gp = ox.project_graph(G)
    G = ox.project_graph(G, to_latlong=True)

    # test getting colors
    co1 = ox.plot.get_colors(n=5, cmap="plasma", start=0.1, stop=0.9, alpha=0.5)
    co2 = ox.plot.get_colors(n=5, cmap="plasma", start=0.1, stop=0.9, alpha=None)
    nc = ox.plot.get_node_colors_by_attr(G, "x")
    ec = ox.plot.get_edge_colors_by_attr(G, "length", num_bins=5)

    # plot and save to disk
    filepath = Path(ox.settings.data_folder) / "test.svg"
    fig, ax = ox.plot_graph(G, show=False, save=True, close=True, filepath=filepath)
    fig, ax = ox.plot_graph(Gp, edge_linewidth=0, figsize=(5, 5), bgcolor="y")
    fig, ax = ox.plot_graph(
        Gp,
        ax=ax,
        dpi=180,
        node_color="k",
        node_size=5,
        node_alpha=0.1,
        node_edgecolor="b",
        node_zorder=5,
        edge_color="r",
        edge_linewidth=2,
        edge_alpha=0.1,
        show=False,
        save=True,
        close=True,
    )

    # figure-ground plots
    fig, ax = ox.plot_figure_ground(G=G)


@pytest.mark.xdist_group(name="group1")
def test_nearest() -> None:
    """Test nearest node/edge searching."""
    # get graph and x/y coords to search
    G = ox.graph_from_point(location_point, dist=500, network_type="drive", simplify=False)
    Gp = ox.project_graph(G)
    points = ox.utils_geo.sample_points(ox.convert.to_undirected(Gp), 5)
    X = points.x.to_numpy()
    Y = points.y.to_numpy()

    # get nearest nodes
    _ = ox.distance.nearest_nodes(G, X, Y, return_dist=True)
    _ = ox.distance.nearest_nodes(G, X, Y, return_dist=False)
    nn0, dist0 = ox.distance.nearest_nodes(G, X[0], Y[0], return_dist=True)
    nn1 = ox.distance.nearest_nodes(Gp, X[0], Y[0], return_dist=False)

    # get nearest edge
    _ = ox.distance.nearest_edges(Gp, X, Y, return_dist=False)
    _ = ox.distance.nearest_edges(Gp, X, Y, return_dist=True)
    _ = ox.distance.nearest_edges(Gp, X[0], Y[0], return_dist=False)
    _ = ox.distance.nearest_edges(Gp, X[0], Y[0], return_dist=True)


@pytest.mark.xdist_group(name="group1")
def test_endpoints() -> None:
    """Test different API endpoints."""
    default_requests_timeout = ox.settings.requests_timeout
    default_key = ox.settings.nominatim_key
    default_nominatim_url = ox.settings.nominatim_url
    default_overpass_url = ox.settings.overpass_url
    default_overpass_rate_limit = ox.settings.overpass_rate_limit

    # test good and bad DNS resolution
    ox.settings.requests_timeout = 1
    ip = ox._http._resolve_host_via_doh("overpass-api.de")
    ip = ox._http._resolve_host_via_doh("AAAAAAAAAAA")
    _doh_url_template_default = ox.settings.doh_url_template
    ox.settings.doh_url_template = "http://aaaaaa.hostdoesntexist.org/nothinguseful"
    ip = ox._http._resolve_host_via_doh("overpass-api.de")
    ox.settings.doh_url_template = None
    ip = ox._http._resolve_host_via_doh("overpass-api.de")
    ox.settings.doh_url_template = _doh_url_template_default

    # Test changing the Overpass endpoint.
    # This should fail because we didn't provide a valid endpoint
    ox.settings.overpass_rate_limit = False
    ox.settings.overpass_url = "http://NOT_A_VALID_ENDPOINT/api/"
    with pytest.raises(RequestsConnectionError, match="Max retries exceeded with url"):
        G = ox.graph_from_place(place1, network_type="all")

    ox.settings.overpass_rate_limit = default_overpass_rate_limit
    ox.settings.requests_timeout = default_requests_timeout

    params: OrderedDict[str, int | str] = OrderedDict()
    params["format"] = "json"
    params["address_details"] = 0

    # Bad Address - should return an empty response
    params["q"] = "AAAAAAAAAAA"
    response_json = ox._nominatim._nominatim_request(params=params, request_type="search")

    # Good Address - should return a valid response with a valid osm_id
    params["q"] = "Newcastle A186 Westgate Rd"
    response_json = ox._nominatim._nominatim_request(params=params, request_type="search")

    # Lookup
    params = OrderedDict()
    params["format"] = "json"
    params["address_details"] = 0
    params["osm_ids"] = "W68876073"

    # good call
    response_json = ox._nominatim._nominatim_request(params=params, request_type="lookup")

    # bad call
    with pytest.raises(
        ox._errors.InsufficientResponseError,
        match="Nominatim API did not return a list of results",
    ):
        response_json = ox._nominatim._nominatim_request(params=params, request_type="search")

    # query must be a str if by_osmid=True
    with pytest.raises(TypeError, match="`query` must be a string if `by_osmid` is True"):
        ox.geocode_to_gdf(query={"City": "Boston"}, by_osmid=True)

    # Invalid nominatim query type
    with pytest.raises(ValueError, match="Nominatim `request_type` must be"):
        response_json = ox._nominatim._nominatim_request(params=params, request_type="xyz")

    # Searching on public nominatim should work even if a (bad) key was provided
    ox.settings.nominatim_key = "NOT_A_KEY"
    response_json = ox._nominatim._nominatim_request(params=params, request_type="lookup")

    ox.settings.nominatim_key = default_key
    ox.settings.nominatim_url = default_nominatim_url
    ox.settings.overpass_url = default_overpass_url


@pytest.mark.xdist_group(name="group1")
def test_save_load() -> None:  # noqa: PLR0915
    """Test saving/loading graphs to/from disk."""
    G = ox.graph_from_point(location_point, dist=500, network_type="drive")

    # save/load geopackage and convert graph to/from node/edge GeoDataFrames
    ox.save_graph_geopackage(G, directed=False)
    fp = ".temp/data/graph-dir.gpkg"
    ox.save_graph_geopackage(G, filepath=fp, directed=True)
    gdf_nodes1 = gpd.read_file(fp, layer="nodes").set_index("osmid")
    gdf_edges1 = gpd.read_file(fp, layer="edges").set_index(["u", "v", "key"])
    G2 = ox.graph_from_gdfs(gdf_nodes1, gdf_edges1)
    G2 = ox.graph_from_gdfs(gdf_nodes1, gdf_edges1, graph_attrs=G.graph)
    gdf_nodes2, gdf_edges2 = ox.graph_to_gdfs(G2)
    _ = list(ox.utils_geo.interpolate_points(gdf_edges2["geometry"].iloc[0], 0.001))
    assert set(gdf_nodes1.index) == set(gdf_nodes2.index) == set(G.nodes) == set(G2.nodes)
    assert set(gdf_edges1.index) == set(gdf_edges2.index) == set(G.edges) == set(G2.edges)

    # test code branches that should raise exceptions
    with pytest.raises(ValueError, match="You must request nodes or edges or both"):
        ox.graph_to_gdfs(G2, nodes=False, edges=False)
    with pytest.raises(ValueError, match="Invalid literal for boolean"):
        ox.io._convert_bool_string("T")

    # create random boolean graph/node/edge attributes
    attr_name = "test_bool"
    G.graph[attr_name] = False
    bools = np.random.default_rng().integers(low=0, high=2, size=len(G.nodes))
    node_attrs = {n: bool(b) for n, b in zip(G.nodes, bools)}
    nx.set_node_attributes(G, node_attrs, attr_name)
    bools = np.random.default_rng().integers(low=0, high=2, size=len(G.edges))
    edge_attrs = {n: bool(b) for n, b in zip(G.edges, bools)}
    nx.set_edge_attributes(G, edge_attrs, attr_name)

    # create list, set, and dict attributes for nodes and edges
    rand_ints_nodes = np.random.default_rng().integers(low=0, high=10, size=len(G.nodes))
    rand_ints_edges = np.random.default_rng().integers(low=0, high=10, size=len(G.edges))
    list_node_attrs = {n: [n, int(r)] for n, r in zip(G.nodes, rand_ints_nodes)}
    nx.set_node_attributes(G, list_node_attrs, "test_list")
    list_edge_attrs = {e: [e, int(r)] for e, r in zip(G.edges, rand_ints_edges)}
    nx.set_edge_attributes(G, list_edge_attrs, "test_list")
    set_node_attrs = {n: {n, int(r)} for n, r in zip(G.nodes, rand_ints_nodes)}
    nx.set_node_attributes(G, set_node_attrs, "test_set")
    set_edge_attrs = {e: {e, int(r)} for e, r in zip(G.edges, rand_ints_edges)}
    nx.set_edge_attributes(G, set_edge_attrs, "test_set")
    dict_node_attrs = {n: {n: int(r)} for n, r in zip(G.nodes, rand_ints_nodes)}
    nx.set_node_attributes(G, dict_node_attrs, "test_dict")
    dict_edge_attrs = {e: {e: int(r)} for e, r in zip(G.edges, rand_ints_edges)}
    nx.set_edge_attributes(G, dict_edge_attrs, "test_dict")

    # save/load graph as graphml file
    ox.save_graphml(G, gephi=True)
    ox.save_graphml(G, gephi=False)
    ox.save_graphml(G, gephi=False, filepath=fp)
    G2 = ox.load_graphml(
        fp,
        graph_dtypes={attr_name: ox.io._convert_bool_string},
        node_dtypes={attr_name: ox.io._convert_bool_string},
        edge_dtypes={attr_name: ox.io._convert_bool_string},
    )

    # verify everything in G is equivalent in G2
    assert tuple(G.graph.keys()) == tuple(G2.graph.keys())
    assert tuple(G.graph.values()) == tuple(G2.graph.values())
    z = zip(G.nodes(data=True), G2.nodes(data=True))
    for (n1, d1), (n2, d2) in z:
        assert n1 == n2
        assert tuple(d1.keys()) == tuple(d2.keys())
        assert tuple(d1.values()) == tuple(d2.values())
    z = zip(G.edges(keys=True, data=True), G2.edges(keys=True, data=True))
    for (u1, v1, k1, d1), (u2, v2, k2, d2) in z:
        assert u1 == u2
        assert v1 == v2
        assert k1 == k2
        assert tuple(d1.keys()) == tuple(d2.keys())
        assert tuple(d1.values()) == tuple(d2.values())

    # test custom data types
    nd = {"osmid": str}
    ed = {"length": str, "osmid": float}
    G2 = ox.load_graphml(fp, node_dtypes=nd, edge_dtypes=ed)

    # test loading graphml from a file stream
    graphml = Path("tests/input_data/short.graphml").read_text(encoding="utf-8")
    G = ox.load_graphml(graphml_str=graphml, node_dtypes=nd, edge_dtypes=ed)


@pytest.mark.xdist_group(name="group2")
def test_graph_from() -> None:
    """Test downloading graphs from Overpass."""
    # test subdividing a large geometry (raises a UserWarning)
    bbox = ox.utils_geo.bbox_from_point((0, 0), dist=1e5, project_utm=True)
    poly = ox.utils_geo.bbox_to_poly(bbox)
    _ = ox.utils_geo._consolidate_subdivide_geometry(poly)

    # graph from bounding box
    _ = ox.utils_geo.bbox_from_point(location_point, dist=1000, project_utm=True, return_crs=True)
    bbox = ox.utils_geo.bbox_from_point(location_point, dist=500)
    G = ox.graph_from_bbox(bbox, network_type="drive")
    G = ox.graph_from_bbox(bbox, network_type="drive_service", truncate_by_edge=True)

    # truncate graph by bounding box
    bbox = ox.utils_geo.bbox_from_point(location_point, dist=400)
    G = ox.truncate.truncate_graph_bbox(G, bbox)
    G = ox.truncate.largest_component(G, strongly=True)

    # graph from address
    G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike")

    # graph from list of places
    G = ox.graph_from_place([place1], which_result=[None], network_type="all")

    # graph from polygon
    G = ox.graph_from_polygon(polygon, network_type="walk", truncate_by_edge=True, simplify=False)
    G = ox.simplify_graph(
        G,
        node_attrs_include=["junction", "ref"],
        edge_attrs_differ=["osmid"],
        remove_rings=False,
        track_merged=True,
    )

    # test custom query filter
    cf = (
        '["highway"]'
        '["area"!~"yes"]'
        '["highway"!~"motor|proposed|construction|abandoned|platform|raceway"]'
        '["foot"!~"no"]'
        '["service"!~"private"]'
        '["access"!~"private"]'
    )
    G = ox.graph_from_point(
        location_point,
        dist=500,
        custom_filter=cf,
        dist_type="bbox",
        network_type="all_public",
    )

    # test union of multiple custom filters
    cf_union = ['["highway"~"tertiary"]', '["railway"~"tram"]']
    G = ox.graph_from_point(location_point, dist=500, custom_filter=cf_union, retain_all=True)

    ox.settings.overpass_memory = 1073741824
    G = ox.graph_from_point(
        location_point,
        dist=500,
        dist_type="network",
        network_type="all",
    )


@pytest.mark.xdist_group(name="group3")
def test_features() -> None:
    """Test downloading features from Overpass."""
    bbox = ox.utils_geo.bbox_from_point(location_point, dist=500)
    tags1: dict[str, bool | str | list[str]] = {"landuse": True, "building": True, "highway": True}

    with pytest.raises(ValueError, match="The geometry of `polygon` is invalid."):
        ox.features.features_from_polygon(Polygon(((0, 0), (0, 0), (0, 0), (0, 0))), tags={})
    with suppress_type_checks(), pytest.raises(TypeError):
        ox.features.features_from_polygon(Point(0, 0), tags={})

    # test cache_only_mode
    ox.settings.cache_only_mode = True
    with pytest.raises(ox._errors.CacheOnlyInterruptError, match="Interrupted because"):
        _ = ox.features_from_bbox(bbox, tags=tags1)
    ox.settings.cache_only_mode = False

    # features_from_bbox - bounding box query to return no data
    with pytest.raises(ox._errors.InsufficientResponseError):
        gdf = ox.features_from_bbox(bbox=(-2.001, -2.001, -2.000, -2.000), tags={"building": True})

    # features_from_bbox - successful
    gdf = ox.features_from_bbox(bbox, tags=tags1)
    fig, ax = ox.plot_footprints(gdf)
    fig, ax = ox.plot_footprints(gdf, ax=ax, bbox=(0, 0, 10, 10))

    # features_from_point - tests multipolygon creation
    gdf = ox.utils_geo.bbox_from_point(location_point, dist=500)

    # features_from_place - includes test of list of places
    tags2: dict[str, bool | str | list[str]] = {
        "amenity": True,
        "landuse": ["retail", "commercial"],
        "highway": "bus_stop",
    }
    gdf = ox.features_from_place(place1, tags=tags2)
    gdf = ox.features_from_place([place1], which_result=[None], tags=tags2)

    # features_from_polygon
    polygon = ox.geocode_to_gdf(place1).geometry.iloc[0]
    ox.features_from_polygon(polygon, tags2)

    # features_from_address - includes testing overpass settings and snapshot from 2019
    ox.settings.overpass_settings = '[out:json][timeout:200][date:"2019-10-28T19:20:00Z"]'
    gdf = ox.features_from_address(address, tags=tags2, dist=1000)

    # features_from_xml - tests error handling of clipped XMLs with incomplete geometry
    gdf = ox.features_from_xml("tests/input_data/planet_10.068,48.135_10.071,48.137.osm")

    # test loading a geodataframe from a local .osm xml file
    with bz2.BZ2File("tests/input_data/West-Oakland.osm.bz2") as f:
        handle, temp_filename = tempfile.mkstemp(suffix=".osm")
        os.write(handle, f.read())
        os.close(handle)
    for filename in ("tests/input_data/West-Oakland.osm.bz2", temp_filename):
        gdf = ox.features_from_xml(filename)
        assert "Willow Street" in gdf["name"].to_numpy()
    Path.unlink(Path(temp_filename))

    # test the "island within a hole" and "touching inner rings" use cases
    # https://wiki.openstreetmap.org/wiki/Relation:multipolygon#Island_within_a_hole
    # https://wiki.openstreetmap.org/wiki/Relation:multipolygon#Touching_inner_rings
    outer1 = Polygon(((0, 0), (4, 0), (4, 4), (0, 4)))
    inner1 = Polygon(((1, 1), (2, 1), (2, 3), (1, 3)))
    inner2 = Polygon(((2, 1), (3, 1), (3, 3), (2, 3)))
    outer2 = Polygon(((1.5, 1.5), (2.5, 1.5), (2.5, 2.5), (1.5, 2.5)))
    outer_polygons = [outer1, outer2]
    inner_polygons = [inner1, inner2]
    result = ox.features._remove_polygon_holes(outer_polygons, inner_polygons)
    geom_wkt = (
        "MULTIPOLYGON (((4 4, 4 0, 0 0, 0 4, 4 4), "
        "(3 1, 3 3, 2 3, 1 3, 1 1, 2 1, 3 1)), "
        "((2.5 2.5, 2.5 1.5, 1.5 1.5, 1.5 2.5, 2.5 2.5)))"
    )
    assert result.equals(wkt.loads(geom_wkt))
