"""Streaming, device-authenticated hand-training upload to ``mint/api_gateway``."""

from __future__ import annotations

import http.client
import json
import os
import re
import ssl
import stat
import uuid
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from urllib.parse import urlsplit


_SESSION_ID = re.compile(r"^[0-9a-f]{32}$")
_ROLES = ("tool", "workspace")
_CONTENT_TYPE = "video/x-motion-jpeg"


class HandTrainingUploadError(RuntimeError):
    """One bounded API-gateway upload failure."""


class HandTrainingUploadClient:
    """Stream a manifest and both video files without buffering them in RAM."""

    def __init__(
        self,
        *,
        upload_url: str,
        device_uid: str,
        device_secret: str,
        bearer_token_provider: Optional[Callable[..., str]] = None,
        endpoint_url_provider: Optional[Callable[[str], str]] = None,
        timeout_seconds: float = 60.0,
        maximum_upload_bytes: int = 768 * 1024 * 1024,
        allow_insecure_http: bool = False,
        connection_factory: Optional[Callable[..., Any]] = None,
    ) -> None:
        if not upload_url or not device_uid or not device_secret:
            raise ValueError("Hand-training upload URL and device credentials are required")
        self.upload_url = str(upload_url)
        self.device_uid = str(device_uid)
        self.device_secret = str(device_secret)
        self.bearer_token_provider = bearer_token_provider
        self.endpoint_url_provider = endpoint_url_provider
        self.timeout_seconds = float(timeout_seconds)
        self.maximum_upload_bytes = int(maximum_upload_bytes)
        self.allow_insecure_http = bool(allow_insecure_http)
        self.connection_factory = connection_factory
        if not 1.0 <= self.timeout_seconds <= 3600.0:
            raise ValueError("Hand-training upload timeout is invalid")
        if not 3 * 1024 * 1024 <= self.maximum_upload_bytes <= 3 * 1024**3:
            raise ValueError("Hand-training upload byte limit is invalid")
        self._validate_url(self._target_url())

    def upload(
        self,
        *,
        manifest_path: Path,
        video_paths: Mapping[str, Path],
        idempotency_key: str,
    ) -> Mapping[str, Any]:
        """Upload exactly one manifest, tool video, and workspace video."""

        if not isinstance(idempotency_key, str) or _SESSION_ID.fullmatch(
            idempotency_key
        ) is None:
            raise HandTrainingUploadError("Hand-training idempotency key is invalid")
        if set(video_paths) != set(_ROLES):
            raise HandTrainingUploadError("Both hand-training videos are required")
        paths = {"manifest": Path(manifest_path)}
        paths.update({role: Path(video_paths[role]) for role in _ROLES})
        sizes = {name: self._safe_file(path) for name, path in paths.items()}
        boundary = "labcobot-{}".format(uuid.uuid4().hex)
        parts = self._part_headers(boundary, paths)
        closing = "--{}--\r\n".format(boundary).encode("ascii")
        content_length = sum(
            len(parts[name]) + sizes[name] + 2 for name in ("manifest",) + _ROLES
        ) + len(closing)
        if content_length > self.maximum_upload_bytes:
            raise HandTrainingUploadError("Hand-training upload exceeds its byte limit")

        response = self._request(
            paths=paths,
            sizes=sizes,
            parts=parts,
            closing=closing,
            boundary=boundary,
            content_length=content_length,
            idempotency_key=idempotency_key,
            force_refresh=False,
        )
        if response[0] == 401 and self.bearer_token_provider is not None:
            response = self._request(
                paths=paths,
                sizes=sizes,
                parts=parts,
                closing=closing,
                boundary=boundary,
                content_length=content_length,
                idempotency_key=idempotency_key,
                force_refresh=True,
            )
        status_code, payload = response
        if not 200 <= status_code < 300:
            error = str(payload.get("error") or "upload_rejected")[:64]
            raise HandTrainingUploadError(
                "Hand-training upload returned HTTP {} ({})".format(
                    status_code, error
                )
            )
        if payload.get("status") not in {"recorded", "duplicate"}:
            raise HandTrainingUploadError("Hand-training upload receipt is invalid")
        recording_id = str(payload.get("recording_id") or "")
        if recording_id != idempotency_key:
            raise HandTrainingUploadError("Hand-training receipt identity is invalid")
        return dict(payload)

    def _request(
        self,
        *,
        paths: Mapping[str, Path],
        sizes: Mapping[str, int],
        parts: Mapping[str, bytes],
        closing: bytes,
        boundary: str,
        content_length: int,
        idempotency_key: str,
        force_refresh: bool,
    ) -> tuple[int, Mapping[str, Any]]:
        target = self._target_url()
        parsed = self._validate_url(target)
        connection = self._connection(parsed)
        request_target = parsed.path or "/"
        if parsed.query:
            request_target += "?" + parsed.query
        headers = {
            "Accept": "application/json",
            "Content-Type": "multipart/form-data; boundary={}".format(boundary),
            "Content-Length": str(content_length),
            "Idempotency-Key": idempotency_key,
            "X-Device-Id": self.device_uid,
            "X-Device-Secret": self.device_secret,
            "User-Agent": "LabCobot-HandTraining/1",
        }
        if self.bearer_token_provider is not None:
            try:
                token = str(
                    self.bearer_token_provider(
                        target,
                        force_refresh=force_refresh,
                    )
                    or ""
                ).strip()
            except Exception as exc:
                raise HandTrainingUploadError(
                    "Tenant login is unavailable for hand-training upload"
                ) from exc
            if token:
                headers["Authorization"] = "Bearer {}".format(token)
        try:
            connection.putrequest("POST", request_target)
            for name, value in headers.items():
                connection.putheader(name, value)
            connection.endheaders()
            for name in ("manifest",) + _ROLES:
                connection.send(parts[name])
                with paths[name].open("rb") as handle:
                    remaining = sizes[name]
                    while remaining:
                        chunk = handle.read(min(1024 * 1024, remaining))
                        if not chunk:
                            raise HandTrainingUploadError(
                                "Hand-training file changed during upload"
                            )
                        connection.send(chunk)
                        remaining -= len(chunk)
                connection.send(b"\r\n")
            connection.send(closing)
            response = connection.getresponse()
            raw = response.read(2 * 1024 * 1024 + 1)
            if len(raw) > 2 * 1024 * 1024:
                raise HandTrainingUploadError("API-gateway response is too large")
            try:
                payload = json.loads(raw.decode("utf-8"))
            except (UnicodeDecodeError, json.JSONDecodeError) as exc:
                raise HandTrainingUploadError(
                    "API-gateway response is not valid JSON"
                ) from exc
            if not isinstance(payload, Mapping):
                raise HandTrainingUploadError("API-gateway response is invalid")
            return int(response.status), dict(payload)
        except HandTrainingUploadError:
            raise
        except Exception as exc:
            raise HandTrainingUploadError(
                "Hand-training API-gateway transmission failed"
            ) from exc
        finally:
            try:
                connection.close()
            except Exception:
                pass

    def _target_url(self) -> str:
        if self.endpoint_url_provider is None:
            return self.upload_url
        try:
            resolved = str(self.endpoint_url_provider(self.upload_url) or "").strip()
        except Exception as exc:
            raise ValueError("Tenant upload endpoint is unavailable") from exc
        configured = urlsplit(self.upload_url)
        target = urlsplit(resolved)
        if (
            not resolved
            or configured.path != target.path
            or configured.query != target.query
            or target.fragment
        ):
            raise ValueError("Tenant upload endpoint changed the commissioned path")
        return resolved

    def _validate_url(self, value: str):
        parsed = urlsplit(value)
        try:
            port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
        except ValueError as exc:
            raise ValueError("Hand-training upload URL is invalid") from exc
        secure = parsed.scheme.lower() == "https" and port == 443
        local = bool(
            self.allow_insecure_http
            and parsed.scheme.lower() == "http"
            and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
        )
        if (
            not parsed.hostname
            or parsed.username is not None
            or parsed.password is not None
            or parsed.fragment
            or not (secure or local)
        ):
            raise ValueError("Hand-training upload URL must be tenant HTTPS")
        return parsed

    def _connection(self, parsed):
        port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
        if self.connection_factory is not None:
            return self.connection_factory(parsed.scheme, parsed.hostname, port, self.timeout_seconds)
        if parsed.scheme.lower() == "https":
            return http.client.HTTPSConnection(
                parsed.hostname,
                port,
                timeout=self.timeout_seconds,
                context=ssl.create_default_context(),
            )
        return http.client.HTTPConnection(
            parsed.hostname,
            port,
            timeout=self.timeout_seconds,
        )

    @staticmethod
    def _safe_file(path: Path) -> int:
        try:
            metadata = path.lstat()
        except OSError as exc:
            raise HandTrainingUploadError("Hand-training file is unavailable") from exc
        if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
            raise HandTrainingUploadError("Hand-training file path is unsafe")
        if metadata.st_size < 1:
            raise HandTrainingUploadError("Hand-training file is empty")
        return int(metadata.st_size)

    @staticmethod
    def _part_headers(boundary: str, paths: Mapping[str, Path]) -> Mapping[str, bytes]:
        fields = {
            "manifest": ("manifest", paths["manifest"].name, "application/json"),
            "tool": ("tool_video", paths["tool"].name, _CONTENT_TYPE),
            "workspace": (
                "workspace_video",
                paths["workspace"].name,
                _CONTENT_TYPE,
            ),
        }
        return {
            key: (
                "--{boundary}\r\n"
                "Content-Disposition: form-data; name=\"{field}\"; "
                "filename=\"{filename}\"\r\n"
                "Content-Type: {content_type}\r\n\r\n"
            ).format(
                boundary=boundary,
                field=field,
                filename=filename,
                content_type=content_type,
            ).encode("ascii")
            for key, (field, filename, content_type) in fields.items()
        }


__all__ = ["HandTrainingUploadClient", "HandTrainingUploadError"]
