"""3DRipper device login and dynamic entitlement client.

The data package never travels to this service. The only remote operations are
device authorization and a server-authoritative access check for this Add-on.
"""

from dataclasses import dataclass, replace
import base64
import json
import sys
import threading
import time
from typing import Optional
from urllib import error as urlerror
from urllib import parse as urlparse
from urllib import request as urlrequest

import bpy

from .config import ADDON_CLIENT_ID, ADDON_SCOPE, ADDON_VERSION
from .credential_store import clear_token, load_token, store_token
from .installation_store import get_installation_id
from .preferences import DEFAULT_SERVICE_URL, get_addon_preferences


class AuthorizationError(RuntimeError):
    pass


class AuthorizationPending(AuthorizationError):
    pass


class AuthorizationSlowDown(AuthorizationError):
    pass


class ServerResponseError(AuthorizationError):
    def __init__(self, status, payload, message):
        super().__init__(message)
        self.status = status
        self.payload = payload if isinstance(payload, dict) else {}


@dataclass(frozen=True)
class DeviceAuthorization:
    device_code: str
    user_code: str
    verification_uri: str
    verification_uri_complete: str
    expires_in: int
    interval: int


@dataclass(frozen=True)
class Entitlement:
    allowed: bool
    message: str
    reason: Optional[str]
    refresh_after_seconds: int
    tier: Optional[str]
    expires_at: Optional[str]
    account_name: Optional[str]


@dataclass(frozen=True)
class AuthorizationState:
    phase: str = "signed_out"
    message: str = "Sign in to verify Blender Add-on access."
    entitlement: Optional[Entitlement] = None
    checked_at: float = 0.0
    user_code: Optional[str] = None
    verification_uri_complete: Optional[str] = None


_state_lock = threading.RLock()
_state = AuthorizationState()


def _set_state(**changes):
    global _state
    with _state_lock:
        _state = replace(_state, **changes)
        return _state


def get_authorization_state():
    with _state_lock:
        return _state


def _online_access_enabled():
    return not hasattr(bpy.app, "online_access") or bool(bpy.app.online_access)


def normalize_service_url(value):
    raw_value = (value or DEFAULT_SERVICE_URL).strip().rstrip("/")
    parsed = urlparse.urlsplit(raw_value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise AuthorizationError("3DRipper service URL must be an absolute HTTP(S) URL.")
    if parsed.username or parsed.password or parsed.query or parsed.fragment:
        raise AuthorizationError("3DRipper service URL must not include credentials, a query, or a fragment.")
    hostname = (parsed.hostname or "").lower()
    if parsed.scheme != "https" and hostname not in {"localhost", "127.0.0.1", "::1"}:
        raise AuthorizationError("The 3DRipper service must use HTTPS outside local development.")
    return urlparse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))


def get_service_settings(context=None):
    preferences = get_addon_preferences(context)
    if preferences is None:
        return normalize_service_url(DEFAULT_SERVICE_URL), 8
    return normalize_service_url(preferences.service_url), int(preferences.request_timeout)


def _endpoint(service_url, path):
    return "{}{}".format(service_url, path)


def _read_json(response):
    raw = response.read().decode("utf-8")
    if not raw:
        return {}
    try:
        return json.loads(raw)
    except json.JSONDecodeError as error:
        raise AuthorizationError("3DRipper returned an invalid JSON response.") from error


def _error_message(payload, fallback):
    if not isinstance(payload, dict):
        return fallback
    nested = payload.get("error")
    if isinstance(nested, dict):
        return str(nested.get("message") or nested.get("code") or fallback)
    return str(payload.get("error_description") or nested or payload.get("message") or fallback)


def _request_json(url, method="GET", body=None, token=None, timeout=8):
    headers = {
        "Accept": "application/json",
        "User-Agent": "3DRipper-Helper/{}".format(ADDON_VERSION),
    }
    data = None
    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body).encode("utf-8")
    if token:
        headers["Authorization"] = "Bearer {}".format(token)
        try:
            installation_id = get_installation_id()
        except (OSError, RuntimeError, ValueError):
            installation_id = None
        if installation_id:
            headers["X-3DRipper-Installation-ID"] = installation_id
            headers["X-3DRipper-Addon-Version"] = ADDON_VERSION
            headers["X-3DRipper-Blender-Version"] = ".".join(
                str(part) for part in bpy.app.version
            )
            headers["X-3DRipper-Platform"] = sys.platform

    request = urlrequest.Request(url, data=data, headers=headers, method=method)
    try:
        with urlrequest.urlopen(request, timeout=timeout) as response:
            return _read_json(response)
    except urlerror.HTTPError as error:
        try:
            payload = _read_json(error)
        except AuthorizationError:
            payload = {}
        raise ServerResponseError(
            error.code, payload, _error_message(payload, "3DRipper rejected the request.")
        ) from error
    except urlerror.URLError as error:
        raise AuthorizationError("Unable to reach 3DRipper: {}".format(error.reason)) from error
    except TimeoutError as error:
        raise AuthorizationError("3DRipper did not respond before the network timeout.") from error


def _require_online_access():
    if not _online_access_enabled():
        raise AuthorizationError("Blender Online Access is disabled in Preferences > System.")


def request_device_authorization(context=None):
    _require_online_access()
    service_url, timeout = get_service_settings(context)
    payload = _request_json(
        _endpoint(service_url, "/api/auth/device/code"),
        method="POST",
        body={"client_id": ADDON_CLIENT_ID, "scope": ADDON_SCOPE},
        timeout=timeout,
    )
    try:
        device = DeviceAuthorization(
            device_code=str(payload["device_code"]),
            user_code=str(payload["user_code"]),
            verification_uri=str(payload["verification_uri"]),
            verification_uri_complete=str(payload["verification_uri_complete"]),
            expires_in=int(payload["expires_in"]),
            interval=max(1, int(payload.get("interval", 5))),
        )
    except (KeyError, TypeError, ValueError) as error:
        raise AuthorizationError("3DRipper returned an incomplete device authorization response.") from error

    _set_state(
        phase="awaiting_approval",
        message="Approve the Blender login in your browser.",
        entitlement=None,
        user_code=device.user_code,
        verification_uri_complete=device.verification_uri_complete,
    )
    return device


def poll_device_token(device_code, context=None):
    _require_online_access()
    service_url, timeout = get_service_settings(context)
    try:
        payload = _request_json(
            _endpoint(service_url, "/api/auth/device/token"),
            method="POST",
            body={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "device_code": device_code,
                "client_id": ADDON_CLIENT_ID,
            },
            timeout=timeout,
        )
    except ServerResponseError as error:
        error_code = str(error.payload.get("error") or "")
        if error_code == "authorization_pending":
            raise AuthorizationPending(str(error)) from error
        if error_code == "slow_down":
            raise AuthorizationSlowDown(str(error)) from error
        raise

    token = payload.get("access_token") if isinstance(payload, dict) else None
    if not token:
        raise AuthorizationError("3DRipper did not return an access token.")

    service_url, _timeout = get_service_settings(context)
    store_token(service_url, str(token))
    _set_state(
        phase="checking",
        message="Checking 3DRipper account access.",
        entitlement=None,
        user_code=None,
        verification_uri_complete=None,
    )
    return str(token)


def has_saved_login(context=None):
    try:
        service_url, _timeout = get_service_settings(context)
    except AuthorizationError:
        return False
    return bool(load_token(service_url))


def logout(context=None):
    service_url, timeout = get_service_settings(context)
    token = load_token(service_url)
    if token and _online_access_enabled():
        try:
            _request_json(
                _endpoint(service_url, "/api/blender/v1/installations/current"),
                method="DELETE",
                token=token,
                timeout=min(timeout, 3),
            )
        except AuthorizationError:
            # Local logout must still succeed when the account service is
            # unavailable. The stale server record remains visible by age.
            pass
    clear_token(service_url)
    _set_state(
        phase="signed_out",
        message="Signed out of 3DRipper.",
        entitlement=None,
        checked_at=0.0,
        user_code=None,
        verification_uri_complete=None,
    )


def refresh_entitlement(context=None, force=True):
    _require_online_access()
    service_url, timeout = get_service_settings(context)
    current_state = get_authorization_state()
    if (
        not force
        and current_state.entitlement is not None
        and time.monotonic() - current_state.checked_at < current_state.entitlement.refresh_after_seconds
    ):
        return current_state.entitlement

    token = load_token(service_url)
    if not token:
        _set_state(
            phase="signed_out",
            message="Sign in to verify Blender Add-on access.",
            entitlement=None,
            user_code=None,
            verification_uri_complete=None,
        )
        raise AuthorizationError("Sign in to 3DRipper before importing a Scene IR package.")

    _set_state(
        phase="checking",
        message="Checking 3DRipper account access.",
        entitlement=None,
        user_code=None,
        verification_uri_complete=None,
    )
    try:
        payload = _request_json(
            _endpoint(service_url, "/api/blender/v1/entitlement"),
            token=token,
            timeout=timeout,
        )
    except ServerResponseError as error:
        if error.status == 401:
            clear_token(service_url)
            _set_state(
                phase="signed_out",
                message="Your 3DRipper login has expired.",
                entitlement=None,
                user_code=None,
                verification_uri_complete=None,
            )
        else:
            _set_state(
                phase="error",
                message=str(error),
                entitlement=None,
                user_code=None,
                verification_uri_complete=None,
            )
        raise
    except AuthorizationError as error:
        _set_state(
            phase="error",
            message=str(error),
            entitlement=None,
            user_code=None,
            verification_uri_complete=None,
        )
        raise

    if not isinstance(payload, dict) or payload.get("success") is not True or not isinstance(payload.get("data"), dict):
        message = _error_message(payload, "3DRipper returned an invalid entitlement response.")
        _set_state(
            phase="error",
            message=message,
            entitlement=None,
            user_code=None,
            verification_uri_complete=None,
        )
        raise AuthorizationError(message)

    data = payload["data"]
    account = data.get("account") if isinstance(data.get("account"), dict) else {}
    entitlement = Entitlement(
        allowed=bool(data.get("allowed")),
        message=str(data.get("message") or "3DRipper account access was checked."),
        reason=str(data["reason"]) if data.get("reason") else None,
        refresh_after_seconds=max(1, int(data.get("refreshAfterSeconds") or 300)),
        tier=str(data["tier"]) if data.get("tier") else None,
        expires_at=str(data["expiresAt"]) if data.get("expiresAt") else None,
        account_name=str(account["name"]) if account.get("name") else None,
    )
    _set_state(
        phase="allowed" if entitlement.allowed else "denied",
        message=entitlement.message,
        entitlement=entitlement,
        checked_at=time.monotonic(),
        user_code=None,
        verification_uri_complete=None,
    )
    return entitlement


def require_import_entitlement(context=None):
    entitlement = refresh_entitlement(context=context, force=True)
    if not entitlement.allowed:
        raise AuthorizationError(entitlement.message)
    return entitlement


def resolve_protected_package_key(key_id, context=None):
    _require_online_access()
    if not isinstance(key_id, str) or not key_id:
        raise AuthorizationError("Protected package key ID is invalid.")
    service_url, timeout = get_service_settings(context)
    token = load_token(service_url)
    if not token:
        raise AuthorizationError("Sign in to 3DRipper before importing a protected package.")

    encoded_key_id = urlparse.quote(key_id, safe="")
    try:
        payload = _request_json(
            _endpoint(
                service_url,
                "/api/blender/v1/package-keys/{}".format(encoded_key_id),
            ),
            token=token,
            timeout=timeout,
        )
    except ServerResponseError as error:
        if error.status == 401:
            clear_token(service_url)
            _set_state(
                phase="signed_out",
                message="Your 3DRipper login has expired.",
                entitlement=None,
                checked_at=0.0,
                user_code=None,
                verification_uri_complete=None,
            )
        raise

    if (
        not isinstance(payload, dict)
        or payload.get("success") is not True
        or not isinstance(payload.get("data"), dict)
    ):
        raise AuthorizationError(
            _error_message(payload, "3DRipper returned an invalid protected package key.")
        )
    data = payload["data"]
    if (
        data.get("keyId") != key_id
        or data.get("keyVersion") != 1
        or data.get("algorithm") != "AES-256-GCM-CHUNKED"
    ):
        raise AuthorizationError("3DRipper returned an incompatible protected package key.")
    encoded_key = data.get("key")
    if (
        not isinstance(encoded_key, str)
        or len(encoded_key) != 43
        or any(
            character
            not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
            for character in encoded_key
        )
    ):
        raise AuthorizationError("3DRipper returned an invalid protected package key.")
    try:
        padding = "=" * (-len(encoded_key) % 4)
        key = base64.urlsafe_b64decode((encoded_key + padding).encode("ascii"))
    except (ValueError, UnicodeEncodeError) as error:
        raise AuthorizationError(
            "3DRipper returned an invalid protected package key."
        ) from error
    if len(key) != 32:
        raise AuthorizationError("3DRipper returned an invalid protected package key.")
    return key


def report_import_event(
    package_type,
    status,
    context=None,
    package_key_id=None,
    error_code=None,
    object_count=None,
    material_count=None,
    warning_count=None,
    duration_ms=None,
):
    """Best-effort operational event; never fail an import because analytics failed."""

    try:
        service_url, timeout = get_service_settings(context)
        token = load_token(service_url)
        if not token or not _online_access_enabled():
            return False
        body = {
            "packageType": package_type,
            "status": status,
        }
        optional_values = {
            "packageKeyId": package_key_id,
            "errorCode": error_code,
            "objectCount": object_count,
            "materialCount": material_count,
            "warningCount": warning_count,
            "durationMs": duration_ms,
        }
        body.update(
            (key, value)
            for key, value in optional_values.items()
            if value is not None
        )
        payload = _request_json(
            _endpoint(service_url, "/api/blender/v1/import-events"),
            method="POST",
            body=body,
            token=token,
            timeout=min(timeout, 3),
        )
        return isinstance(payload, dict) and payload.get("success") is True
    except (AuthorizationError, OSError, RuntimeError, ValueError):
        return False
