"""Zero-config authentication — entirely internal.

Resolution chain (first success wins):
1. session.json → Cognito REFRESH_TOKEN_AUTH → IdToken
2. Interactive fallback → email/password → Cognito USER_PASSWORD_AUTH → IdToken
3. Server UUID → GetOfflineLicenseStatus RPC → hardwareUUID
4. Local UUID → platform-specific hardware identifier

Nothing in this module is part of the public API.
"""

from __future__ import annotations

import getpass
import hashlib
import json
import logging
import os
import platform
import re
import subprocess
import sys
import threading
from pathlib import Path

import grpc
from grpc_interceptor import ClientCallDetails, ClientInterceptor

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Private constants — deliberately NOT in _constants.py
# ---------------------------------------------------------------------------

_COGNITO_REGION: str = "us-east-1"
_COGNITO_CLIENT_ID_PROD: str = "62hgblnnv0dbfrsqad03ub44td"
_COGNITO_CLIENT_ID_DEV: str = "1q4ugfpgijro91l1cjdhau60cq"

_ENV_OFFLINE: str = "RADIENS_OFFLINE"
_ENV_DEV: str = "RADIENS_DEV"
_ENV_QA: str = "RADIENS_QA"


# ===================================================================
# AuthInterceptor — the only class a client ever touches
# ===================================================================


class AuthInterceptor(ClientInterceptor):
    """gRPC interceptor that lazily resolves and attaches auth metadata.

    Constructed with zero arguments.  On the first ``intercept()`` call
    the full resolution chain runs exactly once (thread-safe).

    Parameters
    ----------
    server_address
        Address used for the *server UUID* fallback
        (``GetOfflineLicenseStatus`` RPC).  Typically the core address.
    """

    def __init__(self, server_address: str = "") -> None:
        self._server_address = server_address
        self._authorization: str | None = None
        self._resolved = False
        self._lock = threading.Lock()

    # -- gRPC hook --------------------------------------------------

    def intercept(  # type: ignore[override,explicit-any]
        self,
        method: object,
        request: object,
        call_details: ClientCallDetails,
    ) -> object:
        self._ensure_resolved()

        if self._authorization is None:
            return method(request, call_details)  # type: ignore[operator]

        new_metadata = list(call_details.metadata) if call_details.metadata else []
        new_metadata = [(k, v) for k, v in new_metadata if k.lower() != "authorization"]
        new_metadata.append(("authorization", self._authorization))

        new_details = ClientCallDetails(
            call_details.method,
            call_details.timeout,
            new_metadata,
            call_details.credentials,
            call_details.wait_for_ready,
            call_details.compression,
        )
        return method(request, new_details)  # type: ignore[operator]

    # -- Lazy resolution (runs once) --------------------------------

    def _ensure_resolved(self) -> None:
        if self._resolved:
            return
        with self._lock:
            if self._resolved:  # double-checked lock (threading)
                return  # type: ignore[unreachable]
            self._resolve()
            self._resolved = True

    def _resolve(self) -> None:
        """Walk the resolution chain until one strategy succeeds."""
        force_offline = os.getenv(_ENV_OFFLINE) == "1"

        # 1. Cognito token via session.json refresh
        if not force_offline:
            token = _try_cognito_refresh()
            if token is not None:
                self._authorization = f"Bearer {token}"
                _log.debug("Auth resolved via session.json refresh token")
                return

        # 2. Interactive email/password
        if not force_offline:
            token = _try_cognito_password()
            if token is not None:
                self._authorization = f"Bearer {token}"
                _log.debug("Auth resolved via interactive login")
                return

        # 3. Server UUID via RPC
        if self._server_address:
            hw_uuid = _try_server_uuid(self._server_address)
            if hw_uuid:
                self._authorization = f"deviceUUID {hw_uuid}"
                _log.debug("Auth resolved via server hardware UUID")
                return

        # 4. Local hardware UUID
        hw_uuid = _generate_local_hardware_uuid()
        if hw_uuid:
            self._authorization = f"deviceUUID {hw_uuid}"
            _log.debug("Auth resolved via local hardware UUID")
            return

        _log.warning("Auth resolution failed — requests will be unauthenticated")


# ===================================================================
# Strategy 1 — session.json → Cognito REFRESH_TOKEN_AUTH
# ===================================================================


def _try_cognito_refresh() -> str | None:
    """Attempt Cognito refresh-token auth from session.json."""
    try:
        import boto3
        import botocore.exceptions
    except ImportError:
        _log.debug("boto3 not installed — skipping Cognito auth")
        return None

    app_dir = _session_dir()
    if app_dir is None:
        return None

    client_id = _cognito_client_id()
    refresh_token, device_key = _load_session_file(client_id, app_dir)
    if refresh_token is None or device_key is None:
        return None

    try:
        client = boto3.client("cognito-idp", region_name=_COGNITO_REGION)
        resp = client.initiate_auth(
            AuthFlow="REFRESH_TOKEN_AUTH",
            AuthParameters={
                "REFRESH_TOKEN": refresh_token,
                "DEVICE_KEY": device_key,
            },
            ClientId=client_id,
        )
        client.close()
        token: str = resp["AuthenticationResult"]["IdToken"]
        return token
    except botocore.exceptions.EndpointConnectionError:
        _log.debug("Cannot reach Cognito endpoint")
        return None
    except botocore.exceptions.ClientError as exc:
        _log.debug("Cognito client error: %s", exc)
        return None
    except Exception:
        _log.debug("Unexpected Cognito error", exc_info=True)
        return None


# ===================================================================
# Strategy 2 — Interactive email / password
# ===================================================================


def _try_cognito_password() -> str | None:
    """Prompt user for email/password and attempt Cognito USER_PASSWORD_AUTH."""
    if not sys.stdin.isatty():
        return None

    try:
        import boto3
        import botocore.exceptions
    except ImportError:
        return None

    try:
        email = input("Enter user email address: ")
        password = getpass.getpass(prompt="Enter user password: ")
    except (EOFError, KeyboardInterrupt):
        return None

    try:
        client = boto3.client("cognito-idp", region_name=_COGNITO_REGION)
        resp = client.initiate_auth(
            AuthFlow="USER_PASSWORD_AUTH",
            AuthParameters={"USERNAME": email, "PASSWORD": password},
            ClientId=_cognito_client_id(),
        )
        client.close()
        token: str = resp["AuthenticationResult"]["IdToken"]
        return token
    except botocore.exceptions.ClientError as exc:
        _log.debug("Cognito password auth failed: %s", exc)
        return None
    except Exception:
        _log.debug("Unexpected Cognito error", exc_info=True)
        return None


# ===================================================================
# Strategy 3 — server hardware UUID via RPC
# ===================================================================


def _try_server_uuid(address: str) -> str:
    """Ask the backend for its hardware UUID via GetOfflineLicenseStatus."""
    from radiens_core._generated import (
        allegoserver_pb2_grpc,
        common_pb2,
        radiensserver_pb2_grpc,
    )

    try:
        with grpc.insecure_channel(address) as chan:
            # Try AllegoLifecycle first (allego port)
            try:
                stub = allegoserver_pb2_grpc.AllegoLifecycleStub(chan)
                resp = stub.GetOfflineLicenseStatus(common_pb2.StandardRequest())
                uuid: str = resp.hardwareUUID
                if uuid:
                    return uuid
            except grpc.RpcError:
                pass

            # Fallback to RadiensLifecycle (radiens port)
            try:
                stub_r = radiensserver_pb2_grpc.RadiensLifecycleStub(chan)
                resp_r = stub_r.GetOfflineLicenseStatus(common_pb2.StandardRequest())
                uuid_r: str = resp_r.hardwareUUID
                if uuid_r:
                    return uuid_r
            except grpc.RpcError:
                pass
    except Exception:
        _log.debug("Server UUID fetch failed", exc_info=True)

    return ""


# ===================================================================
# Strategy 4 — local hardware UUID
# ===================================================================


def _generate_local_hardware_uuid() -> str:
    """Platform-specific local hardware UUID."""
    system = platform.system()
    if system == "Darwin":
        return _macos_hardware_uuid()
    if system == "Windows":
        return _windows_hardware_uuid()
    _log.debug("Unsupported OS for local hardware UUID: %s", system)
    return ""


def _macos_hardware_uuid() -> str:
    try:
        output = subprocess.check_output(
            ["ioreg", "-d2", "-c", "IOPlatformExpertDevice"],
            encoding="utf-8",
        )
    except subprocess.CalledProcessError:
        return ""
    match = re.search(r'"IOPlatformUUID"\s=\s"([^"]+)"', output)
    return match.group(1) if match else ""


def _windows_hardware_uuid() -> str:
    parts: list[str] = []
    for cmd in (
        ["wmic", "csproduct", "get", "UUID"],
        ["wmic", "bios", "get", "serialnumber"],
        ["wmic", "DISKDRIVE", "get", "SerialNumber"],
    ):
        try:
            output = subprocess.check_output(cmd, encoding="utf-8", stderr=subprocess.DEVNULL)
            lines = output.strip().split()
            if len(lines) > 1:
                parts.append(lines[1].strip())
            else:
                parts.append("")
        except subprocess.CalledProcessError:
            parts.append("")

    combined = "".join(parts).encode("utf-8")
    md5 = hashlib.md5(combined).hexdigest()
    return f"{md5[:8]}-{md5[8:12]}-{md5[12:16]}-{md5[16:20]}-{md5[20:]}"


# ===================================================================
# Shared helpers
# ===================================================================


def _session_dir() -> Path | None:
    """Platform-specific Radiens session directory."""
    sys_platform = sys.platform
    if sys_platform in ("linux", "linux2"):
        return Path.home() / ".config" / "radiens"
    if sys_platform == "darwin":
        return Path.home() / "Library" / "Application Support" / "radiens"
    if sys_platform == "win32":
        return Path.home() / "AppData" / "Roaming" / "radiens"
    return None


def _cognito_client_id() -> str:
    """Select Cognito app client ID based on environment."""
    if os.getenv(_ENV_DEV) == "1" or os.getenv(_ENV_QA) == "1":
        return _COGNITO_CLIENT_ID_DEV
    return _COGNITO_CLIENT_ID_PROD


def _load_session_file(client_id: str, app_dir: Path) -> tuple[str | None, str | None]:
    """Read refresh_token and device_key from session.json."""
    session_file = app_dir / "session.json"
    if not session_file.is_file() or session_file.stat().st_size == 0:
        return None, None

    try:
        with open(session_file) as fid:
            data = json.load(fid)
    except (json.JSONDecodeError, OSError):
        return None, None

    provider = data.get("CognitoIdentityServiceProvider")
    if not isinstance(provider, dict):
        return None, None

    app_client = provider.get(client_id)
    if not isinstance(app_client, dict):
        return None, None

    last_user = app_client.get("LastAuthUser")
    if not last_user:
        return None, None

    user_body = app_client.get(last_user)
    if not isinstance(user_body, dict):
        return None, None

    refresh_token = user_body.get("refreshToken")
    device_key = user_body.get("deviceKey")
    return refresh_token, device_key
