import math
import re
from collections import defaultdict
from itertools import chain, combinations

import numpy as np
import pandas as pd
from pyproj import CRS, Transformer
from scipy.spatial import cKDTree
from scipy.spatial.distance import cdist
from shapely import LineString, MultiLineString, Point, line_merge
from shapely.ops import substring

from iduedu.constants.highway_enums import HighwayType
from iduedu.modules.overpass.overpass_downloaders import fetch_member_tags

PLATFORM_ROLES = ["platform_entry_only", "platform", "platform_exit_only"]
STOPS_ROLES = ["stop", "stop_exit_only", "stop_entry_only"]
THRESHOLD_METERS = 100


def parse_maxspeed_to_m_per_min(raw: str | int | float | None) -> float | None:

    if raw is None:
        return None

    s = str(raw).strip().lower()
    if not s:
        return None

    try:
        v = float(s)
        return v * 1000.0 / 60.0
    except ValueError:
        pass

    # "50 km/h" / "50kph" / "50kmh"
    m = re.match(r"^(\d+(?:\.\d+)?)\s*(km/h|kmh|kph)$", s)
    if m:
        v = float(m.group(1))
        return v * 1000.0 / 60.0

    # mph
    m = re.match(r"^(\d+(?:\.\d+)?)\s*(mph)$", s)
    if m:
        v = float(m.group(1))
        # 1 mph ≈ 1.60934 km/h
        v_kmh = v * 1.60934
        return v_kmh * 1000.0 / 60.0

    return None


def overpass_routes_to_df(json_routes: list[dict], enable_subway_details: bool) -> pd.DataFrame:
    def _compute_way_speed(tags: dict) -> float:
        highway = tags.get("highway")
        if not highway:
            return None, None
        maxspeed_tag = tags.get("maxspeed")
        speed = parse_maxspeed_to_m_per_min(maxspeed_tag)
        if speed is None:
            try:
                ht = HighwayType(highway)
                speed = ht.max_speed
            except ValueError:
                speed = HighwayType.UNCLASSIFIED.max_speed
        return speed

    if not json_routes:
        empty = pd.DataFrame()
        for col in ("is_stop_area", "is_stop_area_group", "is_station"):
            empty[col] = pd.Series(dtype=bool)
        return empty

    for e in json_routes:
        tags = e.get("tags") or {}
        etype = e.get("type")

        route_type = tags.get("route")
        e["transport_type"] = route_type

        if etype == "way" and "highway" in tags:
            e["is_way_data"] = True
            speed = _compute_way_speed(tags)
            e["way_speed_m_per_min"] = speed

        # if route_type in ["bus", "tram", "trolley"]:
        #     members = e.get("members") or []
        #     ways_refs = set([mem.get("ref") for mem in members])
        #     e["ways_refs"] = ways_refs

        if enable_subway_details:
            is_stop_area = etype == "relation" and tags.get("public_transport") == "stop_area"
            is_stop_area_group = (
                etype == "relation"
                and tags.get("public_transport") == "stop_area_group"
                and tags.get("type") == "public_transport"
            )
            is_station = tags.get("public_transport") == "station"

            e["is_stop_area"] = is_stop_area
            e["is_stop_area_group"] = is_stop_area_group
            e["is_station"] = is_station

            if is_stop_area or is_stop_area_group or is_station:
                e["transport_type"] = "subway"

    data = pd.DataFrame(json_routes)

    for col in ("is_stop_area", "is_stop_area_group", "is_station", "is_way_data"):
        if col not in data.columns:
            data[col] = False
        else:
            data[col] = data[col].astype("boolean").fillna(False).astype(bool)

    return data


def transform_geometry(loc, transformer):
    if isinstance(loc["geometry"], float):
        return transformer.transform(loc["lon"], loc["lat"])
    p = LineString([transformer.transform(coords["lon"], coords["lat"]) for coords in loc["geometry"]]).centroid
    return p.x, p.y


def process_roles(route, roles, transformer):
    filtered = route[route["role"].isin(roles)]
    return filtered.apply(transform_geometry, transformer=transformer, axis=1).tolist(), filtered["ref"].tolist()


def side_left_or_right(point, path):
    # 1 if left 0 if right для определения с какой стороны от линии точки
    dist = path.project(point)

    d1 = dist - 1 if dist - 1 > 0 else 0
    d2 = dist + 1 if dist + 1 < path.length else path.length
    line = substring(path, d1, d2)
    x1, y1 = line.coords[0]
    x2, y2 = line.coords[-1]
    x, y = point.coords[0]

    cross_product = (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1)
    if cross_product > 0:
        return 1
    return 0


def offset_point(point, path_line, direction, distance=7) -> tuple[float, float]:
    # для размещения платформы по одну сторону от пути на расстоянии
    dist = path_line.project(point)
    d1 = dist - 1 if dist - 1 > 0 else 0
    d2 = dist + 1.1 if dist + 1.1 < path_line.length else path_line.length
    nearest_pt_on_line = path_line.interpolate(dist)
    line = substring(path_line, d1, d2)

    x1, y1 = line.coords[0]
    x2, y2 = line.coords[-1]

    dx, dy = x2 - x1, y2 - y1
    length = math.sqrt(dx**2 + dy**2)
    dx, dy = dx / length, dy / length

    if direction == 0:  # Вправо
        nx, ny = dy, -dx
    else:  # Влево
        nx, ny = -dy, dx

    # Смещенная точка
    offset_x = nearest_pt_on_line.x + nx * distance
    offset_y = nearest_pt_on_line.y + ny * distance
    return offset_x, offset_y


def extract_needed(loc_obj: pd.Series, keys: list[str]) -> dict | None:  # pragma: no cover
    out = {}
    tags = loc_obj.get("tags", {}) if isinstance(loc_obj.get("tags"), dict) else {}
    for k in keys:
        value_found = None
        if "." in k:
            # dotted path
            parts = k.split(".")
            cur = loc_obj
            ok = True
            for part in parts:
                if isinstance(cur, dict) and part in cur:
                    cur = cur[part]
                else:
                    ok = False
                    break
            if ok:
                value_found = cur
        else:
            if k in tags:
                value_found = tags[k]
            elif k in loc_obj:
                value_found = loc_obj[k]
        if value_found is not None:
            out[k] = value_found
    return out or None


def _link_unconnected(disconnected_ways) -> dict:  # pragma: no cover
    """
    disconnected_ways: list[dict] where each dict is:
      {
        "coords": list[tuple[float,float]],   # vertices
        "speeds": list[float] | None,         # optional
      }

    returns dict: {"coords": ..., "speeds": ...}
      - speeds is None if input had no speeds
    """
    ways = [w for w in disconnected_ways if w.get("coords")]
    if not ways:
        return {"coords": [], "speeds": None}

    any_has_speeds = any(("speeds" in w) and (w["speeds"] is not None) for w in ways)

    if any_has_speeds:
        for w in ways:
            if ("speeds" not in w) or (w["speeds"] is None):
                raise ValueError("Mixed ways: some have speeds and some don't")

    if len(ways) == 1:
        return {
            "coords": ways[0]["coords"][:],
            "speeds": (ways[0]["speeds"][:] if any_has_speeds else None),
        }

    connect_points = []
    for w in ways:
        coords = w["coords"]
        connect_points += [coords[0], coords[-1]]

    distances = cdist(connect_points, connect_points)
    n = distances.shape[0]

    # mask same-line endpoints
    mask = (np.arange(n)[:, None] // 2) == (np.arange(n) // 2)
    distances[mask] = np.inf

    def relative_point(point: int):
        rel_point = point // 2 * 2
        return rel_point if rel_point != point else rel_point + 1

    # pick first best connection
    first_con_1, first_con_2 = np.unravel_index(np.argmin(distances), distances.shape)

    # forbid using chosen points again
    distances[first_con_1, :] = np.inf
    distances[first_con_2, :] = np.inf
    distances[:, first_con_2] = np.inf
    distances[:, first_con_1] = np.inf

    first_con_1_rel, first_con_2_rel = [relative_point(x) for x in (first_con_1, first_con_2)]
    distances[first_con_1_rel, first_con_2_rel] = np.inf
    distances[first_con_2_rel, first_con_1_rel] = np.inf
    distances[:, first_con_1_rel] = np.inf
    distances[:, first_con_2_rel] = np.inf

    line1, line2 = first_con_1 // 2, first_con_2 // 2
    w1, w2 = ways[line1], ways[line2]

    if first_con_1 % 2 == 1:
        connected_coords = w1["coords"][:]
        connected_speeds = w1["speeds"][:] if any_has_speeds else None
    else:
        connected_coords = w1["coords"][::-1]
        connected_speeds = w1["speeds"][::-1] if any_has_speeds else None

    if first_con_2 % 2 == 0:
        coords2 = w2["coords"][:]
        speeds2 = w2["speeds"][:] if any_has_speeds else None
    else:
        coords2 = w2["coords"][::-1]
        speeds2 = w2["speeds"][::-1] if any_has_speeds else None

    connected_coords += coords2
    if any_has_speeds:
        connected_speeds += speeds2

    extreme_points = [first_con_1_rel, first_con_2_rel]

    for _ in range(len(ways) - 2):
        position, ind = np.unravel_index(np.argmin(distances[extreme_points]), (2, n))
        next_con = (extreme_points[position], ind)
        rel_point = relative_point(ind)

        w = ways[ind // 2]

        if position == 0:
            if ind % 2 == 1:
                coords = w["coords"][:]
                speeds = w["speeds"][:] if any_has_speeds else None
            else:
                coords = w["coords"][::-1]
                speeds = w["speeds"][::-1] if any_has_speeds else None

            if coords and connected_coords and coords[-1] == connected_coords[0]:
                coords = coords[:-1]
            connected_coords = coords + connected_coords
            if any_has_speeds:
                connected_speeds = speeds + connected_speeds

            extreme_points = [rel_point, extreme_points[1]]
        else:
            if ind % 2 == 0:
                coords = w["coords"][:]
                speeds = w["speeds"][:] if any_has_speeds else None
            else:
                coords = w["coords"][::-1]
                speeds = w["speeds"][::-1] if any_has_speeds else None

            connected_coords = connected_coords + coords
            if any_has_speeds:
                connected_speeds = connected_speeds + speeds

            extreme_points = [extreme_points[0], rel_point]

        distances[:, rel_point] = np.inf
        distances[next_con[0], :] = np.inf
        distances[next_con[1], :] = np.inf
        distances[:, next_con[0]] = np.inf
        distances[:, next_con[1]] = np.inf
        rel_point_2 = relative_point(next_con[0])
        distances[rel_point, rel_point_2] = np.inf
        distances[rel_point_2, rel_point] = np.inf

    return {"coords": connected_coords, "speeds": (connected_speeds if any_has_speeds else None)}


def _find_stop_platform_pairs(platforms, stops, platforms_refs, stops_refs):
    items = []  # [(platform_coords, platform_ref, stop_coords, stop_ref), на выходе не может быть пары без платформы

    platform_len, stops_len = len(platforms), len(stops)
    matched_p, matched_s = set(), set()
    pairs = {}  # p_idx -> s_idx

    if platform_len and stops_len:

        stop_tree = cKDTree(stops)
        k = min(2, stops_len)
        dists, idxs = stop_tree.query(platforms, k=k)

        dists = np.asarray(dists)
        idxs = np.asarray(idxs)
        if dists.ndim == 1:  # это случается, когда k == 1
            dists = dists[:, None]
            idxs = idxs[:, None]

        candidates = []
        for p_i in range(platform_len):
            for rank in range(dists.shape[1]):
                dist = float(dists[p_i, rank])
                if not np.isfinite(dist) or dist > THRESHOLD_METERS:
                    continue
                s_i = int(idxs[p_i, rank])
                candidates.append((dist, p_i, s_i))

        candidates.sort(key=lambda t: t[0])
        for dist, p_i, s_i in candidates:
            if p_i not in matched_p and s_i not in matched_s:
                pairs[p_i] = s_i
                matched_p.add(p_i)
                matched_s.add(s_i)

    for p_i, s_i in pairs.items():
        items.append(
            {
                "p": platforms[p_i],
                "pref": platforms_refs[p_i],
                "s": stops[s_i],
                "sref": stops_refs[s_i],
            }
        )
    return items, matched_p, matched_s


def _speed_on_interval(a: float, b: float, cumdist: np.ndarray, seg_speeds: np.ndarray) -> float:

    if b < a:
        a, b = b, a
    if b <= a:
        return float(np.nan)

    i0 = int(np.searchsorted(cumdist, a, side="right") - 1)
    i1 = int(np.searchsorted(cumdist, b, side="left"))

    i0 = max(i0, 0)
    i1 = min(i1, len(seg_speeds))

    total_len = 0.0
    acc = 0.0

    for i in range(i0, i1):
        if i < 0 or i >= len(seg_speeds):
            continue
        seg_a = cumdist[i]
        seg_b = cumdist[i + 1]
        overlap_a = max(a, seg_a)
        overlap_b = min(b, seg_b)
        ol = overlap_b - overlap_a
        if ol > 0:
            v = seg_speeds[i]
            acc += ol * v
            total_len += ol
    return float(acc / total_len) if total_len > 0 else float(np.nan)


def overpass_ground_transport2edgenode(
    loc: pd.Series,
    local_crs,
    ref2speed,
    needed_tags,
) -> tuple[pd.DataFrame, pd.DataFrame]:

    nodes_data: list[dict] = []
    edges_data: list[dict] = []

    def add_node(node_id, x, y, node_type):
        x = round(x, 5)
        y = round(y, 5)
        nodes_data.append(
            {
                "node_id": node_id,
                "point": (x, y),
                "route": transport_name,
                "type": node_type,
            }
        )

    def add_edge(u, v, edge_type, geometry=None, extra_data=None, speed_m_min=None):
        payload = {
            "u": u,
            "v": v,
            "route": transport_name,
            "type": edge_type,
        }
        if geometry is not None:
            payload["geometry"] = LineString([(round(x, 5), round(y, 5)) for x, y in geometry.coords])
        if extra_data is not None:
            payload["extra_data"] = extra_data
        if speed is not None:
            payload["speed_m_min"] = speed_m_min
        edges_data.append(payload)

    transformer = Transformer.from_crs("EPSG:4326", local_crs, always_xy=True)
    tags = loc.get("tags", {}) if isinstance(loc.get("tags"), dict) else {}
    loc_id = loc.name
    transport_type = loc.transport_type
    transport_name = f"Unnamed_{loc_id}"
    if "ref" in tags:
        transport_name = tags["ref"]
    elif "name" in tags:
        transport_name = tags["name"]

    members = loc.get("members", [])
    route = pd.DataFrame(members) if isinstance(members, list) else pd.DataFrame()

    for col in ("geometry", "lat", "lon", "role", "type"):
        if col not in route.columns:
            route[col] = np.nan

    route = route.dropna(subset=["lat", "lon", "geometry"], how="all")
    platforms, platforms_refs = process_roles(route, PLATFORM_ROLES, transformer)
    stops, stops_refs = process_roles(route, STOPS_ROLES, transformer)

    ways_df = route[(route["type"] == "way") & (route["role"].fillna("").isin(["", "forward", "backward"]))][
        ["ref", "geometry"]
    ]
    if len(ways_df) == 0:
        return pd.DataFrame(), pd.DataFrame()

    ways_df["speed"] = ways_df["ref"].map(ref2speed)

    ways = []
    for _, r in ways_df.reset_index(drop=True).iterrows():
        g = r["geometry"]
        coords = [transformer.transform(pt["lon"], pt["lat"]) for pt in g]
        ways.append((coords, float(r["speed"])))

    connected_ways = [{"coords": [], "speeds": []}]
    cur_way = 0

    for coords, speed in ways:  # pragma: no cover
        if not coords:
            continue

        dst = connected_ways[cur_way]

        if not dst["coords"]:
            dst["coords"] = coords
            dst["speeds"] = [speed] * len(coords)
            continue

        if coords[0] == coords[-1]:
            continue

        # хвост текущего == голова нового: dst += coords[1:]
        if dst["coords"][-1] == coords[0]:
            dst["coords"] += coords[1:]
            dst["speeds"] += [speed] * (len(coords) - 1)
        # хвост текущего == хвост нового: dst += coords[::-1][1:]
        elif dst["coords"][-1] == coords[-1]:
            rcoords = coords[::-1]
            dst["coords"] += rcoords[1:]
            dst["speeds"] += [speed] * (len(coords) - 1)
        # голова текущего == голова нового: coords[::-1] + dst[1:]
        elif dst["coords"][0] == coords[0]:
            new_coords = coords[::-1] + dst["coords"][1:]
            new_speeds = ([speed] * (len(coords) - 1)) + dst["speeds"]
            connected_ways[cur_way] = {"coords": new_coords, "speeds": new_speeds}

        # голова текущего == хвост нового: coords + dst[1:]
        elif dst["coords"][0] == coords[-1]:
            new_coords = coords + dst["coords"][1:]
            new_speeds = ([speed] * (len(coords) - 1)) + dst["speeds"]
            connected_ways[cur_way] = {"coords": new_coords, "speeds": new_speeds}

        # нет соединяющей точки — новый компонент
        else:
            connected_ways.append({"coords": coords, "speeds": [speed] * len(coords)})
            cur_way += 1

    for w in connected_ways:
        if w["coords"] and len(w["speeds"]) != len(w["coords"]):
            raise ValueError(f"speeds/coords mismatch: {len(w['speeds'])} vs {len(w['coords'])}")

    if len(connected_ways) > 1:
        # удаляем все круговые движения (замкнутые линии)
        to_del = [
            i
            for i, w in enumerate(connected_ways)
            if w.get("coords") and len(w["coords"]) >= 2 and w["coords"][0] == w["coords"][-1]
        ]

        # Если кол-во удалений == кол-во путей, надо оставить хотя бы самый большой
        if to_del and len(to_del) == len(connected_ways):
            # найдём индекс самого длинного пути среди замкнутых
            longest_index = max(to_del, key=lambda i: len(connected_ways[i].get("coords", [])))
            # удалим все, кроме самого длинного
            to_del.remove(longest_index)

        connected_ways = [w for j, w in enumerate(connected_ways) if j not in to_del]

    if len(connected_ways) > 1:
        cw = _link_unconnected(connected_ways)
    else:
        cw = connected_ways[0]
        if not cw.get("coords") or len(cw["coords"]) < 2:
            raise Exception("No connected ways")

    extra = extract_needed(loc, needed_tags)

    path_coords = cw["coords"]
    seg_speeds = cw["speeds"][:-1]

    path = LineString(path_coords)

    cumdist = [0.0]
    for i in range(len(path_coords) - 1):
        dx = path_coords[i + 1][0] - path_coords[i][0]
        dy = path_coords[i + 1][1] - path_coords[i][1]
        cumdist.append(cumdist[-1] + (dx * dx + dy * dy) ** 0.5)
    cumdist = np.asarray(cumdist)
    seg_speeds = np.asarray(seg_speeds, dtype=float)

    platform_len, stops_len = len(platforms), len(stops)

    stop_plat_pairs, matched_p, matched_s = _find_stop_platform_pairs(platforms, stops, platforms_refs, stops_refs)

    if stops_len:
        base_dir = side_left_or_right(Point(platforms[platform_len // 2]), path) if platform_len else 1
        for s_i in range(stops_len):
            if s_i in matched_s:
                continue
            s_pt = Point(stops[s_i])
            stop_plat_pairs.append(
                {
                    "p": offset_point(s_pt, path, base_dir),
                    "pref": f"from_{stops_refs[s_i]}",  # new platform
                    "s": s_pt.xy,
                    "sref": stops_refs[s_i],
                }
            )

    for p_i in range(platform_len):
        if p_i in matched_p:
            continue
        stop_plat_pairs.append(
            {
                "p": platforms[p_i],
                "pref": platforms_refs[p_i],
                "s": None,
                "sref": f"from_{platforms_refs[p_i]}",
            }
        )

    if not stop_plat_pairs:  # pragma: no cover
        stop_plat_pairs.append(
            {
                "p": offset_point(path.interpolate(0), path, 1, 7),
                "pref": f"{loc_id}_0",
                "s": None,
                "sref": f"from_{loc_id}_0",
            }
        )
        stop_plat_pairs.append(
            {
                "p": offset_point(path.interpolate(path.length), path, 1, 7),
                "pref": f"{loc_id}_1",
                "s": None,
                "sref": f"from_{loc_id}_1",
            }
        )

    stop_plat_pairs.sort(key=lambda d: path.project(Point(d["p"])))

    last_dist = None
    last_projected_stop_id = None

    for item in stop_plat_pairs:
        plat_xy = item["p"]
        plat_ref = item.get("pref")

        stop_xy = item["s"]
        if stop_xy is None:
            stop_xy = plat_xy
        stop_ref = item.get("sref")

        platform = Point(plat_xy)
        stop = Point(stop_xy)

        stop_dist = path.project(stop)

        projected_stop = path.interpolate(stop_dist)

        # Платформы лежат нереалистично далеко от спроецированных остановок, ошибка в данных осм
        if projected_stop.distance(platform) > 100:
            continue

        add_node(node_id=stop_ref, x=projected_stop.x, y=projected_stop.y, node_type=transport_type)

        if last_dist is not None:
            seg = substring(path, last_dist, stop_dist)
            if isinstance(seg, Point):
                seg = LineString((seg, seg))
            v_avg = _speed_on_interval(last_dist, stop_dist, cumdist, seg_speeds)

            add_edge(
                u=last_projected_stop_id,
                v=stop_ref,
                edge_type=transport_type,
                geometry=seg,
                extra_data=extra,
                speed_m_min=v_avg,
            )

        last_projected_stop_id = stop_ref
        last_dist = stop_dist

        add_node(node_id=plat_ref, x=platform.x, y=platform.y, node_type="platform")
        # boarding_geom = LineString(
        #     [(round(projected_stop.x, 5), round(projected_stop.y, 5)), (round(platform.x, 5), round(platform.y, 5))]
        # )
        add_edge(u=plat_ref, v=stop_ref, edge_type="boarding", geometry=None)
        add_edge(u=stop_ref, v=plat_ref, edge_type="boarding", geometry=None)

    nodes_df = pd.DataFrame(nodes_data).drop_duplicates(subset=["node_id"])
    edges_df = pd.DataFrame(edges_data)
    return edges_df, nodes_df


def overpass_subway2edgenode(subway_data: pd.DataFrame, local_crs):

    nodes_data: list[dict] = []
    edges_data: list[dict] = []

    transformer = Transformer.from_crs("EPSG:4326", local_crs, always_xy=True)

    transport_type = "subway"

    for column in ["is_stop_area", "is_stop_area_group", "is_station"]:
        subway_data[column] = subway_data[column].astype("boolean").fillna(False)
    routes_data = subway_data[
        ~((subway_data["is_stop_area"]) | (subway_data["is_stop_area_group"]) | (subway_data["is_station"]))
    ].copy()
    stop_areas = subway_data[subway_data["is_stop_area"]].copy()
    stop_areas_group = subway_data[subway_data["is_stop_area_group"]].copy()
    stations_data = subway_data[subway_data["is_station"]].copy()

    def add_node(node_id, x, y, node_type):
        x = round(x, 5)
        y = round(y, 5)
        nodes_data.append(
            {
                "node_id": node_id,
                "point": (x, y),
                "route": transport_name,
                "type": node_type,
            }
        )

    def add_edge(u, v, edge_type, geometry=None):
        payload = {
            "u": u,
            "v": v,
            "route": transport_name,
            "type": edge_type,
            # "extra_data": extra_data,
        }
        if geometry is not None:
            payload["geometry"] = LineString([(round(x, 5), round(y, 5)) for x, y in geometry.coords])
        edges_data.append(payload)

    add_edges, add_nodes = parse_overpass_subway_data(
        stop_areas, stop_areas_group, stations_data, local_crs
    )  # Входы/выходы, пересадки и тд
    add_edges.rename(columns={"u_ref": "u", "v_ref": "v"}, inplace=True)
    add_nodes.rename(columns={"ref_id": "node_id"}, inplace=True)

    for _, row in routes_data.iterrows():

        loc_id = row.name
        tags = row.get("tags", {})
        tags = tags if isinstance(tags, dict) else {}
        transport_name = f"Unnamed_{loc_id}"
        if "ref" in tags:
            transport_name = tags["ref"]
        elif "name" in tags:
            transport_name = tags["name"]
        members = row.get("members", [])
        if not isinstance(members, list):
            continue
        members = pd.DataFrame(members)
        for col in ("geometry", "lat", "lon", "role", "type"):
            if col not in members.columns:
                members[col] = np.nan

        members = members.dropna(subset=["lat", "lon", "geometry"], how="all")

        platforms, platforms_refs = process_roles(members, PLATFORM_ROLES, transformer)
        stops, stops_refs = process_roles(members, STOPS_ROLES, transformer)

        ways_df = members[(members["type"] == "way") & (members["role"].fillna("").isin(["", "forward", "backward"]))]

        lines = MultiLineString(
            [LineString([transformer.transform(p["lon"], p["lat"]) for p in pts]) for pts in ways_df["geometry"].values]
        )
        merged_lines = line_merge(lines, directed=True)

        if not isinstance(merged_lines, LineString):
            separated_lines = [line for line in merged_lines.geoms if isinstance(line, LineString)]
            cw = _link_unconnected([{"coords": list(ls.coords)} for ls in separated_lines])
            merged_lines = LineString(cw["coords"])

        path = merged_lines

        platform_len, stops_len = len(platforms), len(stops)

        stop_plat_pairs, matched_p, matched_s = _find_stop_platform_pairs(platforms, stops, platforms_refs, stops_refs)

        if stops_len:
            base_dir = side_left_or_right(Point(platforms[platform_len // 2]), path) if platform_len else 1
            for s_i in range(stops_len):  # Проверка остановок
                if s_i in matched_s:
                    continue
                # Добавляем пару с остановкой но без платформы
                add_edges_from_stop = add_edges[add_edges["u"] == stops_refs[s_i]]
                s_pt = Point(stops[s_i])
                p_pt = offset_point(s_pt, path, base_dir)  # new platform
                p_ref = f"from_{stops_refs[s_i]}"  # new platform

                if len(add_edges_from_stop) > 0:
                    potential_nodes = add_nodes[add_nodes["node_id"].isin(add_edges_from_stop["v"])]
                    for _, row in potential_nodes.iterrows():
                        if row.type == "subway_platform":
                            p_pt = row.point if not pd.isna(row.point) else p_pt
                            p_ref = row.node_id
                            break

                stop_plat_pairs.append(
                    {
                        "p": p_pt,
                        "pref": p_ref,
                        "s": stops[s_i],
                        "sref": stops_refs[s_i],
                    }
                )

        for p_i in range(platform_len):
            if p_i in matched_p:
                continue
            # Добавляем пару с платформой у которой нету остановки
            stop_plat_pairs.append(
                {
                    "p": platforms[p_i],
                    "pref": platforms_refs[p_i],
                    "s": None,
                    "sref": f"from_{platforms_refs[p_i]}",
                }
            )

        if not stop_plat_pairs:  # pragma: no cover
            stop_plat_pairs.append(
                {
                    "p": offset_point(path.interpolate(0), path, 1, 7),
                    "pref": f"{loc_id}_0",
                    "s": None,
                    "sref": f"from_{loc_id}_0",
                }
            )
            stop_plat_pairs.append(
                {
                    "p": offset_point(path.interpolate(path.length), path, 1, 7),
                    "pref": f"{loc_id}_1",
                    "s": None,
                    "sref": f"from_{loc_id}_1",
                }
            )

        stop_plat_pairs.sort(key=lambda d: path.project(Point(d["p"])))

        last_dist = None
        last_projected_stop_id = None

        for item in stop_plat_pairs:
            plat_xy = item["p"]
            plat_ref = item.get("pref")

            stop_xy = item["s"]
            if stop_xy is None:
                stop_xy = plat_xy
            stop_ref = item.get("sref")

            platform = Point(plat_xy)
            stop = Point(stop_xy)

            stop_dist = path.project(stop)

            projected_stop = path.interpolate(stop_dist)

            # Платформы лежат нереалистично далеко от спроецированных остановок, ошибка в данных осм
            if projected_stop.distance(platform) > 100:
                continue

            add_node(node_id=stop_ref, x=projected_stop.x, y=projected_stop.y, node_type=transport_type)

            if last_dist is not None:
                seg = substring(path, last_dist, stop_dist)
                if isinstance(seg, Point):
                    seg = LineString((seg, seg))
                add_edge(u=last_projected_stop_id, v=stop_ref, edge_type=transport_type, geometry=seg)

            last_projected_stop_id = stop_ref
            last_dist = stop_dist

            add_node(node_id=plat_ref, x=platform.x, y=platform.y, node_type="subway_platform")
            add_edge(u=plat_ref, v=stop_ref, edge_type="boarding", geometry=None)
            add_edge(u=stop_ref, v=plat_ref, edge_type="boarding", geometry=None)

    if len(edges_data) == 0:
        return pd.DataFrame(), pd.DataFrame()
    nodes_df = pd.DataFrame(nodes_data).drop_duplicates(subset=["node_id"])

    merged_nodes = pd.merge(add_nodes, nodes_df, how="outer", on="node_id")

    base_cols = {c[:-2] for c in merged_nodes.columns if c.endswith("_x")}
    for base in base_cols:
        col_x = f"{base}_x"
        col_y = f"{base}_y"

        merged_nodes[base] = merged_nodes[col_x].combine_first(merged_nodes[col_y])
        merged_nodes.drop(columns=[col_x, col_y], inplace=True)

    edges_df = pd.DataFrame(edges_data)
    merged_edges = pd.merge(add_edges, edges_df, how="outer", on=["u", "v"])
    base_cols = {c[:-2] for c in merged_edges.columns if c.endswith("_x")}
    for base in base_cols:
        col_x = f"{base}_x"
        col_y = f"{base}_y"

        merged_edges[base] = merged_edges[col_x].combine_first(merged_edges[col_y])
        merged_edges.drop(columns=[col_x, col_y], inplace=True)

    platforms_ids = merged_nodes[merged_nodes["type"] == "subway_platform"]["node_id"].unique()
    platforms_with_station = merged_edges[
        (merged_edges["u"].isin(platforms_ids)) & (merged_edges["type"] != "boarding")
    ]["u"].unique()

    platforms_wout_station = (
        pd.Index(platforms_ids.astype(str)).difference(platforms_with_station.astype(str)).to_numpy()
    )

    if len(platforms_wout_station) > 0:
        merged_nodes.loc[merged_nodes["node_id"].astype(str).isin(platforms_wout_station), "type"] = "platform"

    return merged_edges, merged_nodes


def infer_role_from_tags(tags: dict) -> str:
    _TRUE = {"yes", "true", "1"}

    def _is_true(v):
        return str(v).lower() in _TRUE

    if not tags:
        return ""

    pt = (tags.get("public_transport", "")).lower()
    rail = (tags.get("railway", "")).lower()
    stat = (tags.get("station", "")).lower()
    entr = (tags.get("entrance", "")).lower()
    entry = (tags.get("entry", "")).lower()
    exit_ = (tags.get("exit", "")).lower()

    # station
    if pt == "station" or rail == "station" or stat == "subway":
        return "station"

    # platform
    if pt == "platform" or rail == "platform":
        return "platform"

    # stop / stop_position
    if pt in {"stop_position", "stop"} or rail in {"stop", "halt"}:
        return "stop"

    # entrances
    if rail == "subway_entrance" or entr in {"yes", "main", "service", "entrance", "entry", "exit"}:
        if entr == "entry" or (_is_true(entry) and not _is_true(exit_)):
            return "entry_only"
        if entr == "exit" or (_is_true(exit_) and not _is_true(entry)):
            return "exit_only"
        return "entrance"
    return ""


def patch_members_roles_inplace(stop_areas_df):
    missing = []
    for _, r in stop_areas_df.iterrows():
        for m in r.get("members") or []:
            role_missing = (m.get("role") or "").strip() == ""

            has_latlon = "lat" in m and "lon" in m and m["lat"] is not None and m["lon"] is not None
            has_geom = "geometry" in m and m["geometry"]
            geo_missing = (not has_latlon) and (not has_geom)

            if role_missing or geo_missing:
                missing.append({"type": m["type"], "ref": int(m["ref"])})

    if not missing:
        return

    tags_map = fetch_member_tags(missing)

    for _, r in stop_areas_df.iterrows():
        for m in r.get("members") or []:
            key = (m["type"], int(m["ref"]))
            payload = tags_map.get(key, {})

            if (m.get("role") or "").strip() == "":
                tags = payload.get("tags", {}) or {}
                role = infer_role_from_tags(tags)
                if role:
                    m["role"] = role

            # 2) lat/lon
            has_latlon = "lat" in m and "lon" in m and m["lat"] is not None and m["lon"] is not None
            if not has_latlon:
                if "__latlon__" in payload:
                    lon, lat = payload["__latlon__"]
                    m["lon"], m["lat"] = lon, lat
                elif "__center__" in payload:
                    lon, lat = payload["__center__"]
                    m["lon"], m["lat"] = lon, lat

            # 3) geometry
            has_geom = "geometry" in m and m["geometry"]
            if not has_geom and "__geometry__" in payload:
                m["geometry"] = payload["__geometry__"]


def parse_overpass_subway_data(
    stop_areas, stop_areas_group, stations_data, to_crs
) -> tuple[pd.DataFrame, pd.DataFrame] | None:
    graph_nodes = []
    graph_edges = []

    transformer = Transformer.from_crs("EPSG:4326", to_crs, always_xy=True)

    station_parent_ref = {}
    osm_roles_to_iduedu = {
        "station": "subway_station",
        "stop": "subway",
        "platform": "subway_platform",
        "entrance": "subway_entry_exit",
        "subway_entrance": "subway_entry_exit",
        "entrance_yes": "subway_entry_exit",
        "entrance_main": "subway_entry_exit",
        "entry_only": "subway_entry",
        "entrance_entry_only": "subway_entry",
        "entry": "subway_entry",
        "exit_only": "subway_exit",
        "entrance_exit_only": "subway_exit",
        "exit": "subway_exit",
    }

    def _collect_by_roles(members, parent_id):
        roles = defaultdict(list)
        for m in members or []:
            role = (m.get("role") or "").lower()
            roles[role].append(m)

        stations = roles.get("station", [])
        for s in stations:
            station_parent_ref[parent_id] = s["ref"]
        stops = roles.get("stop", [])
        platforms = roles.get("platform", [])

        entrances = (
            roles.get("entrance", [])
            + roles.get("subway_entrance", [])
            + roles.get("entrance_yes", [])
            + roles.get("entrance_main", [])
        )
        entry_only = roles.get("entry_only", []) + roles.get("entrance_entry_only", []) + roles.get("entry", [])
        exit_only = roles.get("exit_only", []) + roles.get("entrance_exit_only", []) + roles.get("exit", [])

        return stations, stops, platforms, entrances, entry_only, exit_only

    def add_node(ref_id, x, y, node_type):

        if x is None or y is None:
            point = None
        else:
            point = transformer.transform(x, y)

        graph_nodes.append(
            {
                "ref_id": int(ref_id),
                "point": point,
                "type": osm_roles_to_iduedu.get(node_type),
            }
        )

    def add_edge(u, v, edge_type):
        graph_edges.append(
            {
                "u_ref": int(u),
                "v_ref": int(v),
                "type": edge_type,
            }
        )

    patch_members_roles_inplace(stop_areas)

    for _, stop_area in stop_areas.iterrows():

        members = stop_area["members"]
        all_nodes = _collect_by_roles(members, stop_area["id"])
        start_idx = len(graph_nodes)

        for node in chain.from_iterable(all_nodes):
            if "geometry" in node:
                new_lon, new_lat = LineString((xy["lon"], xy["lat"]) for xy in node["geometry"]).centroid.xy
                node["lon"], node["lat"] = new_lon[0], new_lat[0]
            if "lon" not in node:
                print(node)
                continue
                node["lon"], node["lat"] = None, None

            add_node(node["ref"], node["lon"], node["lat"], node["role"])

        ref2idx = {graph_nodes[i]["ref_id"]: i for i in range(start_idx, len(graph_nodes))}

        stations, stops, platforms, entrances, entry_only, exit_only = all_nodes

        for stop in stops:
            for platform in platforms:
                add_edge(stop["ref"], platform["ref"], "boarding")
                add_edge(platform["ref"], stop["ref"], "boarding")

        has_station = len(stations) > 0

        if has_station:
            for platform in platforms:
                for station in stations:
                    add_edge(station["ref"], platform["ref"], "subway_station")
                    add_edge(platform["ref"], station["ref"], "subway_station")

        targets = [s["ref"] for s in stations] if has_station else [p["ref"] for p in platforms]

        has_entrance = (len(entrances) + len(entry_only)) > 0
        has_exit = (len(entrances) + len(exit_only)) > 0

        for entrance in entrances:
            for t in targets:
                add_edge(entrance["ref"], t, "subway_entrance")
                add_edge(t, entrance["ref"], "subway_exit")

        for entrance in entry_only:
            for t in targets:
                add_edge(entrance["ref"], t, "subway_entrance")

        for entrance in exit_only:
            for t in targets:
                add_edge(t, entrance["ref"], "subway_exit")

        if not (has_entrance and has_exit):
            for t in targets:
                idx = ref2idx.get(int(t))
                if idx is not None:
                    graph_nodes[idx]["type"] = "platform"

    for _, stop_area_group in stop_areas_group.iterrows():
        members = stop_area_group["members"]

        connect_refs = [station_parent_ref[mem["ref"]] for mem in members if mem["ref"] in station_parent_ref]

        pairs = list(combinations(connect_refs, 2))
        for pair in pairs:
            if pair[0] != pair[1]:
                add_edge(pair[0], pair[1], "subway_transfer")
                add_edge(pair[1], pair[0], "subway_transfer")

    edges_gdf = pd.DataFrame(graph_edges, columns=["u_ref", "v_ref", "type"])
    nodes_gdf = pd.DataFrame(graph_nodes, columns=["ref_id", "point", "type"])

    if len(edges_gdf) == 0:
        return edges_gdf, nodes_gdf

    nodes_gdf["extra_data"] = {}

    for _, station_data in stations_data.iterrows():
        tags = station_data["tags"]
        ref_id = station_data["id"]
        station_ind = nodes_gdf[nodes_gdf["ref_id"] == int(ref_id)].index
        extra_data = {k: v for k, v in tags.items() if k in ["name", "depth"]}
        extra_data["depth"] = extra_data.get("depth", 0)
        nodes_gdf.loc[station_ind, "extra_data"] = [extra_data for _ in station_ind]

    edges_gdf = edges_gdf.drop_duplicates(subset=["u_ref", "v_ref"])
    edges_gdf["length_meter"] = 0.0
    edges_gdf["time_min"] = 0.0
    edges_gdf["geometry"] = None

    def _depth_from_extra(d):
        if isinstance(d, dict):
            v = d.get("depth", 0)
            try:
                return float(v)
            except (TypeError, ValueError):
                return 0.0
        return 0.0

    if len(nodes_gdf) == 0:
        return None
    nodes_info = nodes_gdf[["ref_id", "point", "type", "extra_data"]].copy()
    nodes_info["depth_m"] = nodes_info["extra_data"].apply(_depth_from_extra)

    nodes_lut = nodes_info.set_index("ref_id")[["point", "type", "depth_m"]]

    edges_nodes = (
        edges_gdf.join(nodes_lut.add_prefix("u_"), on="u_ref")
        .join(nodes_lut.add_prefix("v_"), on="v_ref")
        .drop_duplicates(subset=["u_ref", "v_ref"])
    )

    def _horiz_len(pu, pv):
        if pu is None or pv is None:
            return np.nan
        try:
            return LineString([pu, pv]).length
        except Exception:
            return np.nan

    edges_nodes["horiz_m"] = edges_nodes.apply(lambda r: _horiz_len(r["u_point"], r["v_point"]), axis=1)

    edges_nodes["station_depth_m"] = np.where(
        edges_nodes["u_type"].eq("subway_station"),
        edges_nodes["u_depth_m"].fillna(0.0),
        np.where(edges_nodes["v_type"].eq("subway_station"), edges_nodes["v_depth_m"].fillna(0.0), 0.0),
    ).astype(float)

    ESCALATOR_SPEED_MPMIN = 0.75 * 60
    WALK_SPEED_MPMIN = 5 * 1000 / 60
    TRANSFER_PATH_FACTOR = 1.5

    mask_escal = edges_nodes["type"].isin(["subway_entrance", "subway_exit"]) & edges_nodes["horiz_m"].notna()
    edges_nodes.loc[mask_escal, "length_meter"] = np.hypot(
        edges_nodes.loc[mask_escal, "horiz_m"], edges_nodes.loc[mask_escal, "station_depth_m"]
    )
    edges_nodes.loc[mask_escal, "time_min"] = edges_nodes.loc[mask_escal, "length_meter"] / ESCALATOR_SPEED_MPMIN

    def _straight_geom(pu, pv):
        if pu is None or pv is None:
            return None
        try:
            return LineString([pu, pv])
        except Exception:
            return None

    edges_nodes.loc[mask_escal, "geometry"] = edges_nodes.loc[mask_escal].apply(
        lambda r: _straight_geom(r["u_point"], r["v_point"]), axis=1
    )

    mask_transfer = edges_nodes["type"].eq("subway_transfer") & edges_nodes["horiz_m"].notna()
    edges_nodes.loc[mask_transfer, "length_meter"] = TRANSFER_PATH_FACTOR * edges_nodes.loc[
        mask_transfer, "horiz_m"
    ].round(1)
    edges_nodes.loc[mask_transfer, "time_min"] = (
        edges_nodes.loc[mask_transfer, "length_meter"] / WALK_SPEED_MPMIN
    ).round(1)
    edges_nodes.loc[mask_transfer, "geometry"] = edges_nodes.loc[mask_transfer].apply(
        lambda r: _straight_geom(r["u_point"], r["v_point"]), axis=1
    )

    edges_gdf[["length_meter", "time_min", "geometry"]] = edges_nodes[["length_meter", "time_min", "geometry"]]
    return edges_gdf, nodes_gdf
