"""Reading route files someone gave the user -- GPX 1.0/1.1, and the cheap
extras KML/KMZ and TCX (AV-053) -- into one neutral shape, with errors the
page shows as they are (Polish, one sentence each).

Safe by construction: defusedxml refuses DTDs, entities and external
references (the system's expat here predates its own entity-expansion
guard); a file over MAX_FILE_BYTES is refused before parsing; a KMZ is read
only for its first .kml and only up to MAX_FILE_BYTES unpacked.

What comes out is what the file says, nothing more: per track (``trk``,
``rte``, a KML LineString, a TCX lap) its segments of points with the
file's own elevation and time where present (None where absent -- never a
guess); waypoints with their name and type. Elevation from the file is kept
only for comparison: the route's elevation always comes from the
application's own DEM (gpx_import.py). Times are metadata only.

The older research ingester (mountain_twin/trails/gpx.py) reads the
registry-backed TMB fixtures; this one reads whatever a user uploads.
"""

from __future__ import annotations

import io
import math
import zipfile
from dataclasses import dataclass, field
from datetime import datetime

from defusedxml import DefusedXmlException
from defusedxml import ElementTree as SafeElementTree

from .elevation_gain import plausible_elevation

MAX_FILE_BYTES = 25 * 1024 * 1024
# AV-064: how route_export.py marks a transfer (read back here).
APP_NS = "https://echtro.app/gpx/1"
TRANSFER_TYPE = "dojazd"
MAX_POINTS = 500_000  # a whole multi-week trip at 1 s logging is far below this

GPX_NAMESPACES = ("http://www.topografix.com/GPX/1/1", "http://www.topografix.com/GPX/1/0")
KML_NAMESPACES = (
    "http://www.opengis.net/kml/2.2",
    "http://earth.google.com/kml/2.2",
    "http://earth.google.com/kml/2.1",
    "http://earth.google.com/kml/2.0",
)
TCX_NAMESPACE = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
GX_NAMESPACE = "http://www.google.com/kml/ext/2.2"


class RouteFileError(ValueError):
    """A file that cannot be imported; ``str(error)`` is the user's message."""

    def __init__(self, code: str, message: str):
        super().__init__(message)
        self.code = code


@dataclass(frozen=True)
class FilePoint:
    latitude: float
    longitude: float
    elevation_m: float | None = None
    time: datetime | None = None


@dataclass(frozen=True)
class FileTrack:
    """One ``trk`` / ``rte`` / LineString / lap: its segments in file order."""

    name: str | None
    kind: str  # TRACK | ROUTE
    segments: tuple[tuple[FilePoint, ...], ...]
    # AV-064: per segment, whether it is a transfer (dojazd) -- as this
    # application exports it: <type>dojazd</type> on the track, or its own
    # extension on the track segment. Empty: none is.
    transfer_segments: tuple[bool, ...] = ()


@dataclass(frozen=True)
class FileWaypoint:
    latitude: float
    longitude: float
    name: str | None
    kind: str | None  # the file's own <type>/<sym>, as written
    elevation_m: float | None = None


@dataclass(frozen=True)
class RouteFile:
    file_format: str  # GPX_1_0 | GPX_1_1 | KML | KMZ | TCX
    name: str | None
    creator: str | None
    tracks: tuple[FileTrack, ...]
    waypoints: tuple[FileWaypoint, ...] = field(default_factory=tuple)

    @property
    def point_count(self) -> int:
        return sum(len(segment) for track in self.tracks for segment in track.segments)


def _number(text: str | None) -> float | None:
    if text is None or not text.strip():
        return None
    try:
        value = float(text)
    except ValueError:
        return None
    return value if math.isfinite(value) else None


def _time(text: str | None) -> datetime | None:
    if not text or not text.strip():
        return None
    value = text.strip().replace("Z", "+00:00")
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        # Fractions longer than Python 3.9 reads (".123456789"): cut them.
        head, dot, rest = value.partition(".")
        if not dot:
            return None
        digits = "".join(ch for ch in rest if ch.isdigit())
        zone = rest[len(digits) :]
        try:
            parsed = datetime.fromisoformat(f"{head}.{digits[:6].ljust(6, '0')}{zone}")
        except ValueError:
            return None
    return parsed if parsed.tzinfo is not None else None  # a time without a zone says nothing


def _point(latitude, longitude, elevation=None, time=None, *, where: str) -> FilePoint:
    lat, lon = _number(latitude), _number(longitude)
    if lat is None or lon is None or not -90 <= lat <= 90 or not -180 <= lon <= 180:
        raise RouteFileError("BAD_POINT", f"Plik ma punkt z błędnymi współrzędnymi ({where}).")
    # A no-data marker in the file (-32768 and the like) is no height.
    return FilePoint(lat, lon, plausible_elevation(_number(elevation)), _time(time))


def _text(element, tag: str) -> str | None:
    child = element.find(tag)
    if child is None or child.text is None:
        return None
    return child.text.strip() or None


def _parse_xml(data: bytes):
    try:
        return SafeElementTree.fromstring(data, forbid_dtd=True)
    except DefusedXmlException as error:
        raise RouteFileError(
            "UNSAFE_XML", "Plik zawiera deklaracje XML, których nie wczytujemy (DTD lub encje)."
        ) from error
    except SafeElementTree.ParseError as error:
        raise RouteFileError(
            "BAD_XML", "Plik jest uszkodzony: to nie jest poprawny XML."
        ) from error


def _namespace(tag: str) -> tuple[str, str]:
    if tag.startswith("{"):
        uri, _, local = tag[1:].partition("}")
        return uri, local
    return "", tag


def read_route_file(data: bytes, file_name: str = "") -> RouteFile:
    """The file's tracks and waypoints, or RouteFileError with the reason."""
    if len(data) > MAX_FILE_BYTES:
        raise RouteFileError(
            "TOO_LARGE",
            f"Plik jest za duży ({len(data) / 1048576:.0f} MB); największy, jaki wczytamy, "
            f"ma {MAX_FILE_BYTES // 1048576} MB.",
        )
    if not data.strip():
        raise RouteFileError("EMPTY", "Plik jest pusty.")
    if data[:2] == b"PK" or file_name.lower().endswith(".kmz"):
        return _read_kmz(data)
    root = _parse_xml(data)
    uri, local = _namespace(root.tag)
    if local == "gpx" and uri in ("", *GPX_NAMESPACES):
        parsed = _read_gpx(root, uri)
    elif local == "kml":
        parsed = _read_kml(root, uri, "KML")
    elif local == "TrainingCenterDatabase":
        parsed = _read_tcx(root, uri)
    else:
        raise RouteFileError(
            "UNKNOWN_FORMAT", "To nie jest plik GPX (ani KML, KMZ czy TCX), który umiemy wczytać."
        )
    if parsed.point_count > MAX_POINTS:
        raise RouteFileError(
            "TOO_MANY_POINTS",
            f"Plik ma {parsed.point_count} punktów; wczytamy najwyżej {MAX_POINTS}.",
        )
    if not any(len(segment) >= 2 for track in parsed.tracks for segment in track.segments):
        raise RouteFileError(
            "NO_TRACK",
            "W pliku nie ma śladu ani trasy (same punkty POI albo ślad z jednym punktem).",
        )
    return parsed


def _read_gpx(root, uri: str) -> RouteFile:
    ns = f"{{{uri}}}" if uri else ""
    version = root.get("version") or ("1.0" if uri.endswith("/1/0") else "1.1")
    metadata = root.find(f"{ns}metadata")
    name = _text(metadata, f"{ns}name") if metadata is not None else _text(root, f"{ns}name")
    tracks = []
    for t_index, track in enumerate(root.findall(f"{ns}trk")):
        segments, transfers = [], []
        track_transfer = (_text(track, f"{ns}type") or "").lower() == TRANSFER_TYPE
        for s_index, segment in enumerate(track.findall(f"{ns}trkseg")):
            points = tuple(
                _point(
                    p.get("lat"),
                    p.get("lon"),
                    _text(p, f"{ns}ele"),
                    _text(p, f"{ns}time"),
                    where=f"ślad {t_index + 1}, odcinek {s_index + 1}, punkt {i + 1}",
                )
                for i, p in enumerate(segment.findall(f"{ns}trkpt"))
            )
            if points:
                segments.append(points)
                kind = segment.find(f"{ns}extensions/{{{APP_NS}}}kind")
                transfers.append(
                    track_transfer
                    or (kind is not None and (kind.text or "").strip().lower() == TRANSFER_TYPE)
                )
        if segments:
            tracks.append(
                FileTrack(
                    _text(track, f"{ns}name"),
                    "TRACK",
                    tuple(segments),
                    tuple(transfers) if any(transfers) else (),
                )
            )
    for r_index, route in enumerate(root.findall(f"{ns}rte")):
        points = tuple(
            _point(
                p.get("lat"),
                p.get("lon"),
                _text(p, f"{ns}ele"),
                _text(p, f"{ns}time"),
                where=f"trasa {r_index + 1}, punkt {i + 1}",
            )
            for i, p in enumerate(route.findall(f"{ns}rtept"))
        )
        if points:
            tracks.append(FileTrack(_text(route, f"{ns}name"), "ROUTE", (points,)))
    waypoints = []
    for i, wpt in enumerate(root.findall(f"{ns}wpt")):
        point = _point(
            wpt.get("lat"), wpt.get("lon"), _text(wpt, f"{ns}ele"), where=f"punkt POI {i + 1}"
        )
        waypoints.append(
            FileWaypoint(
                point.latitude,
                point.longitude,
                _text(wpt, f"{ns}name"),
                _text(wpt, f"{ns}type") or _text(wpt, f"{ns}sym"),
                point.elevation_m,
            )
        )
    return RouteFile(
        "GPX_1_0" if version.startswith("1.0") else "GPX_1_1",
        name,
        root.get("creator"),
        tuple(tracks),
        tuple(waypoints),
    )


def _kml_coordinates(text: str | None, where: str) -> tuple[FilePoint, ...]:
    points = []
    for i, chunk in enumerate((text or "").split()):
        parts = chunk.split(",")
        if len(parts) < 2:
            raise RouteFileError("BAD_POINT", f"Plik ma punkt z błędnymi współrzędnymi ({where}).")
        points.append(
            _point(
                parts[1],
                parts[0],
                parts[2] if len(parts) > 2 else None,
                where=f"{where}, punkt {i + 1}",
            )
        )
    return tuple(points)


def _read_kml(root, uri: str, file_format: str) -> RouteFile:
    ns = f"{{{uri}}}" if uri else ""
    gx = f"{{{GX_NAMESPACE}}}"
    tracks, waypoints = [], []
    document_name = None
    for element in root.iter():
        if element.tag in (f"{ns}Document", f"{ns}Folder") and document_name is None:
            document_name = _text(element, f"{ns}name")
    for p_index, placemark in enumerate(root.iter(f"{ns}Placemark")):
        name = _text(placemark, f"{ns}name")
        where = f"obiekt {p_index + 1}"
        lines = [
            _kml_coordinates(line.findtext(f"{ns}coordinates"), where)
            for line in placemark.iter(f"{ns}LineString")
        ]
        for track in placemark.iter(f"{gx}Track"):
            coords = [c.text for c in track.findall(f"{gx}coord")]
            whens = [w.text for w in track.findall(f"{ns}when")]
            points = []
            for i, coord in enumerate(coords):
                parts = (coord or "").split()
                if len(parts) < 2:
                    raise RouteFileError(
                        "BAD_POINT", f"Plik ma punkt z błędnymi współrzędnymi ({where})."
                    )
                points.append(
                    _point(
                        parts[1],
                        parts[0],
                        parts[2] if len(parts) > 2 else None,
                        whens[i] if i < len(whens) else None,
                        where=where,
                    )
                )
            lines.append(tuple(points))
        lines = [line for line in lines if line]
        if lines:
            tracks.append(FileTrack(name, "TRACK", tuple(lines)))
            continue
        for point_element in placemark.iter(f"{ns}Point"):
            for point in _kml_coordinates(point_element.findtext(f"{ns}coordinates"), where)[:1]:
                waypoints.append(
                    FileWaypoint(point.latitude, point.longitude, name, None, point.elevation_m)
                )
    return RouteFile(file_format, document_name, None, tuple(tracks), tuple(waypoints))


def _read_kmz(data: bytes) -> RouteFile:
    try:
        archive = zipfile.ZipFile(io.BytesIO(data))
        members = [m for m in archive.infolist() if m.filename.lower().endswith(".kml")]
        if not members:
            raise RouteFileError("NO_TRACK", "W archiwum KMZ nie ma pliku KML.")
        member = next((m for m in members if m.filename.lower() == "doc.kml"), members[0])
        if member.file_size > MAX_FILE_BYTES:
            raise RouteFileError("TOO_LARGE", "Plik KML w archiwum jest za duży.")
        with archive.open(member) as handle:
            inner = handle.read(MAX_FILE_BYTES + 1)
    except zipfile.BadZipFile as error:
        raise RouteFileError(
            "BAD_XML", "Plik jest uszkodzony: archiwum KMZ się nie otwiera."
        ) from error
    if len(inner) > MAX_FILE_BYTES:
        raise RouteFileError("TOO_LARGE", "Plik KML w archiwum jest za duży.")
    root = _parse_xml(inner)
    uri, local = _namespace(root.tag)
    if local != "kml":
        raise RouteFileError("UNKNOWN_FORMAT", "Archiwum KMZ nie zawiera poprawnego pliku KML.")
    parsed = _read_kml(root, uri, "KMZ")
    if not any(len(s) >= 2 for t in parsed.tracks for s in t.segments):
        raise RouteFileError(
            "NO_TRACK",
            "W pliku nie ma śladu ani trasy (same punkty POI albo ślad z jednym punktem).",
        )
    return parsed


def _read_tcx(root, uri: str) -> RouteFile:
    ns = f"{{{uri}}}" if uri else ""
    tracks = []

    def lap_points(container, where):
        points = []
        for track in container.iter(f"{ns}Track"):
            for i, tp in enumerate(track.findall(f"{ns}Trackpoint")):
                position = tp.find(f"{ns}Position")
                if position is None:
                    continue  # a pause or a sensor-only sample
                points.append(
                    _point(
                        _text(position, f"{ns}LatitudeDegrees"),
                        _text(position, f"{ns}LongitudeDegrees"),
                        _text(tp, f"{ns}AltitudeMeters"),
                        _text(tp, f"{ns}Time"),
                        where=f"{where}, punkt {i + 1}",
                    )
                )
        return tuple(points)

    for a_index, activity in enumerate(root.iter(f"{ns}Activity")):
        segments = [
            lap_points(lap, f"okrążenie {k + 1}")
            for k, lap in enumerate(activity.findall(f"{ns}Lap"))
        ]
        segments = [s for s in segments if s]
        if segments:
            tracks.append(FileTrack(_text(activity, f"{ns}Id"), "TRACK", tuple(segments)))
    for course in root.iter(f"{ns}Course"):
        points = lap_points(course, "kurs")
        if points:
            tracks.append(FileTrack(_text(course, f"{ns}Name"), "ROUTE", (points,)))
    return RouteFile("TCX", None, None, tuple(tracks), ())
