from math import atan2, cos, radians, sin, sqrt
from typing import Any

import httpx
import psycopg
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware


# ============================================================
# APP / CONFIG
# ============================================================

app = FastAPI(title="GIS Road Context API", version="3.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # demo only; restrict in production
    allow_credentials=False,
    allow_methods=["GET"],
    allow_headers=["*"],
)

DB_DSN = (
    "postgresql://admin:secret_apidb_pass"
    "@127.0.0.1:54322/osm_apidb"
)

OSRM_URL = "http://127.0.0.1:5000"

# Reuse one HTTP connection pool instead of reconnecting to OSRM each request.
osrm_client = httpx.Client(base_url=OSRM_URL, timeout=5.0)


@app.on_event("shutdown")
def shutdown() -> None:
    osrm_client.close()


# ============================================================
# SMALL HELPERS
# ============================================================

def angular_diff(a: float, b: float) -> float:
    """Smallest absolute difference between two bearings (degrees)."""
    return abs((a - b + 180.0) % 360.0 - 180.0)


def haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
    """Distance in metres, good enough for matching one local junction."""
    r = 6_371_000.0
    p1 = radians(lat1)
    p2 = radians(lat2)
    dp = radians(lat2 - lat1)
    dl = radians(lon2 - lon1)

    a = (
        sin(dp / 2.0) ** 2
        + cos(p1) * cos(p2) * sin(dl / 2.0) ** 2
    )
    return 2.0 * r * atan2(sqrt(a), sqrt(1.0 - a))


# ============================================================
# OSRM: GPS TRACE -> OSM NODE TRAVERSAL
# ============================================================

def osrm_match_nodes(
    prev_lon: float,
    prev_lat: float,
    curr_lon: float,
    curr_lat: float,
) -> dict[str, Any]:
    """
    OSRM remains the routing/map-matching authority.

    We intentionally consume OSM node IDs from annotations instead of
    spatially re-matching the OSRM result back into PostGIS.
    """

    coordinates = f"{prev_lon},{prev_lat};{curr_lon},{curr_lat}"

    try:
        response = osrm_client.get(
            f"/match/v1/driving/{coordinates}",
            params={
                "overview": "false",
                "steps": "false",
                "annotations": "nodes",
            },
        )
        response.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise HTTPException(
            status_code=502,
            detail={
                "message": "OSRM Match HTTP error",
                "status": exc.response.status_code,
                "body": exc.response.text,
            },
        ) from exc
    except httpx.RequestError as exc:
        raise HTTPException(
            status_code=502,
            detail={
                "message": "Cannot connect to OSRM",
                "error": str(exc),
            },
        ) from exc

    data = response.json()

    if data.get("code") != "Ok":
        raise HTTPException(
            status_code=422,
            detail={
                "message": "OSRM map matching failed",
                "osrm": data,
            },
        )

    matchings = data.get("matchings") or []
    if not matchings:
        raise HTTPException(status_code=422, detail="OSRM returned no matching")

    matching = matchings[0]

    nodes: list[int] = []
    for leg in matching.get("legs") or []:
        annotation = leg.get("annotation") or {}
        for node_id in annotation.get("nodes") or []:
            # OSRM can expose 0 for a synthetic/unavailable OSM node.
            if not node_id:
                continue
            if not nodes or nodes[-1] != node_id:
                nodes.append(int(node_id))

    if len(nodes) < 2:
        raise HTTPException(
            status_code=422,
            detail={
                "message": "OSRM returned insufficient OSM nodes",
                "nodes": nodes,
            },
        )

    tracepoints = [p for p in (data.get("tracepoints") or []) if p]
    if not tracepoints:
        raise HTTPException(status_code=422, detail="OSRM returned no tracepoints")

    matched_location = tracepoints[-1].get("location")
    if not matched_location or len(matched_location) != 2:
        raise HTTPException(status_code=422, detail="OSRM returned no matched location")

    return {
        "node_from": nodes[-2],
        "node_to": nodes[-1],
        "nodes": nodes,
        "confidence": matching.get("confidence"),
        "matched_lon": float(matched_location[0]),
        "matched_lat": float(matched_location[1]),
        "gps_distance_m": tracepoints[-1].get("distance"),
    }




# ============================================================
# POSTGIS DEBUG: MAP CLICK -> SYNTHETIC DIRECTED OSM NODE PAIR
# ============================================================

SQL_CLICK_MATCH = r"""
WITH p AS (
    SELECT ST_SetSRID(ST_MakePoint(%(lon)s, %(lat)s), 4326) AS geom
),
nearest_way AS (
    SELECT
        w.id,
        w.tags,
        w.linestring,
        ST_Distance(w.linestring::geography, p.geom::geography) AS distance_m
    FROM ways w
    CROSS JOIN p
    WHERE w.tags ? 'highway'
      AND ST_DWithin(w.linestring::geography, p.geom::geography, 30)
    ORDER BY w.linestring <-> p.geom
    LIMIT 1
),
nearest_segment AS (
    SELECT
        nw.id AS way_id,
        nw.tags,
        nw.distance_m,
        a.node_id AS node_a,
        b.node_id AS node_b,
        a.sequence_id AS seq_a,
        b.sequence_id AS seq_b,
        ST_Distance(
            ST_MakeLine(na.geom, nb.geom)::geography,
            p.geom::geography
        ) AS segment_distance_m
    FROM nearest_way nw
    CROSS JOIN p
    JOIN way_nodes a ON a.way_id = nw.id
    JOIN way_nodes b
      ON b.way_id = nw.id
     AND b.sequence_id = a.sequence_id + 1
    JOIN nodes na ON na.id = a.node_id
    JOIN nodes nb ON nb.id = b.node_id
    ORDER BY segment_distance_m
    LIMIT 1
)
SELECT
    way_id,
    tags->'name' AS name,
    tags->'highway' AS highway,
    tags->'oneway' AS oneway,
    node_a,
    node_b,
    seq_a,
    seq_b,
    distance_m,
    segment_distance_m
FROM nearest_segment;
"""


def click_match(lon: float, lat: float, requested_direction: str | None = None) -> dict[str, Any]:
    """Convert a web-map click into the same node-pair shape used downstream."""
    with psycopg.connect(DB_DSN) as conn:
        with conn.cursor() as cur:
            cur.execute(SQL_CLICK_MATCH, {"lon": lon, "lat": lat})
            row = cur.fetchone()

    if not row:
        raise HTTPException(status_code=404, detail="No highway found within 30 metres")

    (
        way_id,
        name,
        highway,
        oneway,
        node_a,
        node_b,
        seq_a,
        seq_b,
        distance_m,
        segment_distance_m,
    ) = row

    if oneway in ("yes", "1", "true"):
        # OSM Way node order is the legal travel direction.
        node_from = int(node_a)
        node_to = int(node_b)
        direction = "forward"

    elif oneway == "-1":
        # Legal travel direction is opposite to OSM Way node order.
        node_from = int(node_b)
        node_to = int(node_a)
        direction = "reverse"

    else:
        # Two-way road: a single click identifies the segment, but not
        # which way the vehicle is travelling. The web harness may supply it.
        if requested_direction is None:
            return {
                "needs_direction": True,
                "way_id": int(way_id),
                "name": name,
                "highway": highway,
                "oneway": oneway,
                "distance_m": float(distance_m),
                "segment_distance_m": float(segment_distance_m),
                "node_a": int(node_a),
                "node_b": int(node_b),
                "seq_a": int(seq_a),
                "seq_b": int(seq_b),
            }

        if requested_direction == "forward":
            node_from = int(node_a)
            node_to = int(node_b)
            direction = "forward"

        elif requested_direction == "reverse":
            node_from = int(node_b)
            node_to = int(node_a)
            direction = "reverse"

        else:
            raise HTTPException(
                status_code=422,
                detail="direction must be 'forward' or 'reverse'",
            )

    return {
        "needs_direction": False,
        "node_from": node_from,
        "node_to": node_to,
        "nodes": [node_from, node_to],
        "matched_lon": lon,
        "matched_lat": lat,
        "confidence": None,
        "gps_distance_m": None,
        "debug_click": {
            "way_id": int(way_id),
            "name": name,
            "highway": highway,
            "oneway": oneway,
            "direction": direction,
            "distance_m": float(distance_m),
            "segment_distance_m": float(segment_distance_m),
            "seq_a": int(seq_a),
            "seq_b": int(seq_b),
        },
    }


# ============================================================
# POSTGIS: OSM NODE PAIR -> CURRENT WAY + NEXT JUNCTION
# ============================================================

SQL_CONTEXT = r"""
WITH movement AS (
    SELECT
        %(node_from)s::bigint AS node_from,
        %(node_to)s::bigint   AS node_to
),

current_way_candidates AS (
    SELECT
        w.id,
        w.tags,
        w.linestring,
        a.sequence_id AS seq_from,
        b.sequence_id AS seq_to,
        CASE
            WHEN b.sequence_id > a.sequence_id THEN 'forward'
            WHEN b.sequence_id < a.sequence_id THEN 'reverse'
            ELSE 'unknown'
        END AS travel_direction
    FROM movement m
    JOIN way_nodes a
      ON a.node_id = m.node_from
    JOIN way_nodes b
      ON b.node_id = m.node_to
     AND b.way_id = a.way_id
    JOIN ways w
      ON w.id = a.way_id
    WHERE w.tags ? 'highway'
      AND ABS(b.sequence_id - a.sequence_id) = 1
),

current_way AS (
    SELECT *
    FROM current_way_candidates
    WHERE travel_direction <> 'unknown'
    ORDER BY id
    LIMIT 1
),

forward_nodes AS (
    SELECT
        wn.node_id,
        wn.sequence_id,
        n.geom
    FROM current_way cw
    JOIN way_nodes wn
      ON wn.way_id = cw.id
    JOIN nodes n
      ON n.id = wn.node_id
    WHERE
        (cw.travel_direction = 'forward' AND wn.sequence_id >= cw.seq_to)
        OR
        (cw.travel_direction = 'reverse' AND wn.sequence_id <= cw.seq_to)
),

next_junction AS (
    SELECT
        fn.node_id,
        fn.sequence_id,
        fn.geom
    FROM forward_nodes fn
    CROSS JOIN current_way cw
    WHERE EXISTS (
        SELECT 1
        FROM way_nodes wn2
        JOIN ways w2
          ON w2.id = wn2.way_id
        WHERE wn2.node_id = fn.node_id
          AND wn2.way_id <> cw.id
          AND w2.tags ? 'highway'
        LIMIT 1
    )
    ORDER BY
        CASE
            WHEN cw.travel_direction = 'forward' THEN fn.sequence_id
            ELSE -fn.sequence_id
        END
    LIMIT 1
)

SELECT
    cw.id AS current_way,
    cw.tags->'name' AS current_name,
    cw.tags->'highway' AS current_highway,
    cw.travel_direction,
    cw.seq_from,
    cw.seq_to,
    ST_AsGeoJSON(cw.linestring)::jsonb AS current_geometry,

    nj.node_id AS junction_node,
    nj.sequence_id AS junction_seq,
    ST_X(nj.geom) AS junction_lon,
    ST_Y(nj.geom) AS junction_lat

FROM current_way cw
LEFT JOIN next_junction nj ON TRUE;
"""


# ============================================================
# POSTGIS: JUNCTION -> DIRECTED BRANCHES + EXPLANATION METADATA
# ============================================================

SQL_BRANCHES = r"""
WITH params AS (
    SELECT
        %(current_way)s::bigint AS current_way,
        %(junction_node)s::bigint AS junction_node,
        %(junction_seq)s::integer AS junction_seq,
        %(travel_direction)s::text AS travel_direction
),

junction_way_positions AS (
    SELECT
        wn.way_id,
        wn.sequence_id AS junction_seq
    FROM params p
    JOIN way_nodes wn
      ON wn.node_id = p.junction_node
    JOIN ways w
      ON w.id = wn.way_id
    WHERE w.tags ? 'highway'
),

branches AS (
    -- branch towards sequence - 1
    SELECT
        jwp.way_id,
        jwp.junction_seq - 1 AS branch_seq,
        prev_wn.node_id AS branch_node
    FROM junction_way_positions jwp
    JOIN way_nodes prev_wn
      ON prev_wn.way_id = jwp.way_id
     AND prev_wn.sequence_id = jwp.junction_seq - 1

    UNION ALL

    -- branch towards sequence + 1
    SELECT
        jwp.way_id,
        jwp.junction_seq + 1 AS branch_seq,
        next_wn.node_id AS branch_node
    FROM junction_way_positions jwp
    JOIN way_nodes next_wn
      ON next_wn.way_id = jwp.way_id
     AND next_wn.sequence_id = jwp.junction_seq + 1
),

restriction_reason AS (
    SELECT DISTINCT
        rm_to.member_id AS to_way,
        r.tags->'restriction' AS restriction
    FROM params p
    JOIN relation_members rm_from
      ON rm_from.member_id = p.current_way
     AND rm_from.member_type = 'W'
     AND rm_from.member_role = 'from'
    JOIN relations r
      ON r.id = rm_from.relation_id
     AND r.tags->'type' = 'restriction'
    JOIN relation_members rm_via
      ON rm_via.relation_id = r.id
     AND rm_via.member_role = 'via'
     AND rm_via.member_type = 'N'
     AND rm_via.member_id = p.junction_node
    JOIN relation_members rm_to
      ON rm_to.relation_id = r.id
     AND rm_to.member_role = 'to'
     AND rm_to.member_type = 'W'
    WHERE NOT r.tags ? 'restriction:conditional'
)

SELECT
    b.way_id,
    w.tags->'name' AS name,
    w.tags->'highway' AS highway,
    w.tags->'oneway' AS oneway,
    b.branch_node,
    b.branch_seq,

    ROUND(
        DEGREES(ST_Azimuth(j.geom, bn.geom))::numeric,
        1
    )::double precision AS bearing,

    ST_X(bn.geom) AS branch_lon,
    ST_Y(bn.geom) AS branch_lat,

    CASE
        WHEN b.way_id = p.current_way
         AND (
              (p.travel_direction = 'forward' AND b.branch_seq < p.junction_seq)
              OR
              (p.travel_direction = 'reverse' AND b.branch_seq > p.junction_seq)
         )
        THEN true
        ELSE false
    END AS is_incoming,

    rr.restriction,
    ST_AsGeoJSON(w.linestring)::jsonb AS geometry

FROM branches b
CROSS JOIN params p
JOIN ways w
  ON w.id = b.way_id
JOIN nodes j
  ON j.id = p.junction_node
JOIN nodes bn
  ON bn.id = b.branch_node
LEFT JOIN restriction_reason rr
  ON rr.to_way = b.way_id
ORDER BY b.way_id, b.branch_seq;
"""


def load_postgis_context(match: dict[str, Any]) -> dict[str, Any]:
    with psycopg.connect(DB_DSN) as conn:
        with conn.cursor() as cur:
            cur.execute(
                SQL_CONTEXT,
                {
                    "node_from": match["node_from"],
                    "node_to": match["node_to"],
                },
            )
            row = cur.fetchone()

            if not row or row[0] is None:
                raise HTTPException(
                    status_code=422,
                    detail={
                        "message": "OSRM OSM-node pair could not be mapped to one OSM way",
                        "node_from": match["node_from"],
                        "node_to": match["node_to"],
                    },
                )

            (
                current_way,
                current_name,
                current_highway,
                travel_direction,
                seq_from,
                seq_to,
                current_geometry,
                junction_node,
                junction_seq,
                junction_lon,
                junction_lat,
            ) = row

            if junction_node is None:
                raise HTTPException(
                    status_code=422,
                    detail={
                        "message": "No forward junction found on current OSM way",
                        "current_way": current_way,
                        "direction": travel_direction,
                    },
                )

            cur.execute(
                SQL_BRANCHES,
                {
                    "current_way": current_way,
                    "junction_node": junction_node,
                    "junction_seq": junction_seq,
                    "travel_direction": travel_direction,
                },
            )

            branch_rows = cur.fetchall()

    branches = []
    for r in branch_rows:
        branches.append(
            {
                "way_id": int(r[0]),
                "name": r[1],
                "highway": r[2],
                "oneway": r[3],
                "branch_node": int(r[4]),
                "branch_seq": int(r[5]),
                "bearing": float(r[6]),
                "branch_lon": float(r[7]),
                "branch_lat": float(r[8]),
                "is_incoming": bool(r[9]),
                "restriction": r[10],
                "geometry": r[11],
            }
        )

    # Bearing of the matched directed OSM segment. Useful to constrain
    # the OSRM Route probe to the same travel direction.
    with psycopg.connect(DB_DSN) as conn:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT DEGREES(ST_Azimuth(a.geom, b.geom))
                FROM nodes a, nodes b
                WHERE a.id = %(node_from)s
                  AND b.id = %(node_to)s
                """,
                {
                    "node_from": match["node_from"],
                    "node_to": match["node_to"],
                },
            )
            start_bearing = float(cur.fetchone()[0])

    return {
        "current": {
            "way_id": int(current_way),
            "name": current_name,
            "highway": current_highway,
            "direction": travel_direction,
            "seq_from": int(seq_from),
            "seq_to": int(seq_to),
            "geometry": current_geometry,
        },
        "junction": {
            "node_id": int(junction_node),
            "sequence_id": int(junction_seq),
            "lon": float(junction_lon),
            "lat": float(junction_lat),
        },
        "branches": branches,
        "start_bearing": start_bearing,
    }


# ============================================================
# OSRM: FIND INTERSECTION SEMANTICS (bearings[] + entry[])
# ============================================================

def extract_intersection_near_junction(
    route_data: dict[str, Any],
    junction_lon: float,
    junction_lat: float,
    tolerance_m: float = 20.0,
) -> dict[str, Any] | None:
    best: tuple[float, dict[str, Any]] | None = None

    for route in route_data.get("routes") or []:
        for leg in route.get("legs") or []:
            for step in leg.get("steps") or []:
                for intersection in step.get("intersections") or []:
                    location = intersection.get("location")
                    if not location or len(location) != 2:
                        continue

                    # We need a real junction-like intersection, not just a
                    # depart/arrive point with one bearing.
                    bearings = intersection.get("bearings") or []
                    entries = intersection.get("entry") or []
                    if len(bearings) < 2 or len(entries) != len(bearings):
                        continue

                    d = haversine_m(
                        float(location[0]),
                        float(location[1]),
                        junction_lon,
                        junction_lat,
                    )

                    if d <= tolerance_m and (best is None or d < best[0]):
                        best = (d, intersection)

    if best is None:
        return None

    result = dict(best[1])
    result["distance_to_postgis_junction_m"] = round(best[0], 2)
    return result


def osrm_probe_intersection(
    match: dict[str, Any],
    context: dict[str, Any],
) -> dict[str, Any]:
    """
    Ask OSRM for the routability semantics of the *next* junction.

    We try non-incoming branches as probe destinations until one route
    actually crosses the PostGIS junction. The intersection object then
    gives us the authoritative bearings[] / entry[] arrays for this
    incoming routing state.
    """

    start_lon = match["matched_lon"]
    start_lat = match["matched_lat"]
    start_bearing = round(context["start_bearing"])

    junction = context["junction"]

    probe_branches = [b for b in context["branches"] if not b["is_incoming"]]

    # Prefer branches without an explicit OSM restriction as probe targets.
    probe_branches.sort(key=lambda b: (b["restriction"] is not None, b["way_id"]))

    errors: list[dict[str, Any]] = []

    for branch in probe_branches:
        coordinates = (
            f"{start_lon},{start_lat};"
            f"{branch['branch_lon']},{branch['branch_lat']}"
        )

        try:
            response = osrm_client.get(
                f"/route/v1/driving/{coordinates}",
                params={
                    "steps": "true",
                    "overview": "false",
                    # First coordinate constrained to the matched travel direction;
                    # destination bearing intentionally left unconstrained.
                    "bearings": f"{start_bearing},45;",
                },
            )

            # NoRoute is a perfectly valid result for a blocked probe branch.
            if response.status_code >= 400:
                errors.append(
                    {
                        "way_id": branch["way_id"],
                        "status": response.status_code,
                        "body": response.text,
                    }
                )
                continue

            data = response.json()
            if data.get("code") != "Ok":
                errors.append(
                    {
                        "way_id": branch["way_id"],
                        "code": data.get("code"),
                    }
                )
                continue

            intersection = extract_intersection_near_junction(
                data,
                junction["lon"],
                junction["lat"],
            )

            if intersection is not None:
                intersection["probe_way_id"] = branch["way_id"]
                return intersection

        except httpx.RequestError as exc:
            errors.append(
                {
                    "way_id": branch["way_id"],
                    "error": str(exc),
                }
            )

    raise HTTPException(
        status_code=422,
        detail={
            "message": "Could not obtain OSRM intersection semantics for next junction",
            "junction": context["junction"],
            "probe_errors": errors,
        },
    )


# ============================================================
# MERGE OSRM ROUTING TRUTH + POSTGIS OSM IDENTITY / REASONS
# ============================================================

def classify_transitions(
    context: dict[str, Any],
    intersection: dict[str, Any],
) -> list[dict[str, Any]]:
    osrm_bearings = [float(v) for v in (intersection.get("bearings") or [])]
    osrm_entries = [bool(v) for v in (intersection.get("entry") or [])]

    transitions: list[dict[str, Any]] = []

    for branch in context["branches"]:
        if branch["is_incoming"]:
            continue

        if not osrm_bearings:
            matched_index = None
            diff = None
        else:
            matched_index = min(
                range(len(osrm_bearings)),
                key=lambda i: angular_diff(branch["bearing"], osrm_bearings[i]),
            )
            diff = angular_diff(branch["bearing"], osrm_bearings[matched_index])

        # OSRM bearings are integer-ish and the OSM branch azimuth is exact.
        # A 15 degree tolerance is intentionally conservative for local junctions.
        if matched_index is None or diff is None or diff > 15.0:
            allowed = None
            status = "unknown"
            reason = "bearing_not_mapped_to_osrm_intersection"
            osrm_bearing = None
        else:
            allowed = osrm_entries[matched_index]
            status = "allowed" if allowed else "restricted"
            osrm_bearing = osrm_bearings[matched_index]

            if allowed:
                reason = None
            elif branch["restriction"]:
                # PostGIS explains a blocked turn when an explicit OSM relation exists.
                reason = branch["restriction"]
            else:
                # OSRM knows why internally (oneway/access/profile/turn graph),
                # but the public Intersection API exposes only routability here.
                reason = "osrm_not_routable"

        transitions.append(
            {
                **branch,
                "allowed": allowed,
                "status": status,
                "reason": reason,
                "osrm_bearing": osrm_bearing,
                "bearing_diff_deg": None if diff is None else round(diff, 1),
            }
        )

    return transitions


# ============================================================
# RESPONSE ASSEMBLY
# ============================================================

def build_response(
    match: dict[str, Any],
    context: dict[str, Any],
    intersection: dict[str, Any],
    transitions: list[dict[str, Any]],
) -> dict[str, Any]:
    current = context["current"]

    features: list[dict[str, Any]] = [
        {
            "type": "Feature",
            "properties": {
                "way_id": current["way_id"],
                "name": current["name"],
                "highway": current["highway"],
                "status": "current",
                "reason": None,
                "restriction": None,
            },
            "geometry": current["geometry"],
        }
    ]

    seen_way_ids = {current["way_id"]}

    for t in transitions:
        # One OSM way can theoretically have two branches at the same junction.
        # Keep full branch semantics in context.transitions, but avoid drawing the
        # same full Way geometry multiple times in the simple debug GeoJSON.
        if t["way_id"] in seen_way_ids:
            continue
        seen_way_ids.add(t["way_id"])

        features.append(
            {
                "type": "Feature",
                "properties": {
                    "way_id": t["way_id"],
                    "name": t["name"],
                    "highway": t["highway"],
                    "status": t["status"],
                    "allowed": t["allowed"],
                    "reason": t["reason"],
                    "restriction": t["restriction"],
                    "bearing": t["bearing"],
                },
                "geometry": t["geometry"],
            }
        )

    return {
        "type": "FeatureCollection",
        "features": features,
        "context": {
            "current_way": current["way_id"],
            "direction": current["direction"],
            "junction": context["junction"],
            "transitions": [
                {
                    k: v
                    for k, v in t.items()
                    if k != "geometry"
                }
                for t in transitions
            ],
            "osrm": {
                "confidence": match["confidence"],
                "node_from": match["node_from"],
                "node_to": match["node_to"],
                "nodes": match["nodes"],
                "matched_location": [match["matched_lon"], match["matched_lat"]],
                "intersection": {
                    "location": intersection.get("location"),
                    "bearings": intersection.get("bearings"),
                    "entry": intersection.get("entry"),
                    "in": intersection.get("in"),
                    "out": intersection.get("out"),
                    "probe_way_id": intersection.get("probe_way_id"),
                },
            },
        },
    }


# ============================================================
# SINGLE CLEAN V3 ENDPOINT
# ============================================================

@app.get("/api/road-context")
def road_context(
    prev_lon: float = Query(...),
    prev_lat: float = Query(...),
    curr_lon: float = Query(...),
    curr_lat: float = Query(...),
):
    # 1) GPS trace -> OSRM routing state expressed as OSM node IDs
    match = osrm_match_nodes(prev_lon, prev_lat, curr_lon, curr_lat)

    # 2) Exact OSM-ID bridge -> current Way + directed next junction + branches
    context = load_postgis_context(match)

    # 3) OSRM is the routing authority for entry=true/false at that junction
    intersection = osrm_probe_intersection(match, context)

    # 4) PostGIS maps OSRM bearings back to OSM Way identities and explains
    #    explicit OSM restriction relations when available.
    transitions = classify_transitions(context, intersection)

    return build_response(match, context, intersection, transitions)


@app.get("/api/debug-road-context")
def debug_road_context(
    lon: float = Query(...),
    lat: float = Query(...),
    direction: str | None = Query(None),
):
    # Web debug path: click -> nearest OSM way/segment -> synthetic directed state.
    # On two-way roads, direction is supplied explicitly by the debug UI.
    match = click_match(lon, lat, direction)

    # A click alone cannot infer travel direction on a two-way road.
    if match.get("needs_direction"):
        return {
            "status": "needs_direction",
            "clicked": {"lon": lon, "lat": lat},
            "road": match,
        }

    # Reuse the exact same downstream topology/routability pipeline as production.
    context = load_postgis_context(match)
    intersection = osrm_probe_intersection(match, context)
    transitions = classify_transitions(context, intersection)
    response = build_response(match, context, intersection, transitions)
    response["context"]["debug_click"] = match["debug_click"]
    return response


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}
