"""IsardVDI API client for managing virtual desktops.

Mirrors the PowerShell functions in desktop.ps1:
- IsardVdiClient.login()  → Get-IsardToken
- get_desktops()          → Get-Isard

Authentication uses the IsardVDI SAML provider, which triggers the same
Azure Entra ID flow as the Gencat SAML login.  The SAML assertion is posted
to IsardVDI's own ACS endpoint, which returns (or sets) a JWT token.
"""

from __future__ import annotations

import json
import logging
import re
import time
from typing import Optional
from urllib.parse import urlparse, parse_qs

import httpx

from .auth import (
    AuthFlow,
    _extract_form_field,
    _extract_form_action,
    _extract_hidden_form,
    _parse_azure_config,
)
from .client import GencatClient
from .errors import (
    AzureAuthError,
    AzureRedirectError,
    BookingRequiredError,
    HttpError,
    NetworkError,
)
from .types import BastionTarget, Desktop, GencatConfig, IsardVdiSession, Template

logger = logging.getLogger(__name__)

_BASE_URL = "https://elmeuescriptori.gestioeducativa.gencat.cat"

# Hardcoded in desktop.ps1 — the "Provençana" category on this IsardVDI instance.
# Removed: was causing 404 errors, now using endpoint discovery instead.


def _extract_jwt_from_response(
    url: str,
    body: str,
    cookies: dict,
) -> Optional[str]:
    """Try to extract a JWT token from a completed IsardVDI SAML ACS response.

    IsardVDI can return the token in several ways:
    1. Raw JWT string as the response body  ← checked first (most reliable)
    2. JSON body: {"token": "...", ...}
    3. Cookie named ``token``, ``isardvdi_session``, or ``jwt``
    4. Redirect URL with ``?token=...`` query parameter
       (``state`` is intentionally excluded — it holds a transient callback JWT)
    """
    # 1. Check response body — raw JWT (three dot-separated base64url segments)
    stripped = body.strip().strip('"')
    if re.match(r"^[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+$", stripped):
        logger.debug("JWT found as raw response body")
        return stripped

    # 2. Check body as JSON {"token": "..."}
    try:
        import json as _json

        data = _json.loads(body)
        if isinstance(data, dict):
            for key in ("token", "jwt", "access_token"):
                if key in data and data[key]:
                    tok = str(data[key]).strip().strip('"')
                    if tok:
                        logger.debug("JWT found in JSON body key %r", key)
                        return tok
    except Exception:
        pass

    # 3. Check cookies
    for key in ("token", "isardvdi_session", "isardvdi", "jwt", "access_token"):
        if key in cookies:
            tok = str(cookies[key]).strip().strip('"')
            if tok:
                logger.debug("JWT found in cookie %r", key)
                return tok

    # 4. Check URL query params (excluding 'state' which is a transient callback token)
    parsed = urlparse(url)
    qs = parse_qs(parsed.query)
    for key in ("token", "jwt", "access_token"):
        if key in qs:
            tok = qs[key][0].strip().strip('"')
            if tok:
                logger.debug("JWT found in URL query param %r", key)
                return tok

    return None


class _IsardVdiAuthFlow(AuthFlow):
    """SAML auth flow variant that targets IsardVDI's SAML endpoints.

    Overrides the ACS submission step to post the SAMLResponse to IsardVDI's
    own ACS URL and extract the resulting JWT token instead of building a
    Gencat :class:`AuthSession`.
    """

    def __init__(
        self,
        client: GencatClient,
        config: GencatConfig,
        category_id: str | None,
    ):
        super().__init__(client, config)
        self._category_id = category_id
        self._isardvdi_jwt: Optional[str] = None

    def login_isardvdi(self, username: str, password: str) -> str:
        """Run the full SAML flow and return the IsardVDI JWT token.

        Raises:
            :class:`AzureAuthError`: If credentials are rejected or the SAML
                flow fails to produce a token.
        """
        base = self._config.base_url.rstrip("/")
        category_id = self._category_id

        # ── Step 1: Initiate IsardVDI SAML login ─────────────────────────
        logger.info("IsardVDI SAML Step 1: initiating login")
        if category_id:
            login_url = f"{base}/authentication/saml/login?provider=saml&category_id={category_id}"
        else:
            login_url = f"{base}/authentication/saml/login?provider=saml"
        azure_url, _ = self._client.get_following_redirects(login_url)

        if "login.microsoftonline.com" not in azure_url:
            raise AzureRedirectError(
                f"Expected redirect to Azure login, got: {azure_url}",
                redirect_url=azure_url,
            )

        # Capture the original RelayState from the Azure redirect URL.
        # This opaque token matches the saml_* cookie set by IsardVDI and
        # MUST be preserved through the Azure hop loop — the final form page
        # may contain a corrupted/re-encoded RelayState value.
        _azure_qs = parse_qs(urlparse(azure_url).query)
        original_relay_state: str = _azure_qs.get("RelayState", [""])[0]
        logger.debug("Captured original RelayState: %r", original_relay_state)

        # ── Steps 2-4: Standard Azure credential submission ───────────────
        # Reuse the parent class helpers directly.
        azure_landing_url, azure_html = self._client.get_following_redirects(azure_url)

        # BssoInterrupt handling (same as parent)
        for _ in range(3):
            if "BssoInterrupt" not in azure_html:
                break
            logger.debug("BssoInterrupt detected, re-fetching Azure login page")
            new_url, new_html = self._client.get_following_redirects(azure_url)
            if "BssoInterrupt" in new_html:
                try:
                    cfg_tmp = _parse_azure_config(azure_html)
                    bsso_url = cfg_tmp.url_post.replace(r"\u0026", "&").replace(
                        r"\u003d", "="
                    )
                    new_url, new_html = self._client.get_following_redirects(bsso_url)
                except Exception:
                    pass
            azure_landing_url = new_url
            azure_html = new_html

        azure_config = _parse_azure_config(azure_html)

        flow_token = self._get_credential_type(username, azure_config)
        post_url, post_body = self._submit_credentials(
            username, password, flow_token, azure_config
        )

        # ── Post-credential redirect loop ─────────────────────────────────
        for hop in range(15):
            if "SAMLResponse" in post_body:
                break
            if "oPostParams" in post_body:
                logger.info("Hop %d: following oPostParams", hop)
                post_url, post_body = self._follow_o_post_params(
                    post_body, azure_config.correlation_id
                )
                continue
            method = ""
            if "<form" in post_body.lower():
                m = re.search(
                    r'<form[^>]+method=["\'](\w+)["\']', post_body, re.IGNORECASE
                )
                method = m.group(1).upper() if m else "GET"
            if "<form" in post_body.lower() and method == "POST":
                result = _extract_hidden_form(post_body, post_url)
                if result is not None:
                    action, fields = result
                    if base in action:
                        break
                    logger.info("Hop %d: following hidden form to %s", hop, action)
                    post_url, post_body = self._client.post_form_following_redirects(
                        action, fields
                    )
                    continue
            if "$Config=" in post_body:
                try:
                    cfg = _parse_azure_config(post_body)
                except Exception:
                    break
                if cfg.url_post:
                    logger.info(
                        "Hop %d: KmsiInterrupt, posting to %s", hop, cfg.url_post
                    )
                    kmsi_form = {
                        "LoginOptions": "3",
                        "type": "28",
                        "ctx": cfg.s_ctx,
                        "flowToken": cfg.flow_token,
                        "canary": cfg.canary,
                        "hpgrequestid": cfg.session_id,
                    }
                    post_url, post_body = self._client.post_form_following_redirects(
                        cfg.url_post, kmsi_form
                    )
                    continue
            break

        # ── Submit SAMLResponse to IsardVDI ACS ───────────────────────────
        if "SAMLResponse" not in post_body:
            raise AzureAuthError(
                f"SAMLResponse not found after Azure auth loop; last URL: {post_url}",
                correlation_id=azure_config.correlation_id,
            )

        saml_response = _extract_form_field(post_body, "SAMLResponse")
        if not saml_response:
            raise AzureAuthError("Could not extract SAMLResponse field from page")

        form_action = (
            _extract_form_action(post_body) or f"{base}/authentication/saml/acs"
        )

        # Use the original RelayState captured at step 1, NOT the one embedded
        # in the final Azure form — by this point the hop chain (KMSI, OAuth2Cl,
        # Terms-of-Use) may have re-encoded the SAMLResponse inside RelayState.
        relay_state_for_acs = original_relay_state or (
            _extract_form_field(post_body, "RelayState") or ""
        )
        logger.debug("Using RelayState for ACS: %r", relay_state_for_acs[:60])

        logger.info("IsardVDI SAML Step 5: posting SAMLResponse to %s", form_action)
        form = {"SAMLResponse": saml_response, "RelayState": relay_state_for_acs}

        # We need access to the underlying httpx client's cookie jar for this request.
        acs_url, acs_body = self._client.post_form_following_redirects(
            form_action, form
        )
        cookies = dict(self._client._client.cookies)
        logger.debug("ACS response URL: %s", acs_url)
        logger.debug("ACS response body prefix: %r", acs_body[:80])
        logger.debug("ACS cookie keys: %r", list(cookies.keys()))

        # Extract JWT from response
        token = _extract_jwt_from_response(acs_url, acs_body, cookies)
        if not token:
            raise AzureAuthError(
                f"IsardVDI SAML login did not return a JWT token. ACS URL={acs_url!r}"
            )

        return token


class IsardVdiClient:
    """HTTP client for the IsardVDI REST API (``/api/v3``)."""

    def __init__(self, session: IsardVdiSession, base_url: str = _BASE_URL):
        self._base_url = base_url.rstrip("/")
        self._session = session
        self._client = httpx.Client(timeout=30.0, verify=True)

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> "IsardVdiClient":
        return self

    def __exit__(self, *_) -> None:
        self.close()

    # ------------------------------------------------------------------
    # Authentication  (mirrors Get-IsardToken in desktop.ps1)
    # ------------------------------------------------------------------

    @classmethod
    def login(
        cls,
        username: str,
        password: str,
        base_url: str = _BASE_URL,
        category_id: str | None = None,
    ) -> "IsardVdiClient":
        """Authenticate against IsardVDI via the SAML provider (Azure Entra ID).

        Triggers the ``saml`` provider login flow starting at
        ``/authentication/login?provider=saml&category_id=<id>``, which
        redirects to Azure, completes the credential challenge, and posts
        the resulting SAMLResponse to IsardVDI's ACS.  The JWT token
        returned by the ACS is used for subsequent API calls.

        Args:
            username: Gencat username (e.g. ``user@edu.gencat.cat``).
            password: Gencat password.
            base_url: IsardVDI instance base URL.
            category_id: IsardVDI category UUID (defaults to the Provençana category).

        Returns:
            An :class:`IsardVdiClient` ready to call API endpoints.

        Raises:
            :class:`AzureAuthError`: Login rejected or SAML flow failed.
            :class:`HttpError`: Other non-2xx response.
            :class:`NetworkError`: Network-level connectivity error.
        """
        config = GencatConfig(base_url=base_url)
        with GencatClient(config) as http_client:
            flow = _IsardVdiAuthFlow(http_client, config, category_id)
            token = flow.login_isardvdi(username, password)

        session = IsardVdiSession(token=token, username=username)
        return cls(session, base_url=base_url)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _headers(self) -> dict[str, str]:
        return {
            "Accept": "application/json",
            "Authorization": f"Bearer {self._session.token}",
        }

    def _get(self, endpoint: str) -> dict | list:
        url = f"{self._base_url}{endpoint}"
        try:
            resp = self._client.get(url, headers=self._headers())
        except httpx.RequestError as exc:
            raise NetworkError(f"Request failed: {exc}", exc) from exc

        if resp.status_code >= 400:
            raise HttpError(resp.status_code, f"GET {endpoint}")

        return resp.json()

    def _put(self, endpoint: str, body: dict) -> dict | list:
        url = f"{self._base_url}{endpoint}"
        headers = {**self._headers(), "Content-Type": "application/json"}
        try:
            resp = self._client.put(url, headers=headers, json=body)
        except httpx.RequestError as exc:
            raise NetworkError(f"Request failed: {exc}", exc) from exc

        if resp.status_code >= 400:
            raise HttpError(resp.status_code, f"PUT {endpoint}")

        return resp.json()

    def _post(self, endpoint: str, body: dict) -> dict | list:
        url = f"{self._base_url}{endpoint}"
        headers = {**self._headers(), "Content-Type": "application/json"}
        try:
            resp = self._client.post(url, headers=headers, json=body)
        except httpx.RequestError as exc:
            raise NetworkError(f"Request failed: {exc}", exc) from exc

        if resp.status_code >= 400:
            # Try to get the error details from the response
            try:
                error_data = resp.json()
                if isinstance(error_data, dict):
                    error_msg = error_data.get(
                        "detail", error_data.get("message", str(error_data))
                    )
                else:
                    error_msg = str(error_data)
            except Exception:
                error_msg = resp.text or f"POST {endpoint}"

            raise HttpError(resp.status_code, f"POST {endpoint}: {error_msg}")

        return resp.json()

    def _delete(self, endpoint: str) -> dict | list | None:
        """Send a DELETE request to the given endpoint.

        Args:
            endpoint: API endpoint path starting with '/'.

        Returns:
            JSON response from the API, or None if no content.

        Raises:
            :class:`NetworkError`: Network-level connectivity error.
            :class:`HttpError`: Non-2xx response from the API.
        """
        url = f"{self._base_url}{endpoint}"
        headers = self._headers()
        try:
            resp = self._client.delete(url, headers=headers)
        except httpx.RequestError as exc:
            raise NetworkError(f"Request failed: {exc}", exc) from exc

        if resp.status_code >= 400:
            # Try to get the error details from the response
            try:
                error_data = resp.json()
                if isinstance(error_data, dict):
                    error_msg = error_data.get(
                        "detail", error_data.get("message", str(error_data))
                    )
                else:
                    error_msg = str(error_data)
            except Exception:
                error_msg = resp.text or f"DELETE {endpoint}"

            raise HttpError(resp.status_code, f"DELETE {endpoint}: {error_msg}")

        # DELETE might return empty response (204 No Content)
        if resp.status_code == 204 or not resp.text.strip():
            return None

        return resp.json()

    # ------------------------------------------------------------------
    # Desktop operations  (mirror of desktop.ps1 Get-Isard / Start-Isard /
    #                       Stop-Isard)
    # ------------------------------------------------------------------

    def get_desktops(self, name: Optional[str] = None) -> list[Desktop]:
        """Return all desktops for the authenticated user.

        Args:
            name: If provided, filter to the single desktop with this name.

        Returns:
            A list of :class:`Desktop` objects sorted by name.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = self._get("/api/v3/user/desktops")
        if not isinstance(data, list):
            data = [data]

        desktops = [Desktop.from_dict(item) for item in data]
        desktops.sort(key=lambda d: d.name)

        if name is not None:
            desktops = [d for d in desktops if d.name == name]

        return desktops

    def stop_desktop(self, desktop_id: str) -> str:
        """Stop a running desktop.

        Mirrors ``Stop-Isard`` in desktop.ps1.

        Args:
            desktop_id: The desktop UUID (``Desktop.id``).

        Returns:
            The new desktop state string as reported by the API (e.g.
            ``"Stopping"``, ``"Shutting-down"``, or ``"Stopped"``).

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = self._get(f"/api/v3/desktop/stop/{desktop_id}")
        return data.get("status", "") if isinstance(data, dict) else ""

    def start_desktop(self, desktop_id: str) -> str:
        """Start a stopped desktop.

        Mirrors ``Start-Isard`` in desktop.ps1.

        Args:
            desktop_id: The desktop UUID (``Desktop.id``).

        Returns:
            The new desktop state string as reported by the API (e.g.
            ``"Starting"`` or ``"Started"``).

        Raises:
            :class:`BookingRequiredError`: HTTP 428 — a booking must be made before
                the desktop can be started.
            :class:`HttpError`: Other non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        try:
            data = self._get(f"/api/v3/desktop/start/{desktop_id}")
        except HttpError as exc:
            if exc.status_code == 428:
                raise BookingRequiredError(desktop_id) from exc
            raise
        return data.get("status", "") if isinstance(data, dict) else ""

    def star_desktop(self, desktop_id: str, starred: bool = True) -> None:
        """Mark (or unmark) a desktop as a favourite / starred.

        Uses ``PUT /api/v3/domain/{id}`` with ``{"favourite": <bool>}``.

        Args:
            desktop_id: The desktop UUID (``Desktop.id``).
            starred: ``True`` to star the desktop, ``False`` to unstar it.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        self._put(f"/api/v3/domain/{desktop_id}", {"favourite": starred})

    def delete_desktop(self, desktop_id: str) -> None:
        """Move a desktop to trash.

        Mirrors ``Remove-Isard`` in the XTEC Box PowerShell implementation.
        Uses ``DELETE /api/v3/desktop/{id}`` endpoint.

        Note: This moves the desktop to IsardVDI's trash rather than permanently
        deleting it. Permanent deletion requires confirmation via the web interface.

        Args:
            desktop_id: The desktop UUID (``Desktop.id``).

        Raises:
            :class:`HttpError`: Non-2xx response from the API (e.g., desktop not found, cannot move running desktop).
            :class:`NetworkError`: Network-level connectivity error.
        """
        logger.debug(f"Moving desktop to trash: desktop_id='{desktop_id}'")

        # Use DELETE method on the desktop endpoint (from XTEC Box implementation)
        endpoint = f"/api/v3/desktop/{desktop_id}"

        try:
            self._delete(endpoint)
            logger.debug(f"Successfully moved desktop to trash: {desktop_id}")
        except HttpError as exc:
            logger.error(
                f"Failed to move desktop {desktop_id} to trash: HTTP {exc.status_code}"
            )
            raise

    def delete_desktop_permanent(self, desktop_id: str) -> None:
        """Permanently delete a desktop (cannot be undone).

        Uses ``DELETE /api/v3/desktop/{id}/permanent`` endpoint for permanent deletion.

        WARNING: This operation is irreversible. The desktop and all its data will be
        permanently lost and cannot be recovered from trash or any other means.

        Args:
            desktop_id: The desktop UUID (``Desktop.id``).

        Raises:
            :class:`HttpError`: Non-2xx response from the API (e.g., desktop not found, cannot delete running desktop).
            :class:`NetworkError`: Network-level connectivity error.
        """
        logger.debug(f"Permanently deleting desktop: desktop_id='{desktop_id}'")

        # Use DELETE method on the permanent endpoint
        endpoint = f"/api/v3/desktop/{desktop_id}/permanent"

        try:
            self._delete(endpoint)
            logger.debug(f"Successfully permanently deleted desktop: {desktop_id}")
        except HttpError as exc:
            logger.error(
                f"Failed to permanently delete desktop {desktop_id}: HTTP {exc.status_code}"
            )
            raise

    def create_desktop(
        self, template_id: str, name: str, description: str | None = None
    ) -> Desktop:
        """Create a new desktop from a template.

        Args:
            template_id: The template UUID to create desktop from.
            name: Name for the new desktop.
            description: Optional description for the desktop.

        Returns:
            A :class:`Desktop` object representing the newly created desktop.

        Raises:
            :class:`HttpError`: Non-2xx response from the API (e.g., template not found, quota exceeded).
            :class:`NetworkError`: Network-level connectivity error.
        """
        logger.debug(
            f"Creating desktop: name='{name}', template_id='{template_id}', description='{description}'"
        )

        # Based on the XTEC Box PowerShell implementation, use the correct endpoint and payload structure
        endpoint = "/api/v3/persistent_desktop"

        # Build the payload using the correct IsardVDI structure (based on XTEC Box implementation)
        payload = {
            "template_id": template_id,
            "name": name,
            "description": description or "",
            # Default guest properties - these are required by IsardVDI
            "guest_properties": {
                "credentials": {"username": "user", "password": "password"},
                "fullscreen": False,
                "viewers": {
                    "browser_rdp": {"options": None},
                    "browser_vnc": {"options": None},
                    "file_rdpgw": {"options": None},
                    "file_spice": {"options": None},
                },
            },
            # Default hardware configuration - IsardVDI will override with template values
            "hardware": {
                "boot_order": ["disk"],
                "disk_bus": "default",
                "disks": [],  # Template will provide this
                "floppies": [],
                "interfaces": ["default", "wireguard"],
                "isos": [],
                "memory": 4,  # Default, template may override
                "vcpus": 2,  # Default, template may override
                "videos": ["default"],
                "reservables": {"vgpus": ["None"]},
            },
            # Default image - template will provide actual image
            "image": {"id": "default.png", "type": "stock"},
        }

        try:
            logger.debug(
                f"Creating desktop with endpoint {endpoint} and payload: {payload}"
            )
            data = self._post(endpoint, payload)
            logger.debug(f"Creation successful, response: {data}")

            # Handle IsardVDI response format
            if isinstance(data, dict):
                # IsardVDI returns desktop data directly or with an id field
                if "id" in data:
                    # The response might not have all desktop fields, so create a minimal desktop
                    desktop_data = {
                        "id": data["id"],
                        "name": name,
                        "state": data.get("state", "Creating"),
                        "description": description,
                        "template_id": template_id,
                    }
                    # Add any additional fields from the response
                    for key, value in data.items():
                        if key not in desktop_data:
                            desktop_data[key] = value

                    logger.debug(f"Returning desktop: {desktop_data}")
                    return Desktop.from_dict(desktop_data)
                else:
                    # Log unexpected response format but try to handle it
                    logger.warning(
                        f"Unexpected response format from {endpoint}: {data}"
                    )
                    # If response doesn't have id, it might be a different format
                    # Try to extract useful information
                    if isinstance(data, dict) and any(
                        key in data for key in ["desktop", "result", "data"]
                    ):
                        for key in ["desktop", "result", "data"]:
                            if (
                                key in data
                                and isinstance(data[key], dict)
                                and "id" in data[key]
                            ):
                                return Desktop.from_dict(data[key])

                    # If we can't parse the response, create a minimal desktop entry
                    # This shouldn't happen but provides fallback
                    return Desktop(
                        id=str(data.get("id", "unknown")),
                        name=name,
                        state="Creating",
                        description=description,
                        template_id=template_id,
                    )
            else:
                logger.error(f"Invalid response format from {endpoint}: {data}")
                raise HttpError(500, f"Invalid response format from API: {type(data)}")

        except HttpError as e:
            logger.error(f"Desktop creation failed: HTTP {e.status_code} - {e}")
            raise

        # If all endpoints failed, raise the last error
        if last_error:
            raise last_error
        else:
            raise HttpError(404, "Unable to find working desktop creation endpoint")

    def wait_for_state(
        self,
        desktop_id: str,
        target_state: str,
        timeout: float = 120.0,
        poll_interval: float = 3.0,
    ) -> str:
        """Poll the desktop state until it matches *target_state* or the timeout expires.

        Args:
            desktop_id: The desktop UUID to poll.
            target_state: The state to wait for, e.g. ``"Started"`` or ``"Stopped"``.
            timeout: Maximum seconds to wait before raising :class:`TimeoutError`.
            poll_interval: Seconds between polls.

        Returns:
            The final desktop state string.

        Raises:
            :class:`TimeoutError`: Desktop did not reach *target_state* within *timeout* seconds.
            :class:`HttpError`: Non-2xx response from the API during polling.
            :class:`NetworkError`: Network-level connectivity error.
        """
        deadline = time.monotonic() + timeout
        while True:
            desktops = self.get_desktops()
            match = next((d for d in desktops if d.id == desktop_id), None)
            current_state = match.state if match else "Unknown"
            if current_state == target_state:
                return current_state
            if time.monotonic() >= deadline:
                raise TimeoutError(
                    f"Desktop {desktop_id!r} did not reach state {target_state!r} "
                    f"within {timeout:.0f}s (last state: {current_state!r})"
                )
            time.sleep(poll_interval)

    @property
    def isardvdi_session(self) -> IsardVdiSession:
        return self._session

    # ------------------------------------------------------------------
    # Bastion (SSH jump-host)  — /api/v3/desktop/bastion and related
    # ------------------------------------------------------------------

    def is_bastion_allowed(self) -> bool:
        """Return ``True`` if the current user is allowed to use the SSH bastion.

        Calls ``GET /api/v3/user/bastion/allowed``.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = self._get("/api/v3/user/bastion/allowed")
        if isinstance(data, bool):
            return data
        if isinstance(data, dict):
            return bool(data.get("allowed", data.get("result", False)))
        return bool(data)

    def get_bastion(self, desktop_id: str) -> BastionTarget:
        """Return the current bastion configuration for a desktop.

        Calls ``GET /api/v3/desktop/bastion/{desktop_id}``.

        Args:
            desktop_id: The desktop UUID.

        Returns:
            A :class:`BastionTarget` with the current SSH/HTTP bastion settings.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = self._get(f"/api/v3/desktop/bastion/{desktop_id}")
        if not isinstance(data, dict):
            raise HttpError(
                500, f"Unexpected response type from bastion endpoint: {type(data)}"
            )
        return BastionTarget.from_dict(data)

    def set_bastion(
        self,
        desktop_id: str,
        *,
        ssh_enabled: bool = True,
        ssh_port: int = 22,
        authorized_keys: Optional[list[str]] = None,
        http_enabled: bool = False,
    ) -> BastionTarget:
        """Enable (or update) the SSH bastion for a desktop.

        Fetches the current config, merges the requested changes, and PUTs the
        result back.  Only the fields you pass are changed; everything else is
        left as-is.

        Args:
            desktop_id: The desktop UUID.
            ssh_enabled: Whether SSH bastion access should be active.
            ssh_port: The VM-side SSH port (default 22 — the port inside the VM,
                **not** the bastion listen port).
            authorized_keys: If provided, *replaces* the full key list for this
                desktop.  Pass an empty list to clear all keys.  ``None`` leaves
                the existing keys unchanged.
            http_enabled: Whether HTTP proxying should be active.

        Returns:
            The updated :class:`BastionTarget` (re-fetched after the PUT).

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        current = self.get_bastion(desktop_id)

        keys = (
            authorized_keys
            if authorized_keys is not None
            else current.ssh.authorized_keys
        )

        body = {
            "ssh": {
                "enabled": ssh_enabled,
                "port": ssh_port,
                "authorized_keys": keys,
            },
            "http": {
                "enabled": http_enabled,
                "http_port": current.http.http_port,
                "https_port": current.http.https_port,
                "proxy_protocol": current.http.proxy_protocol,
            },
            "domains": current.domains,
        }
        self._put(f"/api/v3/desktop/bastion/{desktop_id}", body)
        return self.get_bastion(desktop_id)

    def add_ssh_key(self, desktop_id: str, public_key: str) -> BastionTarget:
        """Add a single SSH public key to a desktop's authorized_keys list.

        Idempotent — if the key is already present it is not duplicated.

        Args:
            desktop_id: The desktop UUID.
            public_key: The full public-key string, e.g. ``"ssh-ed25519 AAAA... user@host"``.

        Returns:
            The updated :class:`BastionTarget`.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        current = self.get_bastion(desktop_id)
        existing = current.ssh.authorized_keys
        key = public_key.strip()
        if key not in existing:
            existing = existing + [key]
        return self.set_bastion(
            desktop_id,
            ssh_enabled=current.ssh.enabled,
            ssh_port=current.ssh.port,
            authorized_keys=existing,
            http_enabled=current.http.enabled,
        )

    @property
    def bastion_host(self) -> str:
        """The SSH hostname for the bastion (same as the IsardVDI instance host)."""
        from urllib.parse import urlparse as _up

        return _up(self._base_url).hostname or self._base_url

    @property
    def bastion_port(self) -> int:
        """The SSH listen port for the bastion.

        IsardVDI defaults to ``BASTION_SSH_PORT`` env var, which itself falls
        back to ``HTTPS_PORT`` (443).  Port 22 is only used on custom
        deployments that override both variables.
        """
        return 443

    def ssh_command(self, target: BastionTarget, ssh_user: str = "user") -> list[str]:
        """Return the ``ssh`` argv list to connect to *target* via the bastion.

        The bastion uses the target's ``id`` as the SSH username to the jump
        host, which then proxies the connection into the VM.

        Connection format::

            ssh -J <target.id>@<bastion_host>:<bastion_port> <ssh_user>@<bastion_host>

        Because IsardVDI's bastion is a true SSH proxy (not just a jump host),
        the actual command is simpler::

            ssh <target.id>@<bastion_host> -p <bastion_port>

        Args:
            target: The :class:`BastionTarget` for the desktop.
            ssh_user: The Linux username inside the VM (default ``"user"``).

        Returns:
            A list of strings suitable for :func:`subprocess.exec` / :func:`os.execvp`.
        """
        cmd = [
            "ssh",
            "-o",
            "StrictHostKeyChecking=no",
            "-o",
            "UserKnownHostsFile=/dev/null",
            f"{target.id}@{self.bastion_host}",
        ]
        if self.bastion_port != 22:
            cmd += ["-p", str(self.bastion_port)]
        return cmd

    # ------------------------------------------------------------------
    # Template
    # ------------------------------------------------------------------

    def get_template_hardware(self, template_id: str) -> dict:
        """Return the hardware dict for a template.

        Calls ``GET /api/v3/template/{template_id}`` and extracts
        ``create_dict.hardware``.  Returns an empty dict on any error so
        callers can degrade gracefully.

        Args:
            template_id: The template UUID.

        Returns:
            A dict with at least ``vcpus`` (int) and ``memory`` (int, KiB)
            when available, otherwise ``{}``.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = self._get(f"/api/v3/template/{template_id}")
        if not isinstance(data, dict):
            return {}
        return data.get("create_dict", {}).get("hardware", {})

    def enrich_hardware(self, desktops: list) -> list:
        """Populate ``vcpus`` and ``memory_mb`` on each desktop by fetching templates.

        Fetches each unique template once and fills in the hardware fields
        in-place.  Desktops without a template or whose template fetch fails
        are left with ``None`` values.

        Args:
            desktops: List of :class:`Desktop` objects (mutated in-place).

        Returns:
            The same list, with ``vcpus`` and ``memory_mb`` filled where
            available.
        """
        # Collect unique template IDs
        template_ids = {d.template_id for d in desktops if d.template_id}
        hw_cache: dict[str, dict] = {}
        for tid in template_ids:
            try:
                hw_cache[tid] = self.get_template_hardware(tid)
            except Exception:
                hw_cache[tid] = {}

        for d in desktops:
            hw = hw_cache.get(d.template_id or "", {})
            if "vcpus" in hw:
                d.vcpus = int(hw["vcpus"])
            if "memory" in hw:
                # API returns KiB; convert to MiB
                d.memory_mb = int(hw["memory"]) // 1024

        return desktops

    def get_templates(self, category_id: str | None = None) -> list[Template]:
        """Return available IsardVDI templates/images for the authenticated user.

        Args:
            category_id: Optional category UUID to filter templates.
                        If not provided, tries multiple discovery methods.

        Returns:
            A list of :class:`Template` objects sorted by name.

        Raises:
            :class:`HttpError`: Non-2xx response from the API.
            :class:`NetworkError`: Network-level connectivity error.
        """
        data = None
        endpoints_tried = []

        # Primary endpoints for regular users (based on IsardVDI source code)
        primary_endpoints = [
            "/api/v3/user/templates/allowed/all",  # All enabled templates available to user
            "/api/v3/user/templates/allowed/shared",  # Shared templates available to user
        ]

        # Try primary endpoints first
        for endpoint in primary_endpoints:
            try:
                logger.debug(f"Trying primary endpoint: {endpoint}")
                data = self._get(endpoint)
                if isinstance(data, list):
                    logger.debug(f"Successfully fetched templates from {endpoint}")
                    break
                else:
                    logger.debug(f"Got non-list response from {endpoint}: {type(data)}")
                    data = None
            except HttpError as e:
                logger.debug(f"Failed primary endpoint {endpoint}: {e}")
                endpoints_tried.append(f"{endpoint} ({e.status_code})")

        # If category_id provided and primary endpoints failed, try category-specific endpoint
        if data is None and category_id is not None:
            try:
                endpoint = f"/api/v3/user/templates/{category_id}"
                logger.debug(f"Trying category-specific endpoint: {endpoint}")
                data = self._get(endpoint)
                if isinstance(data, list):
                    logger.debug(
                        f"Successfully fetched templates from category {category_id}"
                    )
                else:
                    data = None
            except HttpError as e:
                logger.debug(f"Failed category endpoint {endpoint}: {e}")
                endpoints_tried.append(f"{endpoint} ({e.status_code})")

        # If primary endpoints failed, try fallback endpoints (for admin/manager roles)
        if data is None:
            fallback_endpoints = [
                "/api/v3/user/templates",  # Requires manager/admin role
                "/api/v3/admin/tables/templates",
                "/api/v3/admin/templates",
            ]

            for endpoint in fallback_endpoints:
                try:
                    logger.debug(f"Trying fallback endpoint: {endpoint}")
                    data = self._get(endpoint)
                    if isinstance(data, list) and data:  # Must be non-empty list
                        logger.debug(f"Successfully fetched templates from {endpoint}")
                        break
                    elif data is not None:
                        logger.debug(
                            f"Got non-list response from {endpoint}: {type(data)}"
                        )
                        data = None
                except HttpError as e:
                    logger.debug(f"Failed fallback endpoint {endpoint}: {e}")
                    endpoints_tried.append(f"{endpoint} ({e.status_code})")

        # If we still have no data, provide a helpful error message
        if data is None or not isinstance(data, list):
            # If we got a 401, it's likely a permissions issue
            if any("401" in endpoint for endpoint in endpoints_tried):
                raise HttpError(
                    401,
                    "Access denied: Your account may not have permission to view templates. Contact your IsardVDI administrator for access to the templates feature.",
                )

            # If we got 404s, the endpoints don't exist or aren't available
            error_msg = f"Unable to fetch templates from any known API endpoint. Tried: {', '.join(endpoints_tried)}"
            raise HttpError(404, error_msg)

        # Convert raw data to Template objects
        templates = []
        for template_data in data:
            try:
                # Handle different response formats from different endpoints
                if isinstance(template_data, dict):
                    template = Template.from_dict(template_data)
                    templates.append(template)
                else:
                    logger.debug(f"Skipping invalid template data: {template_data}")
            except (KeyError, TypeError, ValueError) as e:
                logger.debug(f"Failed to parse template data {template_data}: {e}")
                continue

        return sorted(templates, key=lambda t: t.name.lower())

    def get_spice_file(self, desktop_id: str) -> str:
        """Get the SPICE connection file for a desktop.

        Args:
            desktop_id: The desktop UUID to get SPICE file for.

        Returns:
            The SPICE file content as a string.

        Raises:
            :class:`HttpError`: Non-2xx response from the API (e.g., desktop not found, not running).
            :class:`NetworkError`: Network-level connectivity error.
        """
        url = f"{self._base_url}/api/v3/desktop/{desktop_id}/viewer/file-spice"
        try:
            resp = self._client.get(url, headers=self._headers())
        except httpx.RequestError as exc:
            raise NetworkError(f"Request failed: {exc}", exc) from exc

        if resp.status_code >= 400:
            raise HttpError(
                resp.status_code, f"GET /api/v3/desktop/{desktop_id}/viewer/file-spice"
            )

        # Parse JSON response and extract the content field
        try:
            response_data = resp.json()
            spice_content = response_data.get("content", "")
            if not spice_content:
                raise ValueError("No 'content' field found in SPICE file response")

            # Decode escaped characters
            spice_content = spice_content.replace("\\n", "\n")
            spice_content = spice_content.replace("\\\\", "\\")

            # Fix multi-line certificate format for INI parsing
            spice_content = self._fix_certificate_format(spice_content)

            return spice_content
        except (ValueError, KeyError) as exc:
            # Fallback to returning the raw text if JSON parsing fails
            return resp.text

    def _fix_certificate_format(self, spice_content: str) -> str:
        """Fix multi-line certificate format to be compatible with INI parsers.

        Converts multi-line certificates to single-line format with \\n escapes.
        """
        lines = spice_content.split("\n")
        result_lines = []
        in_certificate = False
        cert_lines = []

        for line in lines:
            if line.strip().startswith("ca=-----BEGIN CERTIFICATE-----"):
                in_certificate = True
                cert_lines = [line.strip()]
            elif line.strip() == "-----END CERTIFICATE-----":
                if in_certificate:
                    cert_lines.append(line.strip())
                    # Join all certificate lines with \\n and create single ca= line
                    cert_content = "\\n".join(cert_lines)
                    result_lines.append(cert_content)
                    in_certificate = False
                    cert_lines = []
                else:
                    result_lines.append(line)
            elif in_certificate:
                cert_lines.append(line.strip())
            else:
                result_lines.append(line)

        return "\n".join(result_lines)
