#!/usr/bin/env python3
"""
app.py — FastAPI сервер для Enduro Trails
- Раздаёт статику из static/
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite
- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия
- /api/health — статус БД
"""

import os
import math
import struct
import sqlite3
import itertools
from typing import List

import httpx
import uvicorn
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from shapely.geometry import LineString

from src.api.gps_tracks.endpoint import create_gps_router

GPS_TRACKS_DB_PATH = os.environ.get(
    "GPS_TRACKS_DB_PATH",
    os.path.join(os.path.dirname(__file__), "../../data/gps_tracks.sqlite"),
)

# ET-011 / ADR-015: путь к config/gps_sources.yaml — содержит per-source
# флаг `download_allowed`, который router читает один раз при старте.
GPS_SOURCES_CONFIG_PATH = os.environ.get(
    "GPS_SOURCES_CONFIG_PATH",
    os.path.join(os.path.dirname(__file__), "../../config/gps_sources.yaml"),
)

# ─── Tile cache ──────────────────────────────────────────────────────────────

_tile_cache: dict = {}
_TILE_CACHE_MAX = 512


def get_cached_tile(z, x, y):
    return _tile_cache.get((z, x, y))


def set_cached_tile(z, x, y, data: bytes):
    if len(_tile_cache) >= _TILE_CACHE_MAX:
        # FIFO вытеснение — удаляем самый старый ключ
        _tile_cache.pop(next(iter(_tile_cache)))
    _tile_cache[(z, x, y)] = data


# ─── Конфиг ───────────────────────────────────────────────────────────────────

DATA_PATH = os.environ.get(
    "DATA_PATH",
    os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"),
)
OSRM_URL = os.environ.get("OSRM_URL", "http://172.22.0.1:5559")
DATA_PATH = os.path.abspath(DATA_PATH)
STATIC_DIR = os.environ.get("STATIC_DIR", os.path.join(os.path.dirname(__file__), "../../src/web"))
PORT = int(os.environ.get("PORT", 5558))

app = FastAPI(title="Enduro Trails API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# ─── DB ───────────────────────────────────────────────────────────────────────

def get_db():
    conn = sqlite3.connect(DATA_PATH)
    conn.row_factory = sqlite3.Row
    return conn


# ─── Tile math ────────────────────────────────────────────────────────────────

def tile_to_bbox(z: int, x: int, y: int):
    n = 2 ** z
    west  = x / n * 360.0 - 180.0
    east  = (x + 1) / n * 360.0 - 180.0
    north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
    south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
    return west, south, east, north


# ─── WKB parser ───────────────────────────────────────────────────────────────

def wkb_to_coords(blob: bytes):
    """Парсит WKB LineString с опциональным SRID, возвращает [(lon, lat), ...]."""
    try:
        b = bytes(blob)
        if len(b) < 9:
            return None
        endian = "<" if b[0] == 1 else ">"
        gtype = struct.unpack_from(endian + "I", b, 1)[0]
        base_type = gtype & 0xFF
        if base_type != 2:
            return None
        offset = 5
        if gtype & 0x20000000:
            offset += 4
        npts = struct.unpack_from(endian + "I", b, offset)[0]
        offset += 4
        coords = []
        for _ in range(npts):
            lon, lat = struct.unpack_from(endian + "dd", b, offset)
            offset += 16
            coords.append((lon, lat))
        return coords if len(coords) >= 2 else None
    except Exception:
        return None


def wkb_point_coords(blob: bytes):
    """Парсит WKB Point, возвращает (lon, lat)."""
    try:
        b = bytes(blob)
        if len(b) < 21:
            return None
        endian = "<" if b[0] == 1 else ">"
        gtype = struct.unpack_from(endian + "I", b, 1)[0]
        base_type = gtype & 0xFF
        if base_type != 1:
            return None
        offset = 5
        if gtype & 0x20000000:
            offset += 4
        lon, lat = struct.unpack_from(endian + "dd", b, offset)
        return lon, lat
    except Exception:
        return None


# ─── MVT builder ──────────────────────────────────────────────────────────────

def simplify_coords(coords, z):
    """Упрощает геометрию трека по зуму через Douglas-Peucker."""
    if z >= 12:
        return coords  # без упрощения на детальных зумах
    elif z >= 10:
        tolerance = 0.0005  # ~50м
    elif z >= 8:
        tolerance = 0.002   # ~200м
    else:
        tolerance = 0.008   # ~800м на z7 и ниже

    if len(coords) < 3:
        return coords

    line = LineString(coords)
    simplified = line.simplify(tolerance, preserve_topology=False)
    result = list(simplified.coords)
    return result if len(result) >= 2 else coords


def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
    """Собирает MVT тайл."""
    import mapbox_vector_tile

    west, south, east, north = tile_to_bbox(z, x, y)

    trails_features = []
    for row in trails_rows:
        coords = wkb_to_coords(row["geom"])
        if not coords:
            continue
        coords = simplify_coords(coords, z)
        try:
            props = {
                "highway":   row["highway_type"] or "",
                "tracktype": row["track_type"] or "",
                "surface":   row["surface"] or "",
                "name":      row["name"] or "",
                "length_m":  row["length_m"] or 0,
                "mtb_scale": row["mtb_scale"] or "",
            }
            trails_features.append({
                "geometry": {"type": "LineString", "coordinates": coords},
                "properties": props,
            })
        except Exception:
            continue

    poi_features = []
    for row in poi_rows:
        pt = wkb_point_coords(row["geom"])
        if not pt:
            continue
        try:
            props = {
                "poi_type": row["poi_type"] or "",
                "name": row["name"] or "",
            }
            poi_features.append({
                "geometry": {"type": "Point", "coordinates": list(pt)},
                "properties": props,
            })
        except Exception:
            continue

    layers = []
    if trails_features:
        layers.append({"name": "trails", "features": trails_features})
    if poi_features:
        layers.append({"name": "poi", "features": poi_features})

    if not layers:
        return b""

    return mapbox_vector_tile.encode(
        layers,
        quantize_bounds=(west, south, east, north),
        extents=4096,
        default_options={'y_coord_down': False},
    )


# ─── Route stats ──────────────────────────────────────────────────────────────

def haversine_m(lon1, lat1, lon2, lat2) -> float:
    """Расстояние между двумя точками в метрах."""
    R = 6371000
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlam = math.radians(lon2 - lon1)
    a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
    return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))


def calc_route_stats(geometry: dict, conn) -> dict | None:
    """
    Считает статистику покрытия маршрута по типам дорог.
    Оптимизированная версия: сэмплирует каждые ~500м, один запрос на сэмпл.
    geometry — GeoJSON LineString {"type":"LineString","coordinates":[[lon,lat],...]}
    """
    try:
        coords = geometry.get("coordinates", [])
        if len(coords) < 2:
            return None

        # Считаем длины сегментов и общую длину
        total_len = 0.0
        seg_lengths = []
        for i in range(len(coords) - 1):
            d = haversine_m(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1])
            seg_lengths.append(d)
            total_len += d

        if total_len < 1:
            return None

        # Сэмплируем каждые ~500м (не чаще чем каждые 5 точек)
        # Для маршрута 100км → ~200 сэмплов, для 500км → ~1000 сэмплов
        avg_step = total_len / max(len(seg_lengths), 1)
        # Шаг в точках для ~500м интервала
        pts_per_500m = max(5, int(round(500.0 / avg_step))) if avg_step > 0 else 20

        cur = conn.cursor()

        stats = {
            "track_lev12_m": 0.0,
            "track_lev345_m": 0.0,
            "path_m": 0.0,
            "asphalt_m": 0.0,
        }

        # Кэш результатов по ячейкам сетки ~0.01° (~1км)
        # Чтобы не делать повторные запросы для близких точек
        grid_cache: dict = {}

        i = 0
        while i < len(coords) - 1:
            end_i = min(i + pts_per_500m, len(coords) - 1)

            # Средняя точка сегмента
            mid_lon = (coords[i][0] + coords[end_i][0]) / 2
            mid_lat = (coords[i][1] + coords[end_i][1]) / 2

            # Длина этого сегмента
            seg_len = sum(seg_lengths[i:end_i])

            # Ключ кэша: ячейка 0.01° (~1км)
            grid_key = (round(mid_lon * 100), round(mid_lat * 100))

            if grid_key in grid_cache:
                hw, tt = grid_cache[grid_key]
            else:
                # Tight bbox ~150m — index on (min_lon, max_lon, min_lat, max_lat) is used
                # No ORDER BY to avoid full scan; first bbox hit is good enough for stats
                delta = 0.0015
                try:
                    cur.execute("""
                        SELECT highway_type, track_type
                        FROM trails
                        WHERE min_lon <= ? AND max_lon >= ?
                          AND min_lat <= ? AND max_lat >= ?
                        LIMIT 1
                    """, (
                        mid_lon + delta, mid_lon - delta,
                        mid_lat + delta, mid_lat - delta,
                    ))
                    row = cur.fetchone()
                    if row:
                        hw = (row["highway_type"] or "").lower()
                        tt = (row["track_type"] or "").lower()
                    else:
                        # Widen search to ~500m if nothing found nearby
                        delta2 = 0.005
                        cur.execute("""
                            SELECT highway_type, track_type
                            FROM trails
                            WHERE min_lon <= ? AND max_lon >= ?
                              AND min_lat <= ? AND max_lat >= ?
                            LIMIT 1
                        """, (
                            mid_lon + delta2, mid_lon - delta2,
                            mid_lat + delta2, mid_lat - delta2,
                        ))
                        row = cur.fetchone()
                        if row:
                            hw = (row["highway_type"] or "").lower()
                            tt = (row["track_type"] or "").lower()
                        else:
                            hw, tt = "asphalt", ""
                except Exception:
                    hw, tt = "asphalt", ""
                grid_cache[grid_key] = (hw, tt)

            if hw == "track":
                if tt in ("grade1", "grade2"):
                    stats["track_lev12_m"] += seg_len
                else:
                    stats["track_lev345_m"] += seg_len
            elif hw in ("path", "bridleway", "footway"):
                stats["path_m"] += seg_len
            else:
                stats["asphalt_m"] += seg_len

            i = end_i

        computed_total = (
            stats["track_lev12_m"] + stats["track_lev345_m"] +
            stats["path_m"] + stats["asphalt_m"]
        )
        if computed_total < 1:
            return None

        def pct(v):
            return round(v / computed_total * 100)

        dirt_total = stats["track_lev12_m"] + stats["track_lev345_m"] + stats["path_m"]

        return {
            "track_lev12_m":    round(stats["track_lev12_m"]),
            "track_lev345_m":   round(stats["track_lev345_m"]),
            "path_m":           round(stats["path_m"]),
            "asphalt_m":        round(stats["asphalt_m"]),
            "track_lev12_pct":  pct(stats["track_lev12_m"]),
            "track_lev345_pct": pct(stats["track_lev345_m"]),
            "path_pct":         pct(stats["path_m"]),
            "asphalt_pct":      pct(stats["asphalt_m"]),
            "dirt_total_pct":   pct(dirt_total),
        }
    except Exception:
        return None


# ─── Pydantic models ──────────────────────────────────────────────────────────

class Waypoint(BaseModel):
    lon: float
    lat: float


class RouteRequest(BaseModel):
    waypoints: List[Waypoint]
    alternatives: int = 5


class ReconRequest(BaseModel):
    lon: float
    lat: float
    radius_km: float = 20


class ScenicRequest(BaseModel):
    lon: float
    lat: float
    target_km: float = 100


# ─── API endpoints ────────────────────────────────────────────────────────────

@app.get("/api/cache/clear")
async def clear_cache():
    _tile_cache.clear()
    return {"status": "ok", "cleared": True}


@app.get("/api/tiles/{z}/{x}/{y}.mvt")
async def get_tile(z: int, x: int, y: int):
    if z < 0 or z > 22:
        raise HTTPException(400, "Invalid z")
    max_coord = 2 ** z
    if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
        raise HTTPException(400, "Invalid x/y for zoom level")

    cached = get_cached_tile(z, x, y)
    if cached is not None:
        return Response(
            content=cached,
            media_type="application/x-protobuf",
            headers={"Content-Encoding": "identity", "Access-Control-Allow-Origin": "*", "X-Cache": "HIT"},
        )

    if not os.path.exists(DATA_PATH):
        raise HTTPException(503, f"База данных не найдена: {DATA_PATH}")

    west, south, east, north = tile_to_bbox(z, x, y)

    buf_x = (east - west) * 0.15
    buf_y = (north - south) * 0.15
    q_west  = west  - buf_x
    q_east  = east  + buf_x
    q_south = south - buf_y
    q_north = north + buf_y

    if z <= 7:
        limit = 3000
    elif z <= 9:
        limit = 8000
    elif z <= 11:
        limit = 15000
    else:
        limit = 25000

    if z <= 7:
        min_length = 2000
    elif z == 8:
        min_length = 500
    elif z == 9:
        min_length = 200
    elif z == 10:
        min_length = 50
    else:
        min_length = 0

    try:
        conn = get_db()
        cur = conn.cursor()

        cur.execute("""
            SELECT osm_id, highway_type, track_type, surface, name, length_m, mtb_scale, geom
            FROM trails
            WHERE min_lon <= ? AND max_lon >= ?
              AND min_lat <= ? AND max_lat >= ?
              AND (length_m >= ? OR length_m IS NULL)
            LIMIT ?
        """, (q_east, q_west, q_north, q_south, min_length, limit))
        trails_rows = cur.fetchall()

        cur.execute("""
            SELECT osm_id, poi_type, name, geom
            FROM poi
            WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
            LIMIT 500
        """, (q_west, q_east, q_south, q_north))
        poi_rows = cur.fetchall()

        conn.close()
    except Exception as e:
        raise HTTPException(500, f"Ошибка БД: {e}")

    mvt = build_mvt(trails_rows, poi_rows, z, x, y)

    if mvt:
        set_cached_tile(z, x, y, mvt)

    return Response(
        content=mvt,
        media_type="application/x-protobuf",
        headers={
            "Content-Encoding": "identity",
            "Access-Control-Allow-Origin": "*",
            "X-Cache": "MISS",
        },
    )


@app.post("/api/recon")
async def post_recon(req: ReconRequest):
    """
    Разведка: агрегация trails + POI в радиусе от точки.
    """
    lon, lat, radius_km = req.lon, req.lat, req.radius_km
    lat_rad = math.radians(lat)
    delta_lat = radius_km / 111.0
    delta_lon = radius_km / (111.0 * math.cos(lat_rad))

    try:
        conn = get_db()
        cur = conn.cursor()

        # Trails — агрегация по типам
        cur.execute("""
            SELECT highway_type, track_type, SUM(length_m) as total_m, COUNT(*) as cnt
            FROM trails
            WHERE min_lon <= ? AND max_lon >= ?
              AND min_lat <= ? AND max_lat >= ?
              AND length_m >= 100
            GROUP BY highway_type, track_type
        """, (lon + delta_lon, lon - delta_lon, lat + delta_lat, lat - delta_lat))
        trail_rows = cur.fetchall()

        # Агрегация по категориям
        lev12_count, lev12_km = 0, 0.0
        lev345_count, lev345_km = 0, 0.0
        path_count, path_km = 0, 0.0

        for row in trail_rows:
            hw = (row["highway_type"] or "").lower()
            tt = (row["track_type"] or "").lower() if row["track_type"] else ""
            cnt = row["cnt"]
            km = (row["total_m"] or 0) / 1000.0

            if hw == "track":
                if tt in ("grade1", "grade2"):
                    lev12_count += cnt
                    lev12_km += km
                else:
                    lev345_count += cnt
                    lev345_km += km
            elif hw in ("path", "bridleway"):
                path_count += cnt
                path_km += km

        total_count = lev12_count + lev345_count + path_count
        total_km = lev12_km + lev345_km + path_km

        # POI — подсчёт по типам
        cur.execute("""
            SELECT poi_type, COUNT(*) as cnt
            FROM poi
            WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
            GROUP BY poi_type
        """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat))
        poi_rows = cur.fetchall()

        poi_counts = {}
        for row in poi_rows:
            poi_counts[row["poi_type"]] = row["cnt"]

        conn.close()

        # POI types to report
        poi_report = {}
        for key in ["natural=water", "tourism=viewpoint", "historic=ruins",
                     "ford=yes", "natural=peak", "natural=cave_entrance"]:
            poi_report[key] = poi_counts.get(key, 0)

        return {
            "center": {"lon": lon, "lat": lat},
            "radius_km": radius_km,
            "trails": {
                "total_count": total_count,
                "total_km": round(total_km, 1),
                "lev12_count": lev12_count,
                "lev12_km": round(lev12_km, 1),
                "lev345_count": lev345_count,
                "lev345_km": round(lev345_km, 1),
                "path_count": path_count,
                "path_km": round(path_km, 1),
            },
            "poi": poi_report,
        }
    except Exception as e:
        raise HTTPException(500, f"Ошибка БД: {e}")


# ─── Scenic route helpers ─────────────────────────────────────────────────────

SCENIC_POI_SCORES = {
    "natural=water": 10,
    "tourism=viewpoint": 15,
    "historic=ruins": 10,
    "natural=peak": 12,
    "natural=cave_entrance": 8,
    "ford=yes": 5,
}

SCENIC_POI_ICONS = {
    "natural=water": "💧",
    "tourism=viewpoint": "👁",
    "historic=ruins": "🏚",
    "natural=peak": "🔺",
    "natural=cave_entrance": "🕳",
    "ford=yes": "🌊",
}

DIRECTION_NAMES = {0: "Северный", 1: "Восточный", 2: "Южный", 3: "Западный"}


def _angle_bucket(lon, lat, center_lon, center_lat):
    """Возвращает сектор 0-3 (С, В, Ю, З) от центра."""
    a = math.degrees(math.atan2(lon - center_lon, lat - center_lat)) % 360
    return int(a // 90)


async def _osrm_route_waypoints(waypoints_lonlat: list[tuple[float, float]]) -> dict | None:
    """Запрос к OSRM со списком waypoints. Возвращает полный ответ OSRM."""
    coords_str = ";".join(f"{lon},{lat}" for lon, lat in waypoints_lonlat)
    radiuses_str = ";".join(["5000"] * len(waypoints_lonlat))
    url = (
        f"{OSRM_URL}/route/v1/driving/{coords_str}"
        f"?overview=full&geometries=geojson&alternatives=false"
        f"&radiuses={radiuses_str}"
    )
    try:
        async with httpx.AsyncClient(timeout=60) as client:
            resp = await client.get(url)
            data = resp.json()
    except Exception:
        return None
    if data.get("code") != "Ok" or not data.get("routes"):
        # Retry with wider snap
        radiuses_wide = ";".join(["10000"] * len(waypoints_lonlat))
        url2 = (
            f"{OSRM_URL}/route/v1/driving/{coords_str}"
            f"?overview=full&geometries=geojson&alternatives=false"
            f"&radiuses={radiuses_wide}"
        )
        try:
            async with httpx.AsyncClient(timeout=60) as client:
                resp = await client.get(url2)
                data = resp.json()
        except Exception:
            return None
    if data.get("code") != "Ok" or not data.get("routes"):
        return None
    return data


def _merge_geometries(geometries: list[dict]) -> dict:
    """Объединяет несколько LineString GeoJSON в одну."""
    all_coords = []
    for g in geometries:
        if g.get("type") == "LineString":
            all_coords.extend(g.get("coordinates", []))
    return {"type": "LineString", "coordinates": all_coords}


async def _build_scenic_for_cluster(
    start_lon, start_lat, poi_list, target_km, conn
) -> dict | None:
    """
    Строит кольцевой маршрут: start → POI1 → POI2 → ... → start.
    poi_list: [{lon, lat, poi_type, name, score}, ...]
    """
    if not poi_list:
        return None

    # Ограничиваем до 5 POI
    pois = poi_list[:5]

    # Waypoints: start → POI sequence → start
    wp = [(start_lon, start_lat)]
    for p in pois:
        wp.append((p["lon"], p["lat"]))
    wp.append((start_lon, start_lat))

    osrm_data = await _osrm_route_waypoints(wp)
    if not osrm_data or not osrm_data.get("routes"):
        return None

    route = osrm_data["routes"][0]
    geometry = route["geometry"]
    distance_m = route["distance"]
    duration_s = route["duration"]

    # Если слишком длинный (> target * 1.3) — убрать последний POI и перестроить
    if distance_m > target_km * 1300 and len(pois) > 1:
        pois = pois[:-1]
        wp = [(start_lon, start_lat)]
        for p in pois:
            wp.append((p["lon"], p["lat"]))
        wp.append((start_lon, start_lat))
        osrm_data2 = await _osrm_route_waypoints(wp)
        if osrm_data2 and osrm_data2.get("routes"):
            route = osrm_data2["routes"][0]
            geometry = route["geometry"]
            distance_m = route["distance"]
            duration_s = route["duration"]

    # Статистика покрытия
    stats = None
    if conn:
        try:
            stats = calc_route_stats(geometry, conn)
        except Exception:
            pass

    # Waypoint labels для ответа
    waypoints_out = [{"lon": start_lon, "lat": start_lat, "label": "Старт"}]
    for p in pois:
        icon = SCENIC_POI_ICONS.get(p["poi_type"], "📍")
        name = p.get("name") or p["poi_type"]
        waypoints_out.append({"lon": p["lon"], "lat": p["lat"], "label": f"{icon} {name}"})
    waypoints_out.append({"lon": start_lon, "lat": start_lat, "label": "Финиш"})

    scenic_score = sum(p.get("score", 0) for p in pois)
    scenic_pois_out = [
        {
            "type": p["poi_type"],
            "name": p.get("name", ""),
            "lon": p["lon"],
            "lat": p["lat"],
        }
        for p in pois
    ]

    return {
        "name": "",
        "waypoints": waypoints_out,
        "geometry": geometry,
        "distance_m": round(distance_m),
        "duration_s": round(duration_s),
        "stats": stats,
        "scenic_score": scenic_score,
        "scenic_pois": scenic_pois_out,
    }


@app.post("/api/scenic")
async def post_scenic(req: ScenicRequest):
    """
    Красивый маршрут: кольцевой маршрут через живописные POI.
    """
    lon, lat, target_km = req.lon, req.lat, req.target_km
    if target_km < 20 or target_km > 500:
        raise HTTPException(400, "Дистанция должна быть от 20 до 500 км")

    # Радиус поиска POI
    search_radius_km = target_km * 0.6
    lat_rad = math.radians(lat)
    delta_lat = search_radius_km / 111.0
    delta_lon = search_radius_km / (111.0 * math.cos(lat_rad))

    try:
        conn = get_db()
        cur = conn.cursor()

        # Найти POI в радиусе
        cur.execute("""
            SELECT poi_type, name, lon, lat
            FROM poi
            WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
            ORDER BY poi_type
        """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat))
        poi_rows = cur.fetchall()

        # Фильтруем POI по score и назначаем баллы
        scored_pois = []
        for row in poi_rows:
            pt = row["poi_type"] or ""
            if pt in SCENIC_POI_SCORES:
                p_lon = row["lon"]
                p_lat = row["lat"]
                # Не берём POI ближе 3 км от старта
                d = haversine_m(lon, lat, p_lon, p_lat)
                if d < 3000:
                    continue
                scored_pois.append({
                    "poi_type": pt,
                    "name": row["name"] or "",
                    "lon": p_lon,
                    "lat": p_lat,
                    "score": SCENIC_POI_SCORES[pt],
                    "distance_m": d,
                })

        if not scored_pois:
            # Нет красивых мест — строим просто кольцо через ближайшие грунтовки
            # Пробуем кольцо: старт → точка на расстоянии ~target_km/3 → старт
            _ = 0
            third_dist = target_km / 3.0
            mid_lat = lat + (third_dist / 111.0)
            mid_lon = lon
            osrm_data = await _osrm_route_waypoints([
                (lon, lat), (mid_lon, mid_lat), (lon, lat)
            ])
            if osrm_data and osrm_data.get("routes"):
                route = osrm_data["routes"][0]
                geometry = route["geometry"]
                stats = calc_route_stats(geometry, conn) if conn else None
                conn.close()
                return {
                    "routes": [{
                        "name": "Маршрут по грунтовкам",
                        "waypoints": [
                            {"lon": lon, "lat": lat, "label": "Старт"},
                            {"lon": mid_lon, "lat": mid_lat, "label": "Разворот"},
                            {"lon": lon, "lat": lat, "label": "Финиш"},
                        ],
                        "geometry": geometry,
                        "distance_m": round(route["distance"]),
                        "duration_s": round(route["duration"]),
                        "stats": stats,
                        "scenic_score": 0,
                        "scenic_pois": [],
                    }]
                }
            conn.close()
            raise HTTPException(404, "Не удалось построить маршрут")

        # Разделить POI на кластеры по азимуту (4 сектора)
        clusters: dict[int, list] = {0: [], 1: [], 2: [], 3: []}
        for p in scored_pois:
            bucket = _angle_bucket(p["lon"], p["lat"], lon, lat)
            clusters[bucket].append(p)

        # Для каждого кластера — жадный выбор POI
        remaining_km = target_km * 0.8  # 80% дистанции на POI, 20% на возврат

        cluster_poi_lists = {}
        for bucket, pois in clusters.items():
            if not pois:
                cluster_poi_lists[bucket] = []
                continue
            # Сортируем по score/distance (жадный)
            for p in pois:
                p["score_per_km"] = p["score"] / max(p["distance_m"] / 1000.0, 1.0)
            pois.sort(key=lambda p: p["score_per_km"], reverse=True)

            selected = []
            used_km = 0.0
            for p in pois:
                d_km = p["distance_m"] / 1000.0
                if d_km > remaining_km * 0.5:
                    continue
                if d_km < 3:
                    continue
                if used_km + d_km > remaining_km:
                    continue
                selected.append(p)
                used_km += d_km
                if len(selected) >= 5:
                    break

            cluster_poi_lists[bucket] = selected

        # Строим маршруты для кластеров с POI (до 3 альтернативных)
        routes_out = []
        used_buckets = 0
        for bucket in range(4):
            if used_buckets >= 3:
                break
            pois = cluster_poi_lists.get(bucket, [])
            if not pois:
                continue

            result = await _build_scenic_for_cluster(lon, lat, pois, target_km, conn)
            if result:
                # Проверяем дистанцию: не < 50% target
                if result["distance_m"] < target_km * 500:
                    continue
                result["name"] = DIRECTION_NAMES.get(bucket, f"Вариант {used_buckets + 1}")
                routes_out.append(result)
                used_buckets += 1

        # Если ни одного маршрута — попробовать все POI вместе
        if not routes_out and scored_pois:
            # Топ-5 POI по score_per_km
            all_sorted = sorted(scored_pois, key=lambda p: p["score_per_km"], reverse=True)[:5]
            result = await _build_scenic_for_cluster(lon, lat, all_sorted, target_km, conn)
            if result:
                result["name"] = "Маршрут"
                routes_out.append(result)

        conn.close()

        if not routes_out:
            raise HTTPException(404, "Не удалось построить красивый маршрут")

        return {"routes": routes_out}

    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(500, f"Ошибка: {e}")


async def _osrm_segment_alternatives(lon_a: float, lat_a: float, lon_b: float, lat_b: float, depth: int = 0) -> list:
    """Запросить альтернативы для одного сегмента. При TooBig — разбить пополам."""
    coords_str = f"{lon_a},{lat_a};{lon_b},{lat_b}"
    url = (
        f"{OSRM_URL}/route/v1/driving/{coords_str}"
        f"?alternatives=true&overview=full&geometries=geojson&annotations=false&radiuses=5000;5000"
    )
    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(url)
            data = resp.json()
    except Exception:
        return []

    if data.get("code") == "TooBig" and depth < 2:
        # Разбить пополам — найти середину через среднее координат
        mid_lon = (lon_a + lon_b) / 2
        mid_lat = (lat_a + lat_b) / 2
        # Рекурсивно получить альтернативы для каждой половины
        alts_a = await _osrm_segment_alternatives(lon_a, lat_a, mid_lon, mid_lat, depth + 1)
        alts_b = await _osrm_segment_alternatives(mid_lon, mid_lat, lon_b, lat_b, depth + 1)
        if not alts_a or not alts_b:
            return []
        # Скомбинировать: каждый вариант первой половины × каждый вариант второй
        combined = []
        for r_a, r_b in itertools.product(alts_a[:3], alts_b[:3]):
            coords_a = r_a["geometry"]["coordinates"]
            coords_b = r_b["geometry"]["coordinates"]
            merged_coords = coords_a + coords_b[1:]  # убрать дублирующую точку стыка
            combined.append({
                "distance": r_a["distance"] + r_b["distance"],
                "duration": r_a["duration"] + r_b["duration"],
                "geometry": {"type": "LineString", "coordinates": merged_coords},
            })
        return combined

    if data.get("code") != "Ok" or not data.get("routes"):
        # Попробовать без alternatives
        url_single = (
            f"{OSRM_URL}/route/v1/driving/{coords_str}"
            f"?overview=full&geometries=geojson&annotations=false&radiuses=5000;5000"
        )
        try:
            async with httpx.AsyncClient(timeout=30) as client:
                resp = await client.get(url_single)
                data = resp.json()
        except Exception:
            return []
        if data.get("code") != "Ok":
            return []

    return data.get("routes", [])


async def _build_segmented_route(req: RouteRequest) -> dict:
    """Строит маршрут через промежуточные точки с альтернативами по сегментам."""
    waypoints = req.waypoints
    segments_count = len(waypoints) - 1

    # Получить альтернативы для каждого сегмента
    segment_alternatives = []
    for i in range(segments_count):
        wp_a = waypoints[i]
        wp_b = waypoints[i + 1]
        alts = await _osrm_segment_alternatives(wp_a.lon, wp_a.lat, wp_b.lon, wp_b.lat)
        if not alts:
            raise HTTPException(404, f"Маршрут не найден на сегменте {i + 1}")
        segment_alternatives.append(alts)

    # Скомбинировать сегменты (до 3 вариантов на сегмент чтобы не взорвать комбинаторику)
    max_per_segment = 3
    trimmed = [alts[:max_per_segment] for alts in segment_alternatives]
    all_combos = list(itertools.product(*trimmed))

    # Склеить геометрию для каждой комбинации
    combined_routes = []
    for combo in all_combos:
        total_distance = sum(r["distance"] for r in combo)
        total_duration = sum(r["duration"] for r in combo)
        all_coords: list = []
        for r in combo:
            coords = r["geometry"]["coordinates"]
            if all_coords:
                all_coords.extend(coords[1:])
            else:
                all_coords.extend(coords)
        combined_routes.append({
            "distance": total_distance,
            "duration": total_duration,
            "geometry": {"type": "LineString", "coordinates": all_coords},
        })

    # Дедупликация по геометрии (5 контрольных точек)
    def route_sig(coords):
        n = len(coords)
        if n == 0:
            return ()
        idxs = [0, n//4, n//2, 3*n//4, n-1]
        return tuple((round(coords[i][0], 3), round(coords[i][1], 3)) for i in idxs if i < n)

    seen = set()
    deduped = []
    for route in combined_routes:
        sig = route_sig(route["geometry"]["coordinates"])
        if sig not in seen:
            seen.add(sig)
            deduped.append(route)

    deduped = deduped[:5]

    if not deduped:
        raise HTTPException(404, "Маршрут не найден")

    # Считаем статистику через существующую calc_route_stats
    try:
        conn = get_db()
    except Exception:
        conn = None

    routes_out = []
    for idx, route in enumerate(deduped):
        stats = None
        if conn is not None:
            try:
                stats = calc_route_stats(route["geometry"], conn)
            except Exception:
                stats = None
        routes_out.append({
            "index": idx,
            "distance_m": round(route["distance"]),
            "duration_s": round(route["duration"]),
            "geometry": route["geometry"],
            "stats": stats,
        })

    if conn is not None:
        try:
            conn.close()
        except Exception:
            pass

    if not routes_out:
        raise HTTPException(404, "Маршрут не найден")

    # Сортировать по dirt_total_pct убывающий
    routes_out.sort(key=lambda r: (r["stats"] or {}).get("dirt_total_pct", 0), reverse=True)
    for idx, r in enumerate(routes_out):
        r["index"] = idx

    # Waypoints для ответа
    waypoints_out = []
    for i, wp in enumerate(req.waypoints):
        label = "Старт" if i == 0 else ("Финиш" if i == len(req.waypoints) - 1 else f"Точка {i}")
        waypoints_out.append({"lon": wp.lon, "lat": wp.lat, "label": label})

    return {"routes": routes_out, "waypoints": waypoints_out}


@app.post("/api/route")
async def post_route(req: RouteRequest):
    """
    Роутинг через OSRM с альтернативными маршрутами и статистикой покрытия.
    Принимает JSON: {"waypoints": [{"lon":..,"lat":..}, ...], "alternatives": 5}
    """
    if len(req.waypoints) < 2:
        raise HTTPException(400, "Нужно минимум 2 точки")

    # При промежуточных точках — сегментный подход
    if len(req.waypoints) > 2:
        return await _build_segmented_route(req)

    # Строим строку координат для OSRM
    coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints)
    alternatives = max(1, min(5, req.alternatives))

    # Увеличенный snap radius для длинных маршрутов (5 км)
    radiuses_str = ";".join(["5000"] * len(req.waypoints))

    url = (
        f"{OSRM_URL}/route/v1/driving/{coords_str}"
        f"?alternatives={alternatives}&overview=full&geometries=geojson&annotations=false"
        f"&radiuses={radiuses_str}"
    )

    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(url)
            data = resp.json()
    except Exception as e:
        raise HTTPException(503, f"OSRM недоступен: {e}")

    # OSRM возвращает TooBig когда маршрут слишком длинный для N альтернатив
    # Retry-цепочка: alternatives=5 → 3 → 1, ВСЕГДА с radiuses (snap radius критичен для длинных маршрутов)
    if data.get("code") == "TooBig":
        url_retry3 = (
            f"{OSRM_URL}/route/v1/driving/{coords_str}"
            f"?alternatives=3&overview=full&geometries=geojson&annotations=false"
            f"&radiuses={radiuses_str}"
        )
        try:
            async with httpx.AsyncClient(timeout=60) as client:
                resp = await client.get(url_retry3)
                data = resp.json()
        except Exception as e:
            raise HTTPException(503, f"OSRM недоступен: {e}")

    if data.get("code") == "TooBig":
        url_retry1 = (
            f"{OSRM_URL}/route/v1/driving/{coords_str}"
            f"?overview=full&geometries=geojson&alternatives=false"
            f"&radiuses={radiuses_str}"
        )
        try:
            async with httpx.AsyncClient(timeout=60) as client:
                resp = await client.get(url_retry1)
                data = resp.json()
        except Exception as e:
            raise HTTPException(503, f"OSRM недоступен: {e}")

    # Если NoSegment — пробуем с увеличенным radius (10 км вместо 5)
    if data.get("code") == "NoSegment":
        radiuses_wide = ";".join(["10000"] * len(req.waypoints))
        url_wide = (
            f"{OSRM_URL}/route/v1/driving/{coords_str}"
            f"?overview=full&geometries=geojson&alternatives=false"
            f"&radiuses={radiuses_wide}"
        )
        try:
            async with httpx.AsyncClient(timeout=60) as client:
                resp = await client.get(url_wide)
                data = resp.json()
        except Exception as e:
            raise HTTPException(503, f"OSRM недоступен: {e}")

    if data.get("code") != "Ok" or not data.get("routes"):
        osrm_code = data.get("code", "Unknown")
        osrm_msg = data.get("message", "")
        if osrm_code == "NoRoute":
            raise HTTPException(404, "Маршрут не найден: нет пути между точками")
        elif osrm_code == "NoSegment":
            raise HTTPException(404, "Маршрут не найден: точки слишком далеко от дорог")
        elif osrm_code == "InvalidValue":
            raise HTTPException(400, f"Некорректные координаты: {osrm_msg}")
        else:
            raise HTTPException(404, f"Маршрут не найден ({osrm_code})")

    # Открываем БД один раз для всех маршрутов
    try:
        conn = get_db()
    except Exception:
        conn = None

    routes_out = []
    for idx, route in enumerate(data["routes"]):
        geometry = route["geometry"]
        distance_m = route["distance"]
        duration_s = route["duration"]

        # Считаем статистику покрытия
        stats = None
        if conn is not None:
            try:
                stats = calc_route_stats(geometry, conn)
            except Exception:
                stats = None

        routes_out.append({
            "index": idx,
            "geometry": geometry,
            "distance_m": round(distance_m),
            "duration_s": round(duration_s),
            "stats": stats,
        })

    if conn is not None:
        try:
            conn.close()
        except Exception:
            pass

    return {"routes": routes_out}


# Обратная совместимость — старый GET endpoint (для линейки и прочего)
@app.get("/api/route")
async def get_route(
    from_lon: float, from_lat: float,
    to_lon: float, to_lat: float
):
    """Роутинг через OSRM (legacy GET). Параметры: from_lon, from_lat, to_lon, to_lat"""
    url = (
        f"{OSRM_URL}/route/v1/driving/"
        f"{from_lon},{from_lat};{to_lon},{to_lat}"
        f"?overview=full&geometries=geojson&annotations=false"
    )
    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(url)
            data = resp.json()
    except Exception as e:
        raise HTTPException(503, f"OSRM недоступен: {e}")

    if data.get("code") != "Ok" or not data.get("routes"):
        raise HTTPException(404, "Маршрут не найден")

    route = data["routes"][0]
    geometry = route["geometry"]
    distance_m = route["distance"]
    duration_s = route["duration"]

    return {
        "type": "Feature",
        "geometry": geometry,
        "properties": {
            "distance_m": round(distance_m),
            "distance_km": round(distance_m / 1000, 1),
            "duration_min": round(duration_s / 60),
            "duration_s": round(duration_s),
        }
    }


@app.get("/api/health")
async def health():
    return {
        "status": "ok",
        "db_path": DATA_PATH,
        "db_exists": os.path.exists(DATA_PATH),
    }


# ─── Terrain tiles ───────────────────────────────────────────────────────────

TERRAIN_DIR = os.environ.get(
    "TERRAIN_DIR",
    os.path.join(os.path.dirname(__file__), "../data/terrain"),
)

@app.get("/terrain/{layer}/{z}/{x}/{y}.png")
async def terrain_tile(layer: str, z: int, x: int, y: int):
    """Отдаёт растровые тайлы рельефа (hypso/hillshade/tri).

    ET-013: добавлен слой ``tri`` (Terrain Ruggedness Index) в whitelist.
    Фронтенд (`src/web/app.js`, ``onTerrainCheckbox``) запрашивает
    ``/terrain/tri/{z}/{x}/{y}.png`` для слоя «Перепады высот». На
    test/prod-среде эти запросы перехватывает nginx и отдаёт PNG
    напрямую с диска, но в dev-режиме (``make dev`` → FastAPI на :5556
    без nginx) endpoint должен поддерживать ``tri`` нативно.
    См. review ET-013 F-1.
    """
    if layer not in ("hypso", "hillshade", "tri"):
        raise HTTPException(404, "Unknown layer")
    tile_path = os.path.join(TERRAIN_DIR, layer, str(z), str(x), f"{y}.png")
    if not os.path.exists(tile_path):
        raise HTTPException(404, "Tile not found")
    return FileResponse(
        tile_path,
        media_type="image/png",
        headers={
            "Cache-Control": "public, max-age=31536000, immutable",
            "Access-Control-Allow-Origin": "*",
        }
    )


# ─── Static files ─────────────────────────────────────────────────────────────

# ET-011 / ADR-015: GPS_SOURCES_CONFIG_PATH объявлен в начале файла рядом с
# GPS_TRACKS_DB_PATH; здесь только создаём router после того, как `app` определён.
gps_router = create_gps_router(GPS_TRACKS_DB_PATH, GPS_SOURCES_CONFIG_PATH)
app.include_router(gps_router)

if os.path.exists(STATIC_DIR):
    app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")


if __name__ == "__main__":
    print(f"==> Enduro Trails API  DB={DATA_PATH}  Port={PORT}")
    uvicorn.run("app:app", host="0.0.0.0", port=PORT, workers=4)
