"""API v1 (AV-054 E2, docs/API_V1.md): versioned JSON for the mobile app.

One route table (ROUTES) drives the router, the isolation test and the
contract document. Everything is the signed-in user's own: a saved Journey
of someone else -- or the reference TMB Journey, which is the web app's
showcase only -- is 404 like one that does not exist (the gate in
scripts/serve_visual_prototype.py checks it before this code runs; the
handlers here query by owner again).

* Errors: ``{"error": {"code": "...", "message": "..."}}`` (message in Polish).
* Sync: ``GET /api/v1/journeys?updated_since=ISO`` returns what changed after
  that moment and the tombstones of what was deleted, plus ``server_time``
  for the next call. A Journey's ``updated_at`` moves with its route and plan
  revisions (they are immutable, so their ``created_at`` is the change).
* Idempotency: a write with ``Idempotency-Key`` is done once; the same key
  with the same request returns the first answer (24 h,
  ``Idempotent-Replayed: true``); with a different request it is 422.
* Limits: RATE_PER_MINUTE requests per token (or per signed-in user).
* No CORS: the mobile app is not a browser page on another origin; nothing
  here sends Access-Control-* headers.
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from urllib.parse import unquote

from mountain_twin.journey.persistence import JourneySqliteDatabase, _transaction
from mountain_twin.journey.plan_persistence import (
    CreateJourneyPlan,
    JourneyPlanRepository,
    camp_marker_inputs_from_payload,
    journey_plan_document,
)
from mountain_twin.journey.route_persistence import (
    CreateRouteRevision,
    JourneyRouteRepository,
    route_points_from_payload,
)

API_PREFIX = "/api/v1"
DEFAULT_RATE_PER_MINUTE = 120
IDEMPOTENCY_HOURS = 24
# How long a deleted Journey's tombstone is kept (persistence.delete): a
# client whose updated_since is older cannot learn every deletion from the
# tombstones and is told to synchronise everything (full_sync_required).
TOMBSTONE_DAYS = 30
ID = r"(?P<journey_id>[^/]+)"
TOKEN = r"(?P<token_id>[^/]+)"


@dataclass(frozen=True)
class Route:
    method: str
    pattern: str  # under /api/v1, a regex with named groups
    handler: str
    journey_scoped: bool = False
    write: bool = False

    @property
    def regex(self) -> re.Pattern:
        return re.compile(f"^{self.pattern}$")


# The route table: the router, tests/test_api_v1.py (every journey-scoped
# route is tried by another user and must be 404) and docs/API_V1.md.
ROUTES: tuple[Route, ...] = (
    Route("GET", "/me", "me"),
    Route("GET", "/tokens", "list_tokens"),
    Route("POST", "/tokens", "create_token", write=True),
    Route("DELETE", f"/tokens/{TOKEN}", "revoke_token", write=True),
    Route("GET", "/journeys", "list_journeys"),
    Route("POST", "/journeys", "create_journey", write=True),
    Route("GET", f"/journeys/{ID}", "get_journey", journey_scoped=True),
    Route("DELETE", f"/journeys/{ID}", "delete_journey", journey_scoped=True, write=True),
    Route("GET", f"/journeys/{ID}/route", "get_route", journey_scoped=True),
    Route("PUT", f"/journeys/{ID}/route", "put_route", journey_scoped=True, write=True),
    Route("GET", f"/journeys/{ID}/plan", "get_plan", journey_scoped=True),
    Route("PUT", f"/journeys/{ID}/plan", "put_plan", journey_scoped=True, write=True),
    Route("GET", f"/journeys/{ID}/days", "get_days", journey_scoped=True),
    Route("GET", f"/journeys/{ID}/weather", "get_weather", journey_scoped=True),
    Route("GET", f"/journeys/{ID}/gpx", "get_gpx", journey_scoped=True),
)


class ApiError(Exception):
    def __init__(self, status: int, code: str, message: str, headers: dict[str, str] | None = None):
        super().__init__(message)
        self.status, self.code, self.headers = status, code, headers or {}


def error_document(code: str, message: str) -> dict[str, Any]:
    return {"error": {"code": code, "message": message}}


def match(method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
    """(the route, its path parameters, whether the path exists at all)."""
    sub = path[len(API_PREFIX) :] or "/"
    known = False
    for route in ROUTES:
        found = route.regex.match(sub)
        if found:
            known = True
            if route.method == method:
                # Path parameters arrive percent-encoded (journey ids hold ":").
                return (
                    route,
                    {key: unquote(value) for key, value in found.groupdict().items()},
                    True,
                )
    return None, {}, known


class RateLimiter:
    """RATE_PER_MINUTE requests per principal (token id or user), sliding."""

    def __init__(self, per_minute: int | None = None, *, clock=time.monotonic):
        if per_minute is None:
            try:
                per_minute = int(os.environ.get("API_RATE_PER_MINUTE") or DEFAULT_RATE_PER_MINUTE)
            except ValueError:
                per_minute = DEFAULT_RATE_PER_MINUTE
        self.per_minute = max(1, per_minute)
        self._hits: dict[str, deque] = defaultdict(deque)
        self._lock = threading.Lock()
        self._clock = clock

    def check(self, principal: str) -> int:
        """0 to go on, or the seconds to wait."""
        now = self._clock()
        with self._lock:
            hits = self._hits[principal]
            while hits and now - hits[0] > 60:
                hits.popleft()
            if len(hits) >= self.per_minute:
                return int(60 - (now - hits[0])) + 1
            hits.append(now)
            return 0


def _iso_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_since(raw: str | None) -> str | None:
    if not raw:
        return None
    try:
        moment = datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError as error:
        raise ApiError(
            400, "BAD_UPDATED_SINCE", "updated_since: data i godzina w ISO 8601."
        ) from error
    if moment.tzinfo is None:
        raise ApiError(400, "BAD_UPDATED_SINCE", "updated_since musi mieć strefę (np. Z).")
    return moment.astimezone(timezone.utc).isoformat()


def full_segments(point_count: int, transfers: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """The route as consecutive stretches covering every step: MOVE between
    the stored TRANSFER spans (route_segments.py)."""
    out, cursor = [], 0
    for span in sorted(transfers or (), key=lambda item: item["start_point_index"]):
        if span["start_point_index"] > cursor:
            out.append(
                {
                    "start_point_index": cursor,
                    "end_point_index": span["start_point_index"],
                    "kind": "MOVE",
                    "label": None,
                }
            )
        out.append({**span, "kind": "TRANSFER"})
        cursor = span["end_point_index"]
    if cursor < point_count - 1:
        out.append(
            {
                "start_point_index": cursor,
                "end_point_index": point_count - 1,
                "kind": "MOVE",
                "label": None,
            }
        )
    return out


@dataclass
class Context:
    """What a request brings: the database, the owner and the server's own
    builders for documents that live in the server (days, weather, GPX)."""

    journey_db: Any
    owner_id: str
    principal: str
    session: Any  # accounts Session, or None in AUTH_MODE=none
    accounts_store: Callable[[], Any] | None
    days: Callable[[str], dict[str, Any]]
    weather: Callable[[str, dict], dict[str, Any]]
    gpx: Callable[[str, dict], tuple[bytes, str]]
    delete_journey: Callable[[str], None]
    create_journey: Callable[[dict], dict[str, Any]]


class ApiV1:
    def __init__(self, limiter: RateLimiter):
        self.limiter = limiter

    # --- the entry point -------------------------------------------------------------
    def handle(
        self, ctx: Context, method: str, path: str, query: dict, raw_body: bytes, headers: dict
    ) -> tuple[int, Any, dict[str, str]]:
        """(status, JSON document or (bytes, content type), headers)."""
        try:
            wait = self.limiter.check(ctx.principal)
            if wait:
                raise ApiError(
                    429,
                    "RATE_LIMITED",
                    f"Za dużo zapytań. Spróbuj ponownie za {wait} s.",
                    {"Retry-After": str(wait)},
                )
            route, params, known = match(method, path)
            if route is None:
                if known:
                    raise ApiError(
                        405, "METHOD_NOT_ALLOWED", "Ta metoda nie jest dostępna dla tego adresu."
                    )
                raise ApiError(404, "NOT_FOUND", "Nie ma takiego zasobu.")
            body = self._json(raw_body) if method in ("POST", "PUT", "PATCH") else {}
            key = (headers.get("Idempotency-Key") or headers.get("idempotency-key") or "").strip()
            if route.write and key:
                return self._idempotent(
                    ctx, route, params, query, body, method, path, raw_body, key
                )
            return self._run(ctx, route, params, query, body)
        except ApiError as error:
            return error.status, error_document(error.code, str(error)), error.headers

    def _run(self, ctx, route, params, query, body):
        status, document = getattr(self, route.handler)(ctx, query=query, body=body, **params)
        return status, document, {}

    @staticmethod
    def _json(raw: bytes) -> dict[str, Any]:
        if not raw:
            return {}
        try:
            value = json.loads(raw)
        except ValueError as error:
            raise ApiError(400, "BAD_JSON", "Nieprawidłowy JSON.") from error
        if not isinstance(value, dict):
            raise ApiError(400, "BAD_JSON", "Oczekiwany obiekt JSON.")
        return value

    def _idempotent(self, ctx, route, params, query, body, method, path, raw_body, key):
        if len(key) > 200:
            raise ApiError(400, "BAD_IDEMPOTENCY_KEY", "Idempotency-Key jest za długi.")
        request_hash = hashlib.sha256(f"{method} {path}\n".encode() + (raw_body or b"")).hexdigest()
        connection = JourneySqliteDatabase(ctx.journey_db).connect()
        try:
            row = connection.execute(
                "SELECT request_hash, status, response_json, created_at FROM api_idempotency"
                " WHERE owner_id = ? AND idempotency_key = ?",
                (ctx.owner_id, key),
            ).fetchone()
            fresh = row is not None and datetime.fromisoformat(row["created_at"]) > datetime.now(
                timezone.utc
            ) - timedelta(hours=IDEMPOTENCY_HOURS)
            if fresh:
                if row["request_hash"] != request_hash:
                    raise ApiError(
                        422,
                        "IDEMPOTENCY_KEY_REUSED",
                        "Ten Idempotency-Key był już użyty do innego zapytania.",
                    )
                return (
                    row["status"],
                    json.loads(row["response_json"]),
                    {"Idempotent-Replayed": "true"},
                )
            status, document, headers = self._run(ctx, route, params, query, body)
            if status < 500 and isinstance(document, dict):
                with _transaction(connection):
                    connection.execute(
                        "DELETE FROM api_idempotency WHERE created_at < ?",
                        (
                            (
                                datetime.now(timezone.utc) - timedelta(hours=IDEMPOTENCY_HOURS)
                            ).isoformat(),
                        ),
                    )
                    connection.execute(
                        "INSERT OR REPLACE INTO api_idempotency(owner_id, idempotency_key, request_hash,"
                        " status, response_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
                        (
                            ctx.owner_id,
                            key,
                            request_hash,
                            status,
                            json.dumps(document, sort_keys=True),
                            _iso_now(),
                        ),
                    )
            return status, document, headers
        finally:
            connection.close()

    # --- account ---------------------------------------------------------------------
    def me(self, ctx, **_):
        if ctx.session is None:
            return 200, {"user": None, "mode": "local"}
        return 200, {
            "user": ctx.session.user.to_dict(),
            "mode": "accounts",
            "via_token": ctx.session.via_token,
        }

    def _store(self, ctx):
        if ctx.accounts_store is None or ctx.session is None:
            raise ApiError(404, "NO_ACCOUNTS", "Ten serwer działa bez kont.")
        return ctx.accounts_store()

    def list_tokens(self, ctx, **_):
        store = self._store(ctx)
        try:
            return 200, {"tokens": store.api_tokens(ctx.session.user.user_id)}
        finally:
            store.close()

    def create_token(self, ctx, body, **_):
        if ctx.session is not None and ctx.session.via_token:
            raise ApiError(
                403, "SESSION_REQUIRED", "Nowy token tworzy się w panelu „Konto” po zalogowaniu."
            )
        store = self._store(ctx)
        try:
            from mountain_twin.accounts.store import AccountError

            try:
                token, record = store.create_api_token(
                    ctx.session.user.user_id, body.get("device_name")
                )
            except AccountError as error:
                raise ApiError(error.status, error.code, str(error)) from error
            return 201, {**record, "token": token, "shown_once": True}
        finally:
            store.close()

    def revoke_token(self, ctx, token_id, **_):
        store = self._store(ctx)
        try:
            if not store.revoke_api_token(ctx.session.user.user_id, token_id):
                raise ApiError(404, "NOT_FOUND", "Nie ma takiego tokenu.")
            return 200, {"revoked": token_id}
        finally:
            store.close()

    # --- Journeys --------------------------------------------------------------------
    _JOURNEY_SQL = """
        SELECT j.journey_id, j.title, j.created_at, j.completed_at, j.current_route_revision_id,
               j.current_plan_id, r.activity_id,
               max(j.updated_at, coalesce(r.created_at, ''), coalesce(p.created_at, ''),
                   coalesce(j.completed_at, '')) AS updated_at
          FROM journeys j
          LEFT JOIN journey_routes r ON r.route_revision_id = j.current_route_revision_id
          LEFT JOIN journey_plans p ON p.journey_plan_id = j.current_plan_id
         WHERE j.owner_id = ?"""

    @staticmethod
    def _journey(row) -> dict[str, Any]:
        return {
            "journey_id": row["journey_id"],
            "title": row["title"],
            "created_at": row["created_at"],
            "updated_at": row["updated_at"],
            "completed_at": row["completed_at"],
            "route_revision_id": row["current_route_revision_id"],
            "plan_id": row["current_plan_id"],
            "activity_id": row["activity_id"]
            or ("trekking" if row["current_route_revision_id"] else None),
        }

    def list_journeys(self, ctx, query, **_):
        since = _parse_since((query.get("updated_since") or [None])[0])
        server_time = _iso_now()
        oldest_tombstone = (datetime.now(timezone.utc) - timedelta(days=TOMBSTONE_DAYS)).isoformat()
        # Offline longer than the tombstones are kept: the whole list, and the
        # client replaces its own (what is missing from it was deleted).
        full_sync = since is not None and since < oldest_tombstone
        if full_sync:
            since = None
        connection = JourneySqliteDatabase(ctx.journey_db).connect()
        try:
            rows = connection.execute(
                self._JOURNEY_SQL + " ORDER BY j.created_at", (ctx.owner_id,)
            ).fetchall()
            journeys = [
                self._journey(row) for row in rows if since is None or row["updated_at"] > since
            ]
            deleted = [
                {"journey_id": row["journey_id"], "deleted_at": row["deleted_at"]}
                for row in connection.execute(
                    "SELECT journey_id, deleted_at FROM journey_deletions WHERE owner_id = ?"
                    + (" AND deleted_at > ?" if since else "")
                    + " ORDER BY deleted_at",
                    (ctx.owner_id, since) if since else (ctx.owner_id,),
                )
            ]
        finally:
            connection.close()
        return 200, {
            "journeys": journeys,
            "deleted": [] if full_sync else deleted,
            "server_time": server_time,
            "full_sync_required": full_sync,
        }

    def _one(self, ctx, journey_id) -> dict[str, Any]:
        connection = JourneySqliteDatabase(ctx.journey_db).connect()
        try:
            row = connection.execute(
                self._JOURNEY_SQL + " AND j.journey_id = ?", (ctx.owner_id, journey_id)
            ).fetchone()
        finally:
            connection.close()
        if row is None:
            raise ApiError(404, "NOT_FOUND", "Nie ma takiej wyprawy.")
        return self._journey(row)

    def create_journey(self, ctx, body, **_):
        title = str(body.get("title") or "").strip()
        if not title:
            raise ApiError(400, "TITLE_REQUIRED", "Podaj nazwę wyprawy.")
        try:
            created = ctx.create_journey(
                {
                    "title": title,
                    **({"date_intent": body["date_intent"]} if body.get("date_intent") else {}),
                }
            )
        except ValueError as error:
            raise ApiError(400, "BAD_REQUEST", str(error)) from error
        return 201, {"journey": self._one(ctx, created["journey"]["journey_id"])}

    def get_journey(self, ctx, journey_id, **_):
        return 200, {"journey": self._one(ctx, journey_id)}

    def delete_journey(self, ctx, journey_id, **_):
        self._one(ctx, journey_id)
        ctx.delete_journey(journey_id)
        return 200, {"deleted": journey_id}

    # --- route -----------------------------------------------------------------------
    def get_route(self, ctx, journey_id, **_):
        self._one(ctx, journey_id)
        repository = JourneyRouteRepository(JourneySqliteDatabase(ctx.journey_db))
        try:
            result = repository.get_current_route(journey_id=journey_id, owner_id=ctx.owner_id)
            anchors = repository.get_current_anchor_point_indexes(
                journey_id=journey_id, owner_id=ctx.owner_id
            )
        finally:
            repository.close()
        if result is None:
            return 200, {"route": None}
        route, geometry = result
        coordinates = geometry["coordinates"]
        return 200, {
            "route": {
                "route_revision_id": route.route_revision_id,
                "route_id": route.route_id,
                "activity_id": route.activity_id,
                "points": [
                    {
                        "latitude": c[1],
                        "longitude": c[0],
                        "elevation_m": c[2] if len(c) > 2 else None,
                    }
                    for c in coordinates
                ],
                "segments": full_segments(len(coordinates), geometry.get("segments") or []),
                "anchor_point_indexes": None if anchors is None else list(anchors),
            }
        }

    def put_route(self, ctx, journey_id, body, **_):
        journey = self._one(ctx, journey_id)
        try:
            points = route_points_from_payload(body["points"])
            transfers = [
                {key: item.get(key) for key in ("start_point_index", "end_point_index", "label")}
                | {"kind": "TRANSFER"}
                for item in body.get("segments") or []
                if isinstance(item, dict) and item.get("kind", "MOVE") == "TRANSFER"
            ]
            anchors = body.get("anchor_point_indexes")
            command = CreateRouteRevision(
                owner_id=ctx.owner_id,
                journey_id=journey_id,
                route_name=str(body.get("route_name") or journey["title"]).strip()
                or journey["title"],
                points=points,
                anchor_point_indexes=None if anchors is None else tuple(anchors),
                activity_id=body.get("activity_id"),
                segments=tuple(transfers),
            )
        except (KeyError, TypeError, ValueError) as error:
            raise ApiError(400, "BAD_ROUTE", str(error)) from error
        repository = JourneyRouteRepository(JourneySqliteDatabase(ctx.journey_db))
        try:
            repository.create_route_revision(command)
        finally:
            repository.close()
        status, document = self.get_route(ctx, journey_id)
        return 201, document

    # --- plan, days, weather, GPX ------------------------------------------------------
    def get_plan(self, ctx, journey_id, **_):
        self._one(ctx, journey_id)
        repository = JourneyPlanRepository(JourneySqliteDatabase(ctx.journey_db))
        try:
            plan = repository.get_current_plan(journey_id=journey_id, owner_id=ctx.owner_id)
        finally:
            repository.close()
        return 200, {"plan": None if plan is None else journey_plan_document(plan)}

    def put_plan(self, ctx, journey_id, body, **_):
        self._one(ctx, journey_id)
        try:
            command = CreateJourneyPlan(
                owner_id=ctx.owner_id,
                journey_id=journey_id,
                pace_profile_id=body.get("pace_profile_id") or "normal_hiker",
                journey_timezone=body.get("journey_timezone"),
                planned_start_local=body.get("planned_start_local"),
                planned_start_time=body.get("planned_start_time"),
                activity_type=body.get("activity_type"),
                camp_markers=camp_marker_inputs_from_payload(body.get("camp_markers") or []),
                rest_days_before_start=body.get("rest_days_before_start"),
                rest_days_after_finish=body.get("rest_days_after_finish"),
            )
            repository = JourneyPlanRepository(JourneySqliteDatabase(ctx.journey_db))
            try:
                plan = repository.create_plan(command)
            finally:
                repository.close()
        except KeyError as error:
            raise ApiError(409, "NO_ROUTE", "Plan wymaga zapisanej trasy.") from error
        except (TypeError, ValueError) as error:
            raise ApiError(400, "BAD_PLAN", str(error)) from error
        return 201, {"plan": journey_plan_document(plan)}

    def get_days(self, ctx, journey_id, **_):
        self._one(ctx, journey_id)
        return 200, ctx.days(journey_id)

    def get_weather(self, ctx, journey_id, query, **_):
        self._one(ctx, journey_id)
        try:
            return 200, ctx.weather(journey_id, query)
        except ValueError as error:
            raise ApiError(409, "NOT_AVAILABLE", str(error)) from error

    def get_gpx(self, ctx, journey_id, query, **_):
        self._one(ctx, journey_id)
        try:
            body, name = ctx.gpx(journey_id, query)
        except LookupError as error:
            raise ApiError(404, "NO_ROUTE", "Ta wyprawa nie ma jeszcze trasy.") from error
        except ValueError as error:
            raise ApiError(400, "BAD_REQUEST", str(error)) from error
        return 200, (body, "application/gpx+xml", name)
