"""The live-tracking HTTP surface (AV-022), free of any server framework:
``LiveTrackingHttp.handle()`` takes a request and returns a response, so the
local development server and the public server (scripts/serve_live_tracking.py
behind nginx) answer every request with the same code.

Public (by token only; nothing here lists or finds a Journey):
    GET  /live/v/{token}                 the view page
    GET  /live/p/{token}                 the push page
    GET  /live/assets/{file}             the pages' scripts and styles (a fixed list)
    GET  /live/api/map                   which basemap the view page may use
    GET  /live/api/v/{token}             route + positions        (401 if a password is set and not given)
    GET  /live/api/p/{token}             last send + limits
    POST /live/api/p/{token}             one position             (429 with retry_after_s past the limits)
    POST /live/api/{v|p}/{token}/unlock  {"password"} -> HttpOnly cookie for this link only

Owner (``Authorization: Bearer <admin secret>``; absent when no secret is set):
    GET    /live/admin/api/journeys/{id}                 status, links (no tokens), last positions
    PUT    /live/admin/api/journeys/{id}                 {"title", "route_revision_id", "route": [[lat, lon], ...]}
    DELETE /live/admin/api/journeys/{id}                 everything about it
    POST   /live/admin/api/journeys/{id}/links/{kind}    {"password"?} -> {"token", ...} (only time it is shown)
    DELETE /live/admin/api/journeys/{id}/links/{kind}
    DELETE /live/admin/api/journeys/{id}/positions
"""

from __future__ import annotations

import hmac
import json
import mimetypes
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from urllib.parse import unquote

from mountain_twin.live_tracking.service import LiveTrackingError, LiveTrackingService

REPO_ROOT = Path(__file__).resolve().parents[2]
# Published name -> file (relative to the code root). Nothing else is served.
ASSETS = {
    "brand_runtime.js": "aventurro/brand_runtime.js",
    "styles.css": "aventurro/styles.css",
    "mission_map.js": "aventurro/mission_map.js",
    "basemap_style.js": "visual_prototype/basemap_style.js",
    "map_provider.js": "visual_prototype/map_provider.js",
    "live.css": "aventurro/live/live.css",
    "live_common.js": "aventurro/live/live_common.js",
    "live_view.js": "aventurro/live/live_view.js",
    "live_push.js": "aventurro/live/live_push.js",
    "app_icon.svg": "aventurro/assets/brand/lentiqa/app_icon.svg",
}
PAGES = {"v": "aventurro/live/view.html", "p": "aventurro/live/push.html"}
KIND_OF = {"v": "view", "p": "push"}
PUBLIC_BODY_MAX = 4096
ADMIN_BODY_MAX = 8 * 1024 * 1024
MAPLIBRE = "https://unpkg.com"
CSP = (
    "default-src 'none'; "
    f"script-src 'self' {MAPLIBRE}; "
    f"style-src 'self' 'unsafe-inline' {MAPLIBRE}; "
    "img-src 'self' data: blob: https: http:; "
    "connect-src 'self' https: http:; "
    "worker-src blob:; child-src blob:; "
    "base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
)


@dataclass
class Response:
    status: int
    body: bytes = b""
    content_type: str = "application/json; charset=utf-8"
    headers: list = field(default_factory=list)


class LiveTrackingHttp:
    def __init__(
        self,
        service: LiveTrackingService,
        *,
        code_root: Path = REPO_ROOT,
        admin_secret: Optional[str] = None,
        brand_script: Callable[[], str],
        map_config: Callable[[], dict[str, Any]],
    ):
        self.service = service
        self.code_root = code_root
        self.admin_secret = admin_secret or None
        self.brand_script = brand_script
        self.map_config = map_config

    def handle(
        self, method: str, path: str, headers: Mapping[str, str], body: bytes, *, secure: bool
    ) -> Optional[Response]:
        """None when the path is not live tracking's."""
        if not path.startswith("/live/"):
            return None
        parts = [unquote(part) for part in path.strip("/").split("/")]
        try:
            if parts[1:3] == ["admin", "api"]:
                return self._admin(method, parts[3:], headers, body)
            response = self._public(method, parts[1:], headers, body, secure)
        except LiveTrackingError as error:
            response = _json(error.status, error.document())
            if error.extra.get("retry_after_s"):
                response.headers.append(("Retry-After", str(error.extra["retry_after_s"])))
        return response

    # --- public -----------------------------------------------------------
    def _public(self, method, parts, headers, body, secure) -> Response:
        if len(parts) == 2 and parts[0] in PAGES and method == "GET":
            return self._file(PAGES[parts[0]], "text/html; charset=utf-8")
        if len(parts) == 2 and parts[0] == "assets" and method == "GET":
            if parts[1] == "brand.js":
                return _private(
                    Response(
                        200, self.brand_script().encode("utf-8"), "text/javascript; charset=utf-8"
                    )
                )
            if parts[1] in ASSETS:
                return self._file(ASSETS[parts[1]], None)
        if parts == ["api", "map"] and method == "GET":
            return _json(200, self.map_config())
        if len(parts) >= 3 and parts[0] == "api" and parts[1] in KIND_OF:
            kind, token, rest = KIND_OF[parts[1]], parts[2], parts[3:]
            if rest == ["unlock"] and method == "POST":
                password = _body(body, PUBLIC_BODY_MAX).get("password")
                result = self.service.unlock(kind, token, password)
                response = _json(200, {"unlocked": True})
                if result["grant"]:
                    response.headers.append(
                        (
                            "Set-Cookie",
                            _cookie(result["link_id"], result["grant"], result["max_age"], secure),
                        )
                    )
                return response
            if rest:
                return _json(404, {"error": "NOT_FOUND"})
            grant = _grant_cookie(headers, lambda: self.service.link_id_for(kind, token))
            if kind == "view" and method == "GET":
                return _json(200, self.service.view_document(token, grant))
            if kind == "push" and method == "GET":
                return _json(200, self.service.push_status(token, grant))
            if kind == "push" and method == "POST":
                return _json(201, self.service.push(token, _body(body, PUBLIC_BODY_MAX), grant))
            return _json(405, {"error": "METHOD"})
        return _json(404, {"error": "NOT_FOUND"})

    def _file(self, relative: str, content_type: Optional[str]) -> Response:
        path = self.code_root / relative
        if not path.is_file():
            return _json(404, {"error": "NOT_FOUND"})
        content_type = content_type or (
            mimetypes.guess_type(path.name)[0] or "application/octet-stream"
        )
        if content_type.startswith("text/") or content_type.endswith("javascript"):
            content_type = content_type.split(";")[0] + "; charset=utf-8"
        return _private(Response(200, path.read_bytes(), content_type))

    # --- owner ------------------------------------------------------------
    def _admin(self, method, parts, headers, body) -> Response:
        supplied = (_header(headers, "Authorization") or "").removeprefix("Bearer ").strip()
        if not self.admin_secret or not hmac.compare_digest(
            supplied.encode(), self.admin_secret.encode()
        ):
            return _json(401 if self.admin_secret else 404, {"error": "ADMIN_ONLY"})
        if len(parts) < 2 or parts[0] != "journeys":
            return _json(404, {"error": "NOT_FOUND"})
        journey_id, rest = parts[1], parts[2:]
        if not rest:
            if method == "GET":
                return _json(200, self.service.status(journey_id))
            if method == "PUT":
                payload = _body(body, ADMIN_BODY_MAX)
                return _json(
                    200,
                    self.service.publish(
                        journey_id,
                        payload.get("title"),
                        payload.get("route_revision_id"),
                        payload.get("route"),
                    ),
                )
            if method == "DELETE":
                self.service.delete_journey(journey_id)
                return _json(200, {"deleted": journey_id})
        if len(rest) == 2 and rest[0] == "links":
            if method == "POST":
                return _json(
                    201,
                    self.service.issue_link(
                        journey_id, rest[1], _body(body, PUBLIC_BODY_MAX).get("password")
                    ),
                )
            if method == "DELETE":
                return _json(200, {"revoked": self.service.revoke_link(journey_id, rest[1])})
        if rest == ["positions"] and method == "DELETE":
            return _json(200, {"deleted": self.service.delete_positions(journey_id)})
        return _json(405, {"error": "METHOD"})


SECURITY_HEADERS = [
    ("Cache-Control", "no-store"),
    ("Referrer-Policy", "no-referrer"),
    ("X-Robots-Tag", "noindex, nofollow"),
    ("X-Content-Type-Options", "nosniff"),
    ("Content-Security-Policy", CSP),
    ("Permissions-Policy", "geolocation=(self), camera=(), microphone=()"),
]


def _private(response: Response) -> Response:
    response.headers.extend(SECURITY_HEADERS)
    return response


def _json(status: int, document: Any) -> Response:
    return _private(Response(status, json.dumps(document, ensure_ascii=False).encode("utf-8")))


def _body(body: bytes, limit: int) -> dict[str, Any]:
    if len(body) > limit:
        raise LiveTrackingError(413, "TOO_LARGE", "Za duże zapytanie.")
    try:
        document = json.loads(body or b"{}")
    except (json.JSONDecodeError, UnicodeDecodeError):
        raise LiveTrackingError(400, "JSON", "Nieprawidłowe zapytanie.") from None
    if not isinstance(document, dict):
        raise LiveTrackingError(400, "JSON", "Nieprawidłowe zapytanie.")
    return document


def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
    for key, value in headers.items():
        if key.lower() == name.lower():
            return value
    return None


COOKIE_PREFIX = "live_grant_"


def _cookie(link_id: str, grant: str, max_age: int, secure: bool) -> str:
    return (
        f"{COOKIE_PREFIX}{link_id}={grant}; Max-Age={max_age}; Path=/live/; HttpOnly; SameSite=Strict"
        + ("; Secure" if secure else "")
    )


def _grant_cookie(headers: Mapping[str, str], link_id: Callable[[], str]) -> Optional[str]:
    raw = _header(headers, "Cookie") or ""
    if COOKIE_PREFIX not in raw:
        return None
    wanted = COOKIE_PREFIX + link_id()
    for part in raw.split(";"):
        name, _, value = part.strip().partition("=")
        if name == wanted and re.fullmatch(r"[0-9]+\.[A-Za-z0-9_-]+", value):
            return value
    return None
