"""The HTTP-free side of GPX import and export (AV-053), called by
scripts/serve_visual_prototype.py:

* ``POST /api/gpx/preview?file_name=...`` (the file's bytes as the body):
  the route as Planning Workspace opens it (route_import.py) and an
  ``import_id``; nothing is saved.
* ``POST /api/gpx/match`` ``{import_id, points, stage_end_indexes,
  activity_id}``: the matched-to-trails version (route_match.py).
* the route's save (``POST /api/journeys/{id}/route``) may carry ``import``
  ``{import_id, mode, matched, points_of_interest}``: recorded next to the
  new revision (route_imports.py).
* ``GET /api/journeys/{id}/gpx-options``: what can be downloaded;
  ``GET /api/journeys/{id}/gpx?day=N&layout=segments|tracks&original=1``:
  the file (route_export.py).
"""

from __future__ import annotations

import re
import unicodedata
from typing import Any

from mountain_twin.brand import brand

from .activities import activity_for
from .route_export import ExportWaypoint, build_gpx, stages_from_camps
from .route_files import RouteFileError, read_route_file
from .route_import import import_route
from .route_imports import IMPORT_MODES, PendingImports, clean_points_of_interest
from .route_match import match_to_trails
from .route_persistence import _geometry_fingerprint, route_points_from_payload
from .route_segments import transfer_steps


def safe_file_name(raw: str | None) -> str:
    name = (raw or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
    name = "".join(ch for ch in name if ch.isprintable())[:200]
    return name or "trasa.gpx"


def preview(
    data: bytes, file_name: str, pending: PendingImports, dem, owner: str | None = None
) -> tuple[int, dict[str, Any]]:
    try:
        route_file = read_route_file(data, file_name)
    except RouteFileError as error:
        return (413 if error.code == "TOO_LARGE" else 400), {
            "error": str(error),
            "code": error.code,
        }
    imported = import_route(route_file, dem=dem)
    document = imported.to_dict()
    fingerprint = _geometry_fingerprint(route_points_from_payload(imported.points))
    import_id = pending.add(
        file_name, route_file.file_format, data, imported.summary, fingerprint, owner=owner
    )
    return 200, {"import_id": import_id, "file_name": file_name, **document}


def match(
    payload: dict[str, Any], pending: PendingImports, opener, dem, owner: str | None = None
) -> tuple[int, dict[str, Any]]:
    import_id = payload.get("import_id")
    if pending.get(import_id, owner=owner) is None:
        return 404, {"error": "Podgląd pliku wygasł — wgraj plik jeszcze raz.", "code": "EXPIRED"}
    points = payload["points"]
    if not isinstance(points, list) or len(points) < 2:
        raise ValueError("points must be a list of at least two points")
    ends = [int(i) for i in payload.get("stage_end_indexes") or []]
    try:
        document = match_to_trails(
            points,
            ends,
            profile=activity_for(payload.get("activity_id")).routing_profile,
            opener=opener,
            dem=dem,
        )
    except ValueError as error:
        return 400, {"error": str(error), "code": "TOO_LONG"}
    except RuntimeError as error:
        return 502, {
            "error": "Nie udało się dopasować trasy do szlaków (serwer tras nie odpowiedział).",
            "code": str(error),
        }
    pending.add_fingerprint(
        import_id, _geometry_fingerprint(route_points_from_payload(document["points"]))
    )
    return 200, document


def import_request(raw: Any) -> dict[str, Any] | None:
    """The route save's ``import`` part, checked (None: not from a file)."""
    if raw is None:
        return None
    if not isinstance(raw, dict):
        raise ValueError("import must be an object")
    mode = raw.get("mode")
    if mode not in IMPORT_MODES:
        raise ValueError(f"import mode must be one of {IMPORT_MODES}")
    return {
        "import_id": str(raw.get("import_id") or ""),
        "file_name": safe_file_name(raw.get("file_name")),
        "mode": mode,
        "matched": bool(raw.get("matched")),
        "points_of_interest": clean_points_of_interest(raw.get("points_of_interest")),
    }


def _ascii_slug(text: str) -> str:
    plain = unicodedata.normalize("NFKD", text.replace("ł", "l").replace("Ł", "L"))
    plain = plain.encode("ascii", "ignore").decode("ascii")
    return re.sub(r"[^A-Za-z0-9]+", "_", plain).strip("_")[:60] or "trasa"


def ascii_file_name(name: str) -> str:
    """A download name for the plain ``filename=`` (``filename*`` has the real one)."""
    stem, dot, extension = name.rpartition(".")
    if not dot:
        stem, extension = name, "gpx"
    return f"{_ascii_slug(stem)}.{_ascii_slug(extension).lower() or 'gpx'}"


def export_options(
    *, geometry: dict[str, Any], camps: list[dict[str, Any]], record
) -> dict[str, Any]:
    stages = stages_from_camps(
        len(geometry["coordinates"]),
        [(camp["route_point_index"], camp["label"]) for camp in camps],
        "start",
    )
    return {
        "days": [
            {"day_number": n, "label": stage.label} for n, stage in enumerate(stages, start=1)
        ],
        "layouts": ["segments", "tracks"],
        "original": None
        if not record or not record["original_available"]
        else {
            "file_name": record["file_name"],
            "edited_before_save": record["summary"].get("edited_before_save"),
        },
    }


def export_gpx(
    *,
    title: str,
    geometry: dict[str, Any],
    camps: list[dict[str, Any]],
    record: dict[str, Any] | None,
    day: int | None,
    layout: str,
    link: str | None,
) -> tuple[bytes, str]:
    """(file bytes, download name). ``record``: the revision's import row."""
    coordinates = geometry["coordinates"]
    points = [(c[1], c[0], c[2] if len(c) > 2 else None) for c in coordinates]
    stages = stages_from_camps(
        len(points), [(camp["route_point_index"], camp["label"]) for camp in camps], "start"
    )
    if day is not None:
        if not 1 <= day <= len(stages):
            raise ValueError(f"day must be between 1 and {len(stages)}")
        chosen = [stages[day - 1]]
    else:
        chosen = stages
    first, last = chosen[0].start_index, chosen[-1].end_index
    waypoints = [
        ExportWaypoint(
            points[camp["route_point_index"]][0],
            points[camp["route_point_index"]][1],
            camp["label"],
            "CAMP",
            points[camp["route_point_index"]][2],
        )
        for camp in sorted(camps, key=lambda c: c["route_point_index"])
        if first <= camp["route_point_index"] <= last
    ]
    for poi in (record or {}).get("points_of_interest") or []:
        waypoints.append(
            ExportWaypoint(
                poi["latitude"],
                poi["longitude"],
                poi.get("name") or "Punkt",
                "POI",
                description="poza trasą" if poi.get("off_route") else None,
            )
        )
    # The file's own line, never matched: not OpenStreetMap's geometry.
    osm_geometry = not (
        record and record["mode"] in ("NEW", "REPLACE") and not record["matched_to_trails"]
    )
    application = brand()["name"]
    name = title if day is None else f"{title} — {chosen[0].label}"
    body = build_gpx(
        name=name,
        application=application,
        link=link,
        points=points,
        stages=chosen,
        waypoints=waypoints,
        layout=layout,
        osm_geometry=osm_geometry,
        # AV-064: transfers as their own track segments / tracks.
        transfer_steps=transfer_steps(geometry.get("segments") or (), len(points)),
        description=None
        if record is None
        else f"Trasa z pliku {record['file_name']}"
        + (", dopasowana do szlaków" if record["matched_to_trails"] else ""),
    )
    suffix = "" if day is None else f"_D{day}"
    return body, f"{_ascii_slug(title)}{suffix}.gpx"
