"""Парсер OSM Public GPS Traces (ET-008)."""
import asyncio
import math
import logging
from typing import AsyncGenerator

import defusedxml.ElementTree as ET
import httpx

from src.api.gps_tracks.models import TrackInsert
from src.api.gps_tracks.sources.base import SourceParser

logger = logging.getLogger(__name__)

# Пространства имён GPX
_GPX_NS = {
    "gpx0": "http://www.topografix.com/GPX/1/0",
    "gpx1": "http://www.topografix.com/GPX/1/1",
}


class OsmParser(SourceParser):
    """Парсер OSM Public GPS Traces API."""

    MAPPING = {
        "enduro": "enduro",
        "moto": "moto",
        "motorcycle": "moto",
        "mtb": "bicycle",
        "bicycle": "bicycle",
        "bike": "bicycle",
        "hike": "hike",
        "hiking": "hike",
        "running": "hike",
        "ski": "ski",
        "skiing": "ski",
        "offroad": "offroad",
        "4x4": "offroad",
    }

    async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
        """Собирает треки из OSM Public GPS Traces API.

        Args:
            bbox: (west, south, east, north)
            ctx: контекст (может содержать 'dry_run', 'session')

        Yields:
            TrackInsert объекты
        """
        west, south, east, north = bbox
        rate_limit = self.config.get("rate_limit_sec", 1)
        base_url = self.config.get("base_url", "https://api.openstreetmap.org/api/0.6")
        user_agent = self.config.get("user_agent", "enduro-trails/1.0")
        source_id = self.config.get("id", "osm")
        ext_url_template = self.config.get("external_url_template", "")

        headers = {"User-Agent": user_agent}

        # Разбиваем bbox на ячейки 0.25°
        cells = split_bbox_for_osm((west, south, east, north))

        async with httpx.AsyncClient(timeout=30, headers=headers) as client:
            for cell_bbox in cells:
                cell_west, cell_south, cell_east, cell_north = cell_bbox
                page = 0
                while True:
                    url = (
                        f"{base_url}/trackpoints"
                        f"?bbox={cell_west},{cell_south},{cell_east},{cell_north}"
                        f"&page={page}"
                    )
                    try:
                        resp = await _fetch_with_backoff(client, url)
                        if resp is None:
                            break
                        if resp.status_code == 204:
                            break
                        if resp.status_code != 200:
                            logger.warning("OSM API returned %d for %s", resp.status_code, url)
                            break
                        content = resp.content
                    except Exception as exc:
                        logger.error("Error fetching %s: %s", url, exc)
                        break

                    # Парсим GPX ответ
                    tracks = _parse_gpx_trackpoints(content, source_id, ext_url_template)

                    if not tracks:
                        break  # Пустая страница — больше треков нет

                    # Обогащаем треки метаданными из OSM API
                    gpx_ids = [t.external_id for t in tracks]
                    meta_map = await _batch_fetch_gpx_meta(
                        client, base_url, gpx_ids, headers, rate_limit
                    )

                    for track in tracks:
                        meta = meta_map.get(track.external_id)
                        if meta:
                            updates = {}
                            if meta.get("activity_type") is not None:
                                updates["activity_type"] = meta["activity_type"]
                            if meta.get("name") is not None:
                                updates["name"] = meta["name"]
                            if meta.get("description") is not None:
                                updates["description"] = meta["description"]
                            if meta.get("user") is not None:
                                updates["user"] = meta["user"]
                            if updates:
                                track = track.model_copy(update=updates)
                        yield track

                    page += 1
                    await asyncio.sleep(rate_limit)


def split_bbox_for_osm(region_bbox: tuple, cell_size: float = 0.25) -> list:
    """Разбивает регион на ячейки cell_size градусов для OSM API.

    OSM API требует bbox не более 0.25° x 0.25°.

    Args:
        region_bbox: (west, south, east, north)
        cell_size: размер ячейки в градусах (по умолчанию 0.25)

    Returns:
        list of (west, south, east, north) tuples
    """
    west, south, east, north = region_bbox
    cells = []

    # Перебираем ячейки с запада на восток, с юга на север
    lat = south
    while lat < north:
        cell_south = lat
        cell_north = min(lat + cell_size, north)
        lon = west
        while lon < east:
            cell_west = lon
            cell_east = min(lon + cell_size, east)
            cells.append((
                round(cell_west, 6),
                round(cell_south, 6),
                round(cell_east, 6),
                round(cell_north, 6),
            ))
            lon += cell_size
        lat += cell_size

    return cells


def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> 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_track_length(coords: list) -> float:
    """Считает длину трека через Haversine."""
    total = 0.0
    for i in range(len(coords) - 1):
        total += _haversine_m(coords[i][0], coords[i][1], coords[i + 1][0], coords[i + 1][1])
    return total


def _parse_gpx_trackpoints(content: bytes, source_id: str, ext_url_template: str) -> list:
    """Парсит GPX-ответ OSM API с треками.

    Группирует trkpt по атрибуту gpx_id.
    Анонимные точки (без gpx_id) пропускаются.

    Returns:
        list[TrackInsert]
    """
    try:
        # defusedxml защищает от XXE
        root = ET.fromstring(content)
    except Exception as exc:
        logger.error("Failed to parse GPX: %s", exc)
        return []

    # Группируем точки по gpx_id
    tracks_points: dict = {}

    # Определяем namespace
    ns = ""
    tag = root.tag
    if tag.startswith("{"):
        ns = tag.split("}")[0] + "}"

    # Ищем trkpt напрямую и через trk/trkseg
    trkpt_elements = []

    # Вариант 1: OSM возвращает trkpt напрямую в корне (API 0.6 trackpoints endpoint)
    for child in root:
        local = child.tag.replace(ns, "") if ns else child.tag
        if local == "trk":
            for trkseg in child:
                local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
                if local2 == "trkseg":
                    for trkpt in trkseg:
                        trkpt_elements.append(trkpt)
        elif local == "trkpt":
            trkpt_elements.append(child)

    for trkpt in trkpt_elements:
        gpx_id = trkpt.get("gpx_id") or trkpt.get("{http://www.topografix.com/GPX/1/0}gpx_id")
        if not gpx_id:
            # Анонимные точки — пропускаем
            continue

        try:
            lat = float(trkpt.get("lat", 0))
            lon = float(trkpt.get("lon", 0))
        except (TypeError, ValueError):
            continue

        if gpx_id not in tracks_points:
            tracks_points[gpx_id] = []

        # Получаем время из дочернего элемента
        time_elem = None
        for child in trkpt:
            local = child.tag.replace(ns, "") if ns else child.tag
            if local == "time":
                time_elem = child
                break

        time_str = time_elem.text if time_elem is not None else None
        tracks_points[gpx_id].append((lon, lat, time_str))

    results = []
    for gpx_id, points in tracks_points.items():
        if len(points) < 2:
            continue

        coords = [(p[0], p[1]) for p in points]

        # Вычисляем bbox
        lons = [c[0] for c in coords]
        lats = [c[1] for c in coords]
        min_lon, max_lon = min(lons), max(lons)
        min_lat, max_lat = min(lats), max(lats)

        # Длина трека
        length_m = _calc_track_length(coords)
        if length_m < 10:  # Слишком короткий трек — пропускаем
            continue

        # Дата из первой точки с временем
        created_at = None
        for p in points:
            if p[2]:
                created_at = p[2][:19].replace("T", "T")  # ISO без миллисекунд
                break

        # WKB из shapely
        try:
            from shapely.geometry import LineString
            from shapely import wkb

            geom = LineString(coords)
            geom_wkb = wkb.dumps(geom)
        except Exception:
            continue

        # External URL
        ext_url = None
        if ext_url_template:
            ext_url = ext_url_template.format(
                user="",
                external_id_numeric=gpx_id,
            )

        track = TrackInsert(
            external_id=str(gpx_id),
            source_id=source_id,
            external_url=ext_url,
            name=None,
            description=None,
            activity_type="other",
            user=None,
            created_at=created_at,
            length_m=length_m,
            points_count=len(coords),
            geom_wkb=geom_wkb,
            min_lon=min_lon,
            min_lat=min_lat,
            max_lon=max_lon,
            max_lat=max_lat,
            tags=[],
            source_priority=50,
        )
        results.append(track)

    return results


async def _fetch_with_backoff(
    client: httpx.AsyncClient,
    url: str,
    max_retries: int = 3,
) -> httpx.Response | None:
    """Выполняет HTTP-запрос с экспоненциальным backoff."""
    for attempt in range(max_retries):
        try:
            resp = await client.get(url)
            if resp.status_code == 429:
                wait = 2 ** attempt * 2
                logger.warning("Rate limited, waiting %ds", wait)
                await asyncio.sleep(wait)
                continue
            return resp
        except httpx.TimeoutException:
            wait = 2 ** attempt
            logger.warning("Timeout on attempt %d, waiting %ds", attempt + 1, wait)
            await asyncio.sleep(wait)
        except Exception as exc:
            logger.error("Request failed: %s", exc)
            return None
    return None


def _parse_gpx_meta_response(content: bytes) -> dict | None:
    """Парсит XML-ответ OSM API /gpx/<id>.

    Returns:
        dict с ключами activity_type, name, description, user или None при ошибке XML.
        Если gpx_file элемент отсутствует — возвращает dict со всеми None-значениями.
    """
    try:
        root = ET.fromstring(content)
    except Exception as exc:
        logger.debug("Failed to parse GPX meta XML: %s", exc)
        return None

    gpx_file = root.find("gpx_file")
    if gpx_file is None:
        return {"activity_type": None, "name": None, "description": None, "user": None}

    name = gpx_file.get("name")
    user = gpx_file.get("user")

    desc_elem = gpx_file.find("description")
    description = desc_elem.text if desc_elem is not None else None

    # Сопоставляем теги через MAPPING (берём первое совпадение)
    activity_type = None
    for tag_elem in gpx_file.findall("tag"):
        tag_text = (tag_elem.text or "").strip().lower()
        if tag_text in OsmParser.MAPPING:
            activity_type = OsmParser.MAPPING[tag_text]
            break

    return {
        "activity_type": activity_type,
        "name": name,
        "description": description,
        "user": user,
    }


async def _fetch_gpx_meta(
    client: httpx.AsyncClient,
    base_url: str,
    gpx_id: str,
    headers: dict,
) -> dict | None:
    """Загружает метаданные одного GPX-трека через OSM API /gpx/<id>."""
    url = f"{base_url}/gpx/{gpx_id}"
    try:
        resp = await _fetch_with_backoff(client, url)
        if resp is None or resp.status_code != 200:
            return None
        return _parse_gpx_meta_response(resp.content)
    except Exception as exc:
        logger.warning("Failed to fetch GPX meta for %s: %s", gpx_id, exc)
        return None


async def _batch_fetch_gpx_meta(
    client: httpx.AsyncClient,
    base_url: str,
    gpx_ids: list,
    headers: dict,
    rate_limit: float,
    batch_size: int = 20,
) -> dict:
    """Загружает метаданные GPX-треков пакетами через asyncio.gather.

    Returns:
        dict {gpx_id: meta_dict}
    """
    result = {}
    for i in range(0, len(gpx_ids), batch_size):
        batch = gpx_ids[i: i + batch_size]
        metas = await asyncio.gather(
            *[_fetch_gpx_meta(client, base_url, gid, headers) for gid in batch],
            return_exceptions=False,
        )
        for gid, meta in zip(batch, metas):
            if meta is not None:
                result[gid] = meta
        if i + batch_size < len(gpx_ids):
            await asyncio.sleep(rate_limit)
    return result
