"""GPX-экспорт публичных GPS-треков (ET-011, ADR-014).

Сборка GPX 1.1 из метаданных трека + санитизация имени файла для
HTTP Content-Disposition с поддержкой RFC 5987 (UTF-8 filename*).

Чистый stdlib-модуль, без I/O — легко тестируется юнитами.
"""
from __future__ import annotations

import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

# OSM-license URL для блока <copyright> (ADR-014 §G, ODbL).
_OSM_LICENSE_URL = "https://www.openstreetmap.org/copyright"

# Запрещённые в FAT/NTFS символы (ADR-014 §F.2).
_FORBIDDEN_NAME_CHARS = set('/\\:*?"<>|')

# Лимит длины ASCII-fallback по байтам UTF-8 (ADR-014 §F.5; RFC 5987 — 254
# на параметр, минус префикс "filename*=UTF-8''" и расширение).
_MAX_NAME_BYTES = 80


def _format_utc(iso_str: str | None) -> str | None:
    """Нормализует ISO-8601 datetime → 'YYYY-MM-DDTHH:MM:SSZ' (UTC).

    Поддерживает входные строки с/без таймзоны. None / нераспарсимое — None.
    """
    if not iso_str:
        return None
    try:
        # Python 3.11+ fromisoformat понимает 'Z'-суффикс; для надёжности
        # делаем явную замену.
        normalized = iso_str.replace("Z", "+00:00")
        dt = datetime.fromisoformat(normalized)
    except (ValueError, TypeError):
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    else:
        dt = dt.astimezone(timezone.utc)
    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")


def build_gpx(
    *,
    track_id: int,
    name: str | None,
    description: str | None,
    activity_type: str | None,
    user: str | None,
    created_at: str | None,
    sources: list[str],
    external_urls: list[str],
    coords: list[tuple[float, float]],
) -> str:
    """Собирает GPX 1.1 как XML-строку (с XML-declaration).

    Args:
        track_id: id трека (используется только в fallback-имени).
        name: tracks.name (если пусто — в `<name>` ставится «Без названия»).
        description: tracks.description (если пусто — `<desc>` опускается).
        activity_type: tracks.activity_type, попадает в `<trk><type>`.
        user: tracks.user — попадает в `<metadata><author><name>`.
        created_at: ISO-8601 строка → нормализуется в UTC `<metadata><time>`.
        sources: список source_id (для `<copyright>` и `<link><text>`).
        external_urls: список внешних URL → `<metadata><link>` по одному.
        coords: список (lon, lat) — точки трека.

    Returns:
        XML-строка (включает `<?xml …?>`-декларацию).
    """
    # GPX namespace должен быть default — иначе ET создаёт префикс ns0:gpx.
    gpx_ns = "http://www.topografix.com/GPX/1/1"
    xsi_ns = "http://www.w3.org/2001/XMLSchema-instance"
    ET.register_namespace("", gpx_ns)
    ET.register_namespace("xsi", xsi_ns)

    root = ET.Element(
        f"{{{gpx_ns}}}gpx",
        {
            "version": "1.1",
            "creator": "Enduro Trails",
            f"{{{xsi_ns}}}schemaLocation": (
                "http://www.topografix.com/GPX/1/1 "
                "http://www.topografix.com/GPX/1/1/gpx.xsd"
            ),
        },
    )

    # ─── <metadata> ───────────────────────────────────────────────
    # Порядок дочерних элементов фиксирован XSD-схемой GPX 1.1:
    # name, desc, author, copyright, link*, time, keywords, bounds, extensions.
    # Любое отклонение → DocumentInvalid (см. UT-03).
    metadata = ET.SubElement(root, f"{{{gpx_ns}}}metadata")

    meta_name = ET.SubElement(metadata, f"{{{gpx_ns}}}name")
    meta_name.text = (name or "").strip() or "Без названия"

    desc_clean = (description or "").strip()
    if desc_clean:
        desc_el = ET.SubElement(metadata, f"{{{gpx_ns}}}desc")
        desc_el.text = desc_clean

    user_clean = (user or "").strip()
    if user_clean:
        author_el = ET.SubElement(metadata, f"{{{gpx_ns}}}author")
        author_name = ET.SubElement(author_el, f"{{{gpx_ns}}}name")
        author_name.text = user_clean

    # <copyright>: OSM → официальная ODbL-ссылка (ADR-014 §G).
    # Для не-OSM источников: license = первый external_url (если есть),
    # иначе блок опускаем.
    sources_list = list(sources or [])
    if "osm" in sources_list:
        cr_el = ET.SubElement(
            metadata, f"{{{gpx_ns}}}copyright", {"author": "Enduro Trails"}
        )
        lic_el = ET.SubElement(cr_el, f"{{{gpx_ns}}}license")
        lic_el.text = _OSM_LICENSE_URL
    elif external_urls:
        first_url = next((u for u in external_urls if u), None)
        if first_url:
            cr_el = ET.SubElement(
                metadata, f"{{{gpx_ns}}}copyright", {"author": "Enduro Trails"}
            )
            lic_el = ET.SubElement(cr_el, f"{{{gpx_ns}}}license")
            lic_el.text = first_url

    # <link> на каждый external_url; <text> = "Источник: <source_id>".
    # ADR-014 §G: по одному `<link>` на каждый элемент external_urls.
    src_for_link = list(sources or [])
    for idx, url in enumerate(external_urls or []):
        if not url:
            continue
        link_el = ET.SubElement(metadata, f"{{{gpx_ns}}}link", {"href": url})
        text_el = ET.SubElement(link_el, f"{{{gpx_ns}}}text")
        src_label = src_for_link[idx] if idx < len(src_for_link) else (
            src_for_link[0] if src_for_link else ""
        )
        text_el.text = (
            f"Источник: {src_label}" if src_label else "Источник"
        )

    time_str = _format_utc(created_at)
    if time_str:
        time_el = ET.SubElement(metadata, f"{{{gpx_ns}}}time")
        time_el.text = time_str

    # ─── <trk> ────────────────────────────────────────────────────
    trk = ET.SubElement(root, f"{{{gpx_ns}}}trk")
    trk_name = ET.SubElement(trk, f"{{{gpx_ns}}}name")
    trk_name.text = (name or "").strip() or "Без названия"

    act_clean = (activity_type or "").strip()
    if act_clean:
        trk_type = ET.SubElement(trk, f"{{{gpx_ns}}}type")
        trk_type.text = act_clean

    trkseg = ET.SubElement(trk, f"{{{gpx_ns}}}trkseg")
    # Координаты приходят как (lon, lat) из _wkb_to_coords (см. mvt.py).
    # GPX: lat/lon атрибуты с фиксированной точностью 6 знаков
    # (~0.11 м, ADR-014 §G).
    for lon, lat in coords or []:
        ET.SubElement(
            trkseg,
            f"{{{gpx_ns}}}trkpt",
            {"lat": f"{lat:.6f}", "lon": f"{lon:.6f}"},
        )

    # ET.tostring с xml_declaration=True даёт нужный prolog.
    xml_bytes = ET.tostring(
        root,
        encoding="utf-8",
        xml_declaration=True,
    )
    return xml_bytes.decode("utf-8")


def _sanitize_for_filesystem(name: str) -> str:
    """Заменяет запрещённые / управляющие символы на '_'.

    Затем триммит пробелы и точки по краям (Windows-нюанс).
    """
    out_chars: list[str] = []
    for ch in name:
        code = ord(ch)
        if ch in _FORBIDDEN_NAME_CHARS:
            out_chars.append("_")
        elif code < 0x20 or code == 0x7F:
            out_chars.append("_")
        else:
            out_chars.append(ch)
    return "".join(out_chars).strip(" .")


def _truncate_utf8(name: str, max_bytes: int) -> str:
    """Триммит строку так, чтобы её UTF-8-длина не превышала max_bytes."""
    encoded = name.encode("utf-8")
    if len(encoded) <= max_bytes:
        return name
    # Декодируем с ignore, чтобы не обрезать середину code-point'а.
    return encoded[:max_bytes].decode("utf-8", errors="ignore")


def _ascii_fallback(name: str) -> str:
    """ASCII-fallback для параметра `filename=` (без `*`).

    ADR-014 §F.7: транслитерации **не делаем**; non-ASCII / non-printable
    символы заменяем на '_'. Если результат пуст — caller подставит
    'track-<id>'.
    """
    out: list[str] = []
    for ch in name:
        code = ord(ch)
        # 0x20..0x7E — printable ASCII, исключая запрещённые ФС-символы
        # (они уже подменены в _sanitize_for_filesystem, но на всякий случай).
        if 0x20 <= code <= 0x7E and ch not in _FORBIDDEN_NAME_CHARS:
            out.append(ch)
        else:
            out.append("_")
    return "".join(out).strip(" .")


def safe_filename(name: str | None, track_id: int) -> tuple[str, str]:
    """Возвращает (ascii_fallback, utf8_percent_quoted) без расширения.

    Алгоритм (ADR-014 §F):
        1. Пустой/None → 'track-<id>'.
        2. Запрещённые / управляющие символы → '_'.
        3. Триммим пробелы и точки.
        4. Триммим до 80 байт UTF-8.
        5. Пустой результат → 'track-<id>'.
        6. ASCII-fallback: только printable ASCII; non-ASCII → '_'.
        7. UTF-8 quoted: urllib.parse.quote(name, safe='', encoding='utf-8').

    Args:
        name: исходное имя (tracks.name) — может быть None / пустым.
        track_id: id трека для fallback-имени.

    Returns:
        Кортеж (ascii_fallback, utf8_percent_quoted). Оба без расширения.
    """
    fallback = f"track-{track_id}"

    raw = (name or "").strip()
    if not raw:
        return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")

    sanitized = _sanitize_for_filesystem(raw)
    if not sanitized:
        return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")

    truncated = _truncate_utf8(sanitized, _MAX_NAME_BYTES).strip(" .")
    if not truncated:
        return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")

    utf8_quoted = urllib.parse.quote(truncated, safe="", encoding="utf-8")

    ascii_ok = _ascii_fallback(truncated)
    if not ascii_ok:
        ascii_ok = fallback

    return ascii_ok, utf8_quoted
