"""HTTP client for the Maeris API."""

import asyncio
import json
import os
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import quote

import structlog
import httpx
from uuid import uuid4

from maeris_mcp.maeris.types import (
    CreateCollectionRequest,
    MaerisAPI,
    MaerisSecurityFinding,
    MaerisSecurityScan,
)

from maeris_mcp.config import ENV as _ENV

DEFAULT_BASE_URL = _ENV.service_url
DEFAULT_TIMEOUT = 30.0
DEFAULT_SECURITY_SCAN_PATH = os.getenv("MAERIS_SECURITY_SCAN_PATH", "/security/scans")
DEFAULT_SECURITY_FINDINGS_PATH = os.getenv(
    "MAERIS_SECURITY_FINDINGS_PATH",
    "/security/findings",
)
DEFAULT_ADD_CHILD_COLLECTION_PATH = "/api/api/collections/addChildCollection"
FALLBACK_ADD_CHILD_COLLECTION_PATH = "/api/collections/addChildCollection"
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
MAX_ERROR_BODY_PREVIEW = 1000


def _env_int(name: str, default: int) -> int:
    """Load an integer env var, falling back to default on invalid values."""
    try:
        return int(os.getenv(name, str(default)))
    except (TypeError, ValueError):
        return default


def _env_float(name: str, default: float) -> float:
    """Load a float env var, falling back to default on invalid values."""
    try:
        return float(os.getenv(name, str(default)))
    except (TypeError, ValueError):
        return default


DEFAULT_MAX_RETRIES = _env_int("MAERIS_HTTP_MAX_RETRIES", 2)
DEFAULT_RETRY_BASE_DELAY = _env_float("MAERIS_HTTP_RETRY_BASE_DELAY", 0.5)
DEFAULT_RETRY_MAX_DELAY = _env_float("MAERIS_HTTP_RETRY_MAX_DELAY", 8.0)

logger = structlog.get_logger()


class MaerisClientError(Exception):
    """Base error type for Maeris client failures."""

    pass


class AuthenticationError(MaerisClientError):
    """Raised when authentication fails or token is invalid."""

    pass


class APIRequestError(MaerisClientError):
    """Raised when an API request receives a non-success HTTP status."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int,
        method: str,
        url: str,
        response_text: str = "",
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.method = method
        self.url = url
        self.response_text = response_text


class RateLimitError(APIRequestError):
    """Raised when an API request is rate-limited (HTTP 429)."""

    pass


class InsufficientCreditsError(MaerisClientError):
    """Raised when the user lacks AI credits (HTTP 402)."""

    pass


class NetworkError(MaerisClientError):
    """Raised when network/transport errors persist after retries."""

    pass


class ResponseParseError(MaerisClientError):
    """Raised when a successful HTTP response is not valid JSON."""

    pass


class MaerisClient:
    """Async HTTP client for the Maeris API."""

    def __init__(
        self,
        base_url: str | None = None,
        token: str | None = None,
        user_id: str | None = None,
        app_id: str | None = None,
    ) -> None:
        """Initialize Maeris API client.

        Credentials are loaded from TokenStore if not provided explicitly.
        Every request sends Authorization, application_id, and autoelight_userId headers.

        Args:
            base_url: Maeris API base URL
            token: Firebase ID token; loaded from TokenStore if omitted
            user_id: Firebase user ID; loaded from TokenStore if omitted
            app_id: Application ID; loaded from TokenStore if omitted. Pass explicitly
                    when calling from CLI context where MAERIS_CONFIG_DIR is not set.
        """
        self.base_url = base_url or DEFAULT_BASE_URL
        self._client = httpx.AsyncClient(timeout=DEFAULT_TIMEOUT)
        self._max_retries = max(0, DEFAULT_MAX_RETRIES)
        self._retry_base_delay = max(0.0, DEFAULT_RETRY_BASE_DELAY)
        self._retry_max_delay = max(self._retry_base_delay, DEFAULT_RETRY_MAX_DELAY)

        stored = self._load_credentials_from_store()
        self.token = token or os.getenv("MAERIS_TOKEN") or stored.get("token") or ""
        self.application_id = app_id or os.getenv("MAERIS_APP_ID") or stored.get("application_id") or ""
        self.user_id = user_id or os.getenv("MAERIS_USER_ID") or stored.get("user_id") or ""
        self._batch_support_cache: bool | None = None

    def _load_credentials_from_store(self) -> dict:
        """Load token, application_id, and user_id from the repo-specific credentials file.

        Resolution order:
          1. MAERIS_CONFIG_DIR env var (set by .mcp.json for the MCP server process)
          2. ~/.maeris/repos/<sha256(cwd)[:16]>/ (same path written by 'maeris login' / CLI)
        """
        try:
            import hashlib
            from pathlib import Path

            from maeris_mcp.auth.token_store import TokenStore

            config_dir_env = os.getenv("MAERIS_CONFIG_DIR")
            if config_dir_env:
                config_dir = Path(config_dir_env)
            else:
                repo_hash = hashlib.sha256(str(Path.cwd().resolve()).encode()).hexdigest()[:16]
                config_dir = Path.home() / ".maeris" / "repos" / repo_hash

            data = TokenStore(config_dir=config_dir).get_token_data() or {}
            return {
                "token": data.get("access_token"),
                "application_id": data.get("app_id"),
                "user_id": data.get("user_id"),
            }
        except ImportError:
            logger.debug("token_store_not_available")
            return {}

    async def close(self) -> None:
        """Close the HTTP client."""
        await self._client.aclose()

    async def __aenter__(self) -> "MaerisClient":
        return self

    async def __aexit__(self, *args: object) -> None:
        await self.close()

    def _get_headers(self) -> dict[str, str]:
        """Get common headers for all Maeris API requests."""
        headers = {"Content-Type": "application/json"}
        if self.token:
            headers["Authorization"] = f"Bearer {self.token}"
        if self.application_id:
            headers["application_id"] = self.application_id
        if self.user_id:
            headers["autoelight_userId"] = self.user_id
        return headers

    def _response_preview(self, response: httpx.Response) -> str:
        """Return a bounded response-text preview for logs/exceptions."""
        return (response.text or "")[:MAX_ERROR_BODY_PREVIEW]

    def _retry_after_seconds(self, response: httpx.Response) -> float | None:
        """Parse Retry-After header value in seconds when present."""
        retry_after = response.headers.get("Retry-After")
        if not retry_after:
            return None
        try:
            seconds = float(retry_after)
            if seconds >= 0:
                return seconds
        except ValueError:
            pass
        try:
            dt = parsedate_to_datetime(retry_after)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
        except (TypeError, ValueError):
            return None

    def _compute_retry_delay(self, attempt: int, response: httpx.Response | None = None) -> float:
        """Compute retry delay using Retry-After or exponential backoff + jitter."""
        if response is not None and response.status_code == 429:
            retry_after = self._retry_after_seconds(response)
            if retry_after is not None:
                return min(retry_after, self._retry_max_delay)

        base = self._retry_base_delay * (2 ** attempt)
        jitter = random.uniform(0, base * 0.2) if base > 0 else 0.0
        return min(base + jitter, self._retry_max_delay)

    def _parse_json(self, response: httpx.Response) -> object:
        """Parse response JSON with a typed error on malformed payloads."""
        if not (response.text or "").strip():
            return {}
        try:
            return response.json()
        except ValueError as exc:
            logger.error(
                "invalid_json_response",
                method=response.request.method if response.request else "",
                url=str(response.request.url) if response.request else str(response.url),
                status=response.status_code,
                preview=self._response_preview(response)[:300],
            )
            method = response.request.method if response.request else ""
            req_url = str(response.request.url) if response.request else str(response.url)
            raise ResponseParseError(
                f"Invalid JSON response from {method} {req_url} (status {response.status_code})."
            ) from exc

    def _build_url(self, path_or_url: str) -> str:
        """Build an absolute URL from either an absolute URL or a path."""
        if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
            return path_or_url
        return f"{self.base_url.rstrip('/')}/{path_or_url.lstrip('/')}"

    def _candidate_add_child_urls(self, collection_id: str) -> list[str]:
        """Return candidate endpoints for linking an API to a collection."""
        configured_url = os.getenv("MAERIS_ADD_CHILD_COLLECTION_URL", "").strip()
        configured_path = os.getenv(
            "MAERIS_ADD_CHILD_COLLECTION_PATH",
            DEFAULT_ADD_CHILD_COLLECTION_PATH,
        ).strip()

        # If an explicit override URL is set, treat it as authoritative.
        # This is commonly used when the add-child endpoint lives on a different host.
        if configured_url:
            return [self._build_url(configured_url)]

        # Prefer modern endpoints first, then fall back to the legacy child endpoint.
        candidates: list[str] = []
        if configured_path and configured_path != DEFAULT_ADD_CHILD_COLLECTION_PATH:
            candidates.append(self._build_url(configured_path))
        candidates.append(self._build_url(DEFAULT_ADD_CHILD_COLLECTION_PATH))
        if configured_path != FALLBACK_ADD_CHILD_COLLECTION_PATH:
            candidates.append(self._build_url(FALLBACK_ADD_CHILD_COLLECTION_PATH))
        candidates.append(f"{self.base_url.rstrip('/')}/api_collection/{collection_id}/child")

        # Preserve order while removing duplicates.
        deduped: list[str] = []
        seen: set[str] = set()
        for url in candidates:
            if url in seen:
                continue
            seen.add(url)
            deduped.append(url)
        return deduped

    async def _make_request(
        self,
        method: str,
        url: str,
        **kwargs: object,
    ) -> httpx.Response:
        """Make HTTP request with retries/backoff for transient failures.

        Args:
            method: HTTP method (GET, POST, etc.)
            url: Request URL
            **kwargs: Additional arguments for httpx request

        Returns:
            HTTP response

        Raises:
            AuthenticationError: If authentication fails
            RateLimitError: If rate-limited after retries
            APIRequestError: For non-success HTTP status codes
            NetworkError: If transport errors persist after retries
        """
        for attempt in range(self._max_retries + 1):
            is_last_attempt = attempt == self._max_retries
            try:
                response = await self._client.request(method, url, **kwargs)
            except httpx.TransportError as exc:
                if is_last_attempt:
                    raise NetworkError(
                        f"{method} {url} failed after {attempt + 1} attempts: {exc}"
                    ) from exc
                delay_s = self._compute_retry_delay(attempt)
                logger.warning(
                    "request_retry_transport_error",
                    method=method,
                    url=url,
                    attempt=attempt + 1,
                    max_attempts=self._max_retries + 1,
                    retry_in_s=round(delay_s, 3),
                    error=str(exc),
                )
                await asyncio.sleep(delay_s)
                continue

            # Handle 401 Unauthorized - token expired or invalid
            if response.status_code == 401:
                logger.warning("authentication_failed", status=401, url=url)
                raise AuthenticationError(
                    "Authentication failed. Token may be expired. Run 'maeris auth login' to re-authenticate."
                )

            if response.status_code in RETRYABLE_STATUS_CODES and not is_last_attempt:
                delay_s = self._compute_retry_delay(attempt, response)
                logger.warning(
                    "request_retry_status",
                    method=method,
                    url=url,
                    status=response.status_code,
                    attempt=attempt + 1,
                    max_attempts=self._max_retries + 1,
                    retry_in_s=round(delay_s, 3),
                )
                await asyncio.sleep(delay_s)
                continue

            if response.status_code >= 400:
                body = self._response_preview(response)
                message = f"{method} {url} failed with status {response.status_code}: {body}"
                if response.status_code == 429:
                    raise RateLimitError(
                        message,
                        status_code=response.status_code,
                        method=method,
                        url=url,
                        response_text=body,
                    )
                raise APIRequestError(
                    message,
                    status_code=response.status_code,
                    method=method,
                    url=url,
                    response_text=body,
                )

            return response

        raise NetworkError(f"{method} {url} failed: exhausted retry attempts")

    async def get_applications(self) -> list[dict]:
        """Fetch all applications the user has access to."""
        url = f"{self.base_url}/application"
        logger.info("fetching applications", url=url)

        response = await self._make_request("GET", url, headers=self._get_headers())

        if not response.is_success:
            raise Exception(
                f"Get applications failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)

        # Handle common response shapes: list or wrapped object
        if isinstance(result, list):
            return result
        for key in ["applications", "data", "results", "items"]:
            if isinstance(result.get(key), list):
                return result[key]

        logger.warning("unexpected applications response shape", result=result)
        return []

    # ── Command queue (maeris listen) ──────────────────────────────

    async def poll_commands(self) -> dict | None:
        """GET /commands/pending -- poll for a pending command."""
        endpoint = f"{self.base_url}/commands/pending"
        response = await self._make_request("GET", endpoint, headers=self._get_headers())
        if not response.is_success:
            return None
        data = response.json()
        return data.get("command")

    async def claim_command(self, command_id: str) -> dict | None:
        """PUT /commands/<id>/claim -- atomically claim a pending command."""
        endpoint = f"{self.base_url}/commands/{command_id}/claim"
        response = await self._make_request("PUT", endpoint, headers=self._get_headers())
        if not response.is_success:
            return None
        return response.json()

    async def post_progress(self, command_id: str, lines: list[str]) -> bool:
        """PUT /commands/<id>/progress -- post progress lines."""
        endpoint = f"{self.base_url}/commands/{command_id}/progress"
        response = await self._make_request(
            "PUT", endpoint,
            json={"lines": lines},
            headers=self._get_headers(),
        )
        return response.is_success

    async def get_command_status(self, command_id: str) -> str | None:
        """GET /commands/<id> -- return the status string or None."""
        endpoint = f"{self.base_url}/commands/{command_id}"
        response = await self._make_request(
            "GET", endpoint,
            headers=self._get_headers(),
        )
        if not response.is_success:
            return None
        return response.json().get("status")

    async def complete_command(
        self, command_id: str,
        output: str = "", error: str = "", success: bool = True,
    ) -> dict | None:
        """PUT /commands/<id>/complete -- post final result."""
        endpoint = f"{self.base_url}/commands/{command_id}/complete"
        response = await self._make_request(
            "PUT", endpoint,
            json={"output": output, "error": error, "success": success},
            headers=self._get_headers(),
        )
        if not response.is_success:
            return None
        return response.json()

    # ── AI proxy ──────────────────────────────────────────────────

    async def proxy_anthropic_message(self, body: dict, idempotency_key: str | None = None) -> dict:
        """POST /ai/messages — proxy an Anthropic API call through the Maeris backend."""
        endpoint = f"{self.base_url}/ai/messages"
        headers = self._get_headers()
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key
        try:
            response = await self._client.request(
                "POST", endpoint, json=body, headers=headers,
                timeout=300.0,
            )
        except Exception as exc:
            raise NetworkError(f"POST {endpoint} failed: {exc}") from exc

        if response.status_code == 402:
            detail = ""
            try:
                detail = response.json().get("detail", "Insufficient credits")
            except Exception:
                detail = response.text or "Insufficient credits"
            raise InsufficientCreditsError(detail)

        if response.status_code == 429:
            detail = ""
            try:
                detail = response.json().get("detail", "Rate limited")
            except Exception:
                detail = response.text or "Rate limited"
            raise RateLimitError(
                detail,
                status_code=429,
                method="POST",
                url=endpoint,
                response_text=detail,
            )

        if not response.is_success:
            body_preview = (response.text or "")[:500]
            raise APIRequestError(
                f"AI proxy failed ({response.status_code}): {body_preview}",
                status_code=response.status_code,
                method="POST",
                url=endpoint,
                response_text=body_preview,
            )

        return response.json()

    # ── AI batch proxy ─────────────────────────────────────────────

    async def create_message_batch(self, requests: list[dict]) -> dict:
        """POST /ai/messages/batch — submit independent requests as a batch."""
        endpoint = f"{self.base_url}/ai/messages/batch"
        headers = self._get_headers()
        try:
            response = await self._client.request(
                "POST", endpoint, json={"requests": requests}, headers=headers,
                timeout=60.0,
            )
        except Exception as exc:
            raise NetworkError(f"POST {endpoint} failed: {exc}") from exc

        if response.status_code == 402:
            detail = ""
            try:
                detail = response.json().get("detail", "Insufficient credits")
            except Exception:
                detail = response.text or "Insufficient credits"
            raise InsufficientCreditsError(detail)

        if response.status_code == 429:
            detail = ""
            try:
                detail = response.json().get("detail", "Rate limited")
            except Exception:
                detail = response.text or "Rate limited"
            raise RateLimitError(
                detail, status_code=429, method="POST", url=endpoint,
                response_text=detail,
            )

        if not response.is_success:
            body_preview = (response.text or "")[:500]
            raise APIRequestError(
                f"Create batch failed ({response.status_code}): {body_preview}",
                status_code=response.status_code, method="POST", url=endpoint,
                response_text=body_preview,
            )

        return response.json()

    async def get_batch_status(self, batch_id: str) -> dict:
        """GET /ai/messages/batch/{batch_id} — check processing status."""
        endpoint = f"{self.base_url}/ai/messages/batch/{batch_id}"
        try:
            response = await self._client.request(
                "GET", endpoint, headers=self._get_headers(), timeout=30.0,
            )
        except Exception as exc:
            raise NetworkError(f"GET {endpoint} failed: {exc}") from exc

        if not response.is_success:
            body_preview = (response.text or "")[:500]
            raise APIRequestError(
                f"Batch status failed ({response.status_code}): {body_preview}",
                status_code=response.status_code, method="GET", url=endpoint,
                response_text=body_preview,
            )

        return response.json()

    async def get_batch_results(self, batch_id: str) -> list[dict]:
        """GET /ai/messages/batch/{batch_id}/results — download completed results."""
        endpoint = f"{self.base_url}/ai/messages/batch/{batch_id}/results"
        try:
            response = await self._client.request(
                "GET", endpoint, headers=self._get_headers(), timeout=120.0,
            )
        except Exception as exc:
            raise NetworkError(f"GET {endpoint} failed: {exc}") from exc

        if not response.is_success:
            body_preview = (response.text or "")[:500]
            raise APIRequestError(
                f"Batch results failed ({response.status_code}): {body_preview}",
                status_code=response.status_code, method="GET", url=endpoint,
                response_text=body_preview,
            )

        data = response.json()
        return data.get("results", data if isinstance(data, list) else [])

    async def poll_batch_until_complete(
        self,
        batch_id: str,
        poll_interval: float = 10.0,
        max_wait: float = 600.0,
        on_progress=None,
    ) -> list[dict]:
        """Poll until processing_status is 'ended', then fetch and return results.

        Raises TimeoutError if max_wait is exceeded.
        """
        elapsed = 0.0
        while elapsed < max_wait:
            status = await self.get_batch_status(batch_id)
            processing_status = status.get("processing_status", "unknown")
            counts = status.get("request_counts", {})

            if on_progress:
                succeeded = counts.get("succeeded", 0)
                processing = counts.get("processing", 0)
                on_progress(f"Batch {batch_id[:12]}… — {processing_status} "
                            f"(succeeded: {succeeded}, processing: {processing})")

            if processing_status == "ended":
                return await self.get_batch_results(batch_id)

            if processing_status in ("canceled", "canceling", "expired"):
                raise APIRequestError(
                    f"Batch {batch_id} terminated with status: {processing_status}",
                    status_code=0, method="GET",
                    url=f"/ai/messages/batch/{batch_id}",
                    response_text=processing_status,
                )

            await asyncio.sleep(poll_interval)
            elapsed += poll_interval

        raise TimeoutError(
            f"Batch {batch_id} did not complete within {max_wait}s"
        )

    async def check_batch_support(self) -> bool:
        """Probe /ai/messages/batch/probe. Return True if available, False on 404.

        Result is cached for the lifetime of this client instance.
        """
        if self._batch_support_cache is not None:
            return self._batch_support_cache

        endpoint = f"{self.base_url}/ai/messages/batch/probe"
        try:
            response = await self._client.request(
                "GET", endpoint, headers=self._get_headers(), timeout=10.0,
            )
            self._batch_support_cache = response.status_code != 404
        except Exception:
            self._batch_support_cache = False

        return self._batch_support_cache

    async def create_collection(self, name: str, parent_collection_id: str = "") -> str:
        """Create a new collection in Maeris."""
        url = f"{self.base_url}/api_collection"
        payload = CreateCollectionRequest(name=name, parent_collection_id=parent_collection_id)

        logger.info("creating collection", name=name, url=url)

        response = await self._make_request(
            "POST", url,
            json=payload.model_dump(by_alias=True),
            headers=self._get_headers(),
        )

        logger.info("collection response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create collection failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)

        # Try to extract ID from response (multiple possible field names)
        for key in ["api_collection_id", "id", "_id", "collection_id"]:
            if collection_id := result.get(key):
                logger.info("collection created", id=collection_id)
                return str(collection_id)

        logger.warning("collection created but no ID in response")
        return ""

    async def create_api(self, api: MaerisAPI) -> str:
        """Create a new API entry in Maeris."""
        url = f"{self.base_url}/apis"

        logger.info("creating API", name=api.name, method=api.type, url=url)

        response = await self._make_request(
            "POST", url,
            json=api.model_dump(by_alias=True),
            headers=self._get_headers(),
        )

        logger.info("API response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create API failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)

        # Try to extract ID from response
        for key in ["id", "_id", "api_id"]:
            if api_id := result.get(key):
                logger.info("API created", id=api_id)
                return str(api_id)

        logger.warning("API created but no ID in response")
        return ""


    async def move_api_to_collection(self, collection_id: str, api_id: str) -> None:
        """Attach an existing API to a collection with endpoint/payload compatibility fallbacks."""
        payload_item = {"type": "REQUEST", "id": api_id}
        payload_list = [payload_item]
        legacy_payloads: list[object] = [
            payload_list,
            {"children": payload_list},
        ]
        modern_payloads: list[object] = [
            {"api_collection_id": collection_id, "children": payload_list},
            {"api_collection_id": collection_id, "child": payload_list},
            {"api_collection_id": collection_id, "childs": payload_list},
            {"collection_id": collection_id, "children": payload_list},
            {"collection_id": collection_id, "child": payload_list},
            {"api_collection_id": collection_id, "child_id": api_id, "type": "REQUEST"},
        ]

        last_error: Exception | None = None
        headers = self._get_headers()
        legacy_url_suffix = f"/api_collection/{collection_id}/child"
        for url in self._candidate_add_child_urls(collection_id):
            payloads = legacy_payloads if url.endswith(legacy_url_suffix) else modern_payloads
            for payload in payloads:
                try:
                    await self._make_request("POST", url, json=payload, headers=headers)
                    return
                except AuthenticationError:
                    raise
                except APIRequestError as exc:
                    last_error = exc
                    # Endpoint likely does not exist in this deployment.
                    if exc.status_code in {404, 405}:
                        break
                    continue
                except MaerisClientError as exc:
                    last_error = exc
                    continue

        if last_error is not None:
            raise last_error
        raise MaerisClientError("Move API failed: no compatible add-child endpoint succeeded.")

    async def remove_api_from_collection(self, collection_id: str, api_id: str) -> None:
        url = f"{self.base_url}/api_collection/{collection_id}/child/{api_id}"
        # Prefer path-based delete first (most REST implementations).
        try:
            await self._make_request("DELETE", url, headers=self._get_headers())
            return
        except Exception:
            # Fallback for backends requiring explicit body.
            payload = {"api_collection_id": collection_id, "child_id": api_id}
            await self._make_request("DELETE", url, json=payload, headers=self._get_headers())

    async def get_all_apis(self) -> list[dict]:
        """GET /apis — list all APIs for the connected app."""
        url = f"{self.base_url}/apis"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get all APIs failed with status {response.status_code}: {response.text}")
        result = self._parse_json(response)
        return result if isinstance(result, list) else result.get("data", [])

    def _assertion_id_from_payload(self, payload: dict) -> str:
        """Extract assertion id from common response keys."""
        for key in ["api_assertion_id", "assertion_id", "id", "_id"]:
            value = payload.get(key)
            if value:
                return str(value)
        return ""

    async def add_assertion(
        self,
        api_id: str,
        assertion_nature: str,
        assertion_type: str,
        assertion_value: dict,
    ) -> dict:
        """POST /api/assertions — create assertion for an API."""
        url = f"{self.base_url}/api/assertions"
        payload = {
            "api_id": api_id,
            "assertion_nature": assertion_nature,
            "assertion_type": assertion_type,
            "assertion_value": assertion_value,
        }
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Add assertion failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_assertions(self) -> list[dict]:
        """GET /api/assertions — list assertions."""
        url = f"{self.base_url}/api/assertions"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get assertions failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["data", "items", "results", "assertions", "api_assertions"]:
            value = result.get(key)
            if isinstance(value, list):
                return value
        return []

    async def get_assertions_for_api(self, api_id: str) -> list[dict]:
        """GET /api/assertions?api_id=... — list assertions for a specific API."""
        url = f"{self.base_url}/api/assertions"
        response = await self._make_request(
            "GET",
            url,
            params={"api_id": api_id},
            headers=self._get_headers(),
        )
        if not response.is_success:
            raise Exception(f"Get assertions for API failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["data", "items", "results", "assertions", "api_assertions"]:
            value = result.get(key)
            if isinstance(value, list):
                return value
        return []

    async def update_assertion(self, api_assertion_id: str, assertion_value: dict) -> dict:
        """PUT /api/assertions/{api_assertion_id} — update assertion value."""
        url = f"{self.base_url}/api/assertions/{api_assertion_id}"
        payload = {"assertion_value": assertion_value}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update assertion failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_assertion(self, api_assertion_id: str) -> None:
        """DELETE /api/assertions/{api_assertion_id}."""
        url = f"{self.base_url}/api/assertions/{api_assertion_id}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete assertion failed: {response.status_code}: {response.text}")

    async def get_api(self, api_id: str) -> dict:
        """GET /apis/{id} — returns {api: {...}, param_headers: [...]}."""
        url = f"{self.base_url}/apis/{api_id}"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get API failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def update_api(self, api_id: str, data: dict) -> dict:
        """PUT /apis/{id} — update url, type, name, payload, verification."""
        url = f"{self.base_url}/apis/{api_id}"
        response = await self._make_request("PUT", url, json=data, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update API failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_api(self, api_id: str) -> None:
        """DELETE /apis/{id}."""
        url = f"{self.base_url}/apis/{api_id}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete API failed with status {response.status_code}: {response.text}")

    async def run_api(self, api_id: str, env_id: str | None = None, flow_id: str | None = None) -> dict:
        """POST /api/run - execute a single API."""
        url = f"{self.base_url}/api/run"
        payload: dict[str, str] = {"api_id": api_id}
        if env_id:
            payload["env_id"] = env_id
        if flow_id:
            payload["flow_id"] = flow_id
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Run API failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def run_flow(self, flow_id: str, env_id: str) -> dict:
        """POST /api/run_flow - execute a single flow."""
        url = f"{self.base_url}/api/run_flow"
        payload = {"flow_id": flow_id, "env_id": env_id}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Run flow failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def add_param_header(self, api_id: str, headers: list[dict], params: list[dict]) -> dict:
        """POST /api_param_header/{api_id} — payload: {headers: [...], params: [...]}."""
        url = f"{self.base_url}/api_param_header/{api_id}"
        payload = {"headers": headers, "params": params}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Add param/header failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def update_param_header(self, api_id: str, field_name: str, field_value: str, field_type: str) -> dict:
        """PUT /api_param_header/{api_id} — payload: {field_name, field_value, type}."""
        url = f"{self.base_url}/api_param_header/{api_id}"
        type_map = {"header": "HEADERS", "param": "PARAMS", "headers": "HEADERS", "params": "PARAMS"}
        payload = {"field_name": field_name, "field_value": field_value, "type": type_map.get(field_type.lower(), field_type.upper())}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update param/header failed with status {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_param_header(self, api_id: str, field_name: str) -> None:
        """DELETE /api_param_header/{api_id}/{field_name}."""
        url = f"{self.base_url}/api_param_header/{api_id}/{field_name}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete param/header failed with status {response.status_code}: {response.text}")

    async def create_security_scan(self, scan: MaerisSecurityScan) -> str:
        """Create a consolidated security scan entry in Maeris."""
        url = f"{self.base_url}{DEFAULT_SECURITY_SCAN_PATH}"

        logger.info("creating security scan", scan_id=scan.scan_id, url=url)

        response = await self._make_request(
            "POST", url,
            json=scan.model_dump(by_alias=True),
            headers=self._get_headers(),
        )

        logger.info("security scan response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create security scan failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        for key in [
            "scan_id",
            "scanId",
            "security_scan_id",
            "securityScanId",
            "id",
            "_id",
        ]:
            if scan_id := result.get(key):
                logger.info("security scan created", id=scan_id)
                return str(scan_id)

        logger.warning("security scan created but no ID in response")
        return scan.scan_id

    def _build_security_finding_payload(self, finding: MaerisSecurityFinding) -> dict:
        """Convert a finding model to the backend payload format."""
        data = finding.model_dump(by_alias=True)
        cvss = data.get("cvss")
        if isinstance(cvss, dict):
            data["cvss"] = cvss.get("score", 0.0)
        if "reference_links" in data and "references" not in data:
            data["references"] = data.pop("reference_links") or []
        data.setdefault("references", [])
        return data

    async def create_security_finding(self, finding: MaerisSecurityFinding) -> str:
        """Create an individual security scan finding in Maeris."""
        url = f"{self.base_url}{DEFAULT_SECURITY_FINDINGS_PATH}"

        logger.info(
            "creating security finding",
            scan_id=finding.scan_id,
            finding_id=finding.scan_finding_id,
            url=url,
        )
        payload = {"findings": [self._build_security_finding_payload(finding)]}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())

        logger.info("security finding response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create security finding failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        for key in ["scan_finding_id", "security_scan_finding_id", "id", "_id", "finding_id"]:
            if finding_id := result.get(key):
                logger.info("security finding created", id=finding_id)
                return str(finding_id)

        logger.warning("security finding created but no ID in response")
        return finding.scan_finding_id

    async def rename_collection(self, collection_id: str, new_name: str) -> dict:
        """PUT /api_collection/{id} — rename a collection."""
        url = f"{self.base_url}/api_collection/{collection_id}"
        response = await self._make_request("PUT", url, json={"name": new_name}, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Rename collection failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_collection(self, collection_id: str) -> None:
        """DELETE /api_collection/{id} — delete a collection."""
        url = f"{self.base_url}/api_collection/{collection_id}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete collection failed: {response.status_code}: {response.text}")

    async def get_collections(self) -> list[dict]:
        """GET /api_collection — list root-level collections for the connected app."""
        url = f"{self.base_url}/api_collection"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get collections failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        return result if isinstance(result, list) else result.get("data", [])

    async def get_collection(self, collection_id: str) -> dict:
        """GET /api_collection/{id} — get single collection metadata."""
        url = f"{self.base_url}/api_collection/{collection_id}"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get collection failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_collection_children(self, collection_ids: list[str]) -> dict:
        """POST /api_collection/children/batch — get one level of children for given IDs."""
        url = f"{self.base_url}/api_collection/children/batch"
        payload = {"parent_collection_ids": collection_ids}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get children failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_app_users(self) -> list[dict]:
        """GET /app_users — list users for the connected app."""
        url = f"{self.base_url}/app_users"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get app users failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        return result if isinstance(result, list) else result.get("data", [])

    async def create_api_flow(self, name: str) -> dict:
        """POST /api_flow - create API flow."""
        url = f"{self.base_url}/api_flow"
        payload = {"name": name}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Create API flow failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_api_flows(self) -> list[dict]:
        """GET /api_flow - list API flows."""
        url = f"{self.base_url}/api_flow"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get API flows failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["data", "items", "results", "flows", "api_flows"]:
            value = result.get(key)
            if isinstance(value, list):
                return value
        return []

    async def update_api_flow(self, flow_id: str, name: str) -> dict:
        """PUT /api_flow/{flow_id} - update API flow name."""
        url = f"{self.base_url}/api_flow/{flow_id}"
        payload = {"name": name}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update API flow failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_api_flow(self, flow_id: str) -> None:
        """DELETE /api_flow/{flow_id}."""
        url = f"{self.base_url}/api_flow/{flow_id}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete API flow failed: {response.status_code}: {response.text}")

    async def create_api_flow_map(self, flow_id: str, flow_map: list[dict]) -> dict:
        """POST /api_flow/{flow_id}/flow_map."""
        url = f"{self.base_url}/api_flow/{flow_id}/flow_map"
        payload = {"flow_map": flow_map}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Create API flow map failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_api_flow_map(self, flow_id: str) -> dict | list[dict]:
        """GET /api_flow/{flow_id}/flow_map."""
        url = f"{self.base_url}/api_flow/{flow_id}/flow_map"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get API flow map failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def add_api_flow_params(self, flow_id: str, params: list[dict]) -> dict:
        """POST /api_flow/{flow_id}/params."""
        url = f"{self.base_url}/api_flow/{flow_id}/params"
        payload = {"params": params}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Add API flow params failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_api_flow_params(self, flow_id: str) -> dict | list[dict]:
        """GET /api_flow/{flow_id}/params."""
        url = f"{self.base_url}/api_flow/{flow_id}/params"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get API flow params failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def update_api_flow_params(self, flow_id: str, params: list[dict]) -> dict:
        """PUT /api_flow/{flow_id}/params."""
        url = f"{self.base_url}/api_flow/{flow_id}/params"
        payload = {"params": params}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update API flow params failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_api_flow_params(self, flow_id: str) -> None:
        """DELETE /api_flow/{flow_id}/params."""
        url = f"{self.base_url}/api_flow/{flow_id}/params"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete API flow params failed: {response.status_code}: {response.text}")

    async def create_environment(self, name: str) -> dict:
        """POST /api_environment - create environment (idempotent).

        If an environment with the same name already exists, returns the
        existing one instead of raising a duplicate error.
        """
        # Check for existing environment first
        existing = await self.get_environments()
        for env in existing:
            if (env.get("name") or "").lower() == name.lower():
                return env

        url = f"{self.base_url}/api_environment"
        payload = {"name": name}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Create environment failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_environments(self) -> list[dict]:
        """GET /api_environment - list environments."""
        url = f"{self.base_url}/api_environment"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get environments failed: {response.status_code}: {response.text}")
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["data", "items", "results", "environments", "api_environments"]:
            value = result.get(key)
            if isinstance(value, list):
                return value
        return []

    async def update_environment(self, environment_id: str, name: str) -> dict:
        """PUT /api_environment/{environmentId} - update environment name."""
        url = f"{self.base_url}/api_environment/{environment_id}"
        payload = {"name": name}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update environment failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_environment(self, environment_id: str) -> None:
        """DELETE /api_environment/{environmentId}."""
        url = f"{self.base_url}/api_environment/{environment_id}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete environment failed: {response.status_code}: {response.text}")

    async def add_environment_fields(self, environment_id: str, fields: list[dict]) -> dict:
        """POST /api_environment/{environmentId}/data - add field entries."""
        url = f"{self.base_url}/api_environment/{environment_id}/data"
        payload = {"fields": fields}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Add environment fields failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def get_environment_fields(self, environment_id: str) -> dict:
        """GET /api_environment/{environmentId}/data."""
        url = f"{self.base_url}/api_environment/{environment_id}/data"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Get environment fields failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def update_environment_field(self, environment_id: str, field_name: str, field_value: str) -> dict:
        """PUT /api_environment/{environmentId}/data - update one field."""
        url = f"{self.base_url}/api_environment/{environment_id}/data"
        payload = {"field_name": field_name, "field_value": field_value}
        response = await self._make_request("PUT", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Update environment field failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def delete_environment_field(self, environment_id: str, field_name: str) -> None:
        """DELETE /api_environment/{environmentId}/{field_name}."""
        safe_field_name = quote(field_name, safe="")
        url = f"{self.base_url}/api_environment/{environment_id}/{safe_field_name}"
        response = await self._make_request("DELETE", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Delete environment field failed: {response.status_code}: {response.text}")

    async def create_security_findings(
        self,
        findings: list[MaerisSecurityFinding],
    ) -> list[str]:
        """Create security scan findings in Maeris (batch)."""
        url = f"{self.base_url}{DEFAULT_SECURITY_FINDINGS_PATH}"

        logger.info(
            "creating security findings batch",
            count=len(findings),
            url=url,
        )
        payload = {"findings": [self._build_security_finding_payload(f) for f in findings]}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())

        logger.info("security findings response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create security findings failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)

        def _extract_ids(items: list[dict]) -> list[str]:
            ids: list[str] = []
            for item in items:
                for key in ["scan_finding_id", "security_scan_finding_id", "id", "_id", "finding_id"]:
                    if value := item.get(key):
                        ids.append(str(value))
                        break
            return ids

        if isinstance(result, dict):
            if isinstance(result.get("findings"), list):
                ids = _extract_ids(result["findings"])
                if ids:
                    return ids
            if isinstance(result.get("ids"), list):
                return [str(v) for v in result["ids"]]
            if isinstance(result.get("results"), list):
                ids = _extract_ids(result["results"])
                if ids:
                    return ids
        elif isinstance(result, list):
            ids = _extract_ids(result)
            if ids:
                return ids

        return [f.scan_finding_id for f in findings]

    async def get_testcase_groups(self) -> list[dict]:
        """Fetch all testcase groups (folders) for the current application via GET /testcase-groups."""
        url = f"{self.base_url}/testcase-groups"
        logger.info("fetching testcase groups", url=url)

        response = await self._make_request("GET", url, headers=self._get_headers())

        logger.info("testcase groups response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Get testcase groups failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["testcase_groups", "groups", "data", "results", "items"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def create_testcase_group(
        self,
        name: str,
        parent_id: str | None = None,
    ) -> str:
        """Create a testcase group (folder) via POST /testcase-groups.

        Returns the testcase_group_id of the created group.
        """
        url = f"{self.base_url}/testcase-groups"
        payload: dict = {"testcase_group_name": name}
        if parent_id:
            payload["testcase_group_parent_id"] = parent_id

        logger.info("creating testcase group", name=name, url=url)

        response = await self._make_request(
            "POST",
            url,
            headers=self._get_headers(),
            json=payload,
        )

        logger.info("create testcase group response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create testcase group failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        group_id = result.get("testcase_group_id") or result.get("id") or result.get("_id") or ""
        logger.info("testcase group created", id=group_id, name=name)
        return str(group_id)

    async def delete_testcase_group(self, testcase_group_id: str) -> dict:
        """Delete a testcase group via DELETE /testcase-groups/<id>."""
        url = f"{self.base_url}/testcase-groups/{testcase_group_id}"
        logger.info("deleting testcase group", id=testcase_group_id, url=url)

        response = await self._make_request(
            "DELETE",
            url,
            headers=self._get_headers(),
        )

        logger.info("delete testcase group response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Delete testcase group failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response) if response.text else {}

    async def create_manual_tests(self, testcases: list[dict]) -> dict:
        """Create manual tests in Maeris via /tests/manual-create."""
        url = f"{self.base_url}/tests/manual-create"
        logger.info("creating manual tests", count=len(testcases), url=url)

        response = await self._make_request(
            "POST",
            url,
            headers=self._get_headers(),
            json=testcases,
            timeout=None,
        )

        logger.info("manual tests response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Create manual tests failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response)

    async def delete_testcases(self, test_ids: list[str]) -> dict:
        """Delete testcases via DELETE /testcases."""
        url = f"{self.base_url}/testcases"
        payload = {"testcase_ids": test_ids}

        logger.info("deleting testcases", count=len(test_ids), url=url)

        response = await self._make_request(
            "DELETE",
            url,
            headers=self._get_headers(),
            json=payload,
        )

        logger.info("delete testcases response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Delete testcases failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response) if response.text else {}

    async def get_testcases(self) -> list[dict]:
        """Fetch all testcases for the current application/user via /testcases."""
        url = f"{self.base_url}/testcases"
        logger.info("fetching testcases", url=url)

        response = await self._make_request(
            "GET",
            url,
            headers=self._get_headers(),
        )

        logger.info("testcases response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Get testcases failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["testcases", "data", "results", "items"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def get_components_for_testcases(self, testcase_ids: list[str]) -> dict:
        """Fetch step components for testcases via /tests/components-by-testcase-ids."""
        url = f"{self.base_url}/tests/components-by-testcase-ids"
        response = await self._make_request(
            "POST",
            url,
            headers=self._get_headers(),
            json={"testcase_ids": testcase_ids},
        )
        if not response.is_success:
            raise Exception(
                f"Get components failed with status {response.status_code}: {response.text}"
            )
        return response.json()

    async def get_verifications_by_testcase_id(self, testcase_id: str) -> list[dict]:
        """Fetch verifications for a testcase via GET /verifications/<testcase_id>."""
        url = f"{self.base_url}/verifications/{testcase_id}"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(
                f"Get verifications failed with status {response.status_code}: {response.text}"
            )
        result = response.json()
        if isinstance(result, list):
            return result
        for key in ["verifications", "data", "results"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def get_custom_data_for_testcase(self, testcase_id: str) -> list[dict]:
        """Fetch custom input data for a testcase via GET /custom_data_testcase."""
        url = f"{self.base_url}/custom_data_testcase"
        response = await self._make_request(
            "GET",
            url,
            headers=self._get_headers(),
            params={"testcase_id": testcase_id},
        )
        if not response.is_success:
            raise Exception(
                f"Get custom data failed with status {response.status_code}: {response.text}"
            )
        result = response.json()
        if isinstance(result, list):
            return result
        for key in ["custom_data", "data", "results"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def run_testcases(
        self,
        test_ids: list[str],
        test_execution_id: str | None = None,
        type_of_test_run: str = "manual",
    ) -> dict:
        """Run testcases via /run_test and return the response."""
        url = f"{self.base_url}/run_test"
        payload = {
            "testExecutionId": test_execution_id or str(uuid4()),
            "typeOfTestRun": type_of_test_run,
            "testIdsToRun": test_ids,
        }

        logger.info("running testcases", count=len(test_ids), url=url)

        response = await self._make_request(
            "POST",
            url,
            headers=self._get_headers(),
            json=payload,
            timeout=None,  # test execution can take minutes; don't cap it
        )

        logger.info("run testcases response", status=response.status_code)

        if not response.is_success:
            raise Exception(
                f"Run testcases failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response)

    async def download_testcase_scripts_zip(
        self,
        test_ids: list[str],
        languages: list[str] | None = None,
        browsers: list[str] | None = None,
        skip_credits: bool = True,
    ) -> bytes:
        """Download generated testcase scripts as a ZIP via POST /testcases/download.

        Notes:
        - ``languages`` maps to backend TEST_LANGUAGES (currently "python"/"javascript").
        - ``skip_credits`` sends explicit CLI intent markers (payload + header). Backend
          must honor these markers to truly bypass credit deduction.
        """
        url = f"{self.base_url}/testcases/download"
        payload = {
            "testIdsToDownload": test_ids,
            "languages": languages or ["python"],
            "browsers": browsers or ["chromium"],
            "source": "maeris_cli",
            # Compatibility markers for backend credit bypass handling.
            "skip_credits": bool(skip_credits),
            "skip_credit_check": bool(skip_credits),
            "is_cli": True,
        }
        headers = self._get_headers()
        if skip_credits:
            headers["x-maeris-cli-no-credits"] = "true"

        logger.info(
            "downloading testcase scripts",
            count=len(test_ids),
            languages=payload["languages"],
            url=url,
            skip_credits=skip_credits,
        )

        response = await self._make_request(
            "POST",
            url,
            headers=headers,
            json=payload,
            timeout=None,
        )

        if not response.is_success:
            raise Exception(
                f"Download testcase scripts failed with status {response.status_code}: {response.text}"
            )

        return response.content

    async def get_test_results_for_testcase(self, testcase_id: str) -> list[dict]:
        """GET /result/testcase/<testcase_id> — fetch test results for a specific testcase.

        Returns results sorted by most recent first.

        The backend streams concatenated JSON objects (one per chunk), each
        shaped ``{testcase_id: [result, …]}``.  The final chunk is ``{}``.
        Results within each chunk are sorted ascending by ``created_at``, so
        we reverse the merged list to get most-recent-first.
        """
        import re as _re

        url = f"{self.base_url}/result/testcase/{testcase_id}"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(
                f"Get test results failed with status {response.status_code}: {response.text}"
            )

        text = (response.text or "").strip()
        if not text:
            return []
        all_results: list[dict] = []
        for obj in self._parse_streamed_json_chunks(text):
            if not isinstance(obj, dict) or not obj:
                continue

            all_results.extend(self._extract_results_from_chunk(obj, testcase_id))

        # Backend sorts ascending by created_at — reverse for most-recent-first
        all_results.reverse()
        return all_results

    async def get_test_results_for_testcases(
        self,
        testcase_ids: list[str],
        ui_environment_id: str | None = None,
    ) -> dict[str, list[dict]]:
        """POST /test-results/batch — fetch grouped test results for testcase IDs."""
        url = f"{self.base_url}/test-results/batch"
        payload: dict[str, object] = {"testcase_ids": testcase_ids}
        if ui_environment_id:
            payload["ui_environment_id"] = ui_environment_id

        response = await self._make_request(
            "POST",
            url,
            headers=self._get_headers(),
            json=payload,
        )
        if not response.is_success:
            raise Exception(
                f"Get batch test results failed with status {response.status_code}: {response.text}"
            )

        grouped_results: dict[str, list[dict]] = {
            str(testcase_id): []
            for testcase_id in testcase_ids
            if testcase_id
        }
        for obj in self._parse_streamed_json_chunks(response.text or ""):
            if not isinstance(obj, dict) or not obj:
                continue
            for testcase_id, results in self._extract_grouped_results_from_chunk(obj).items():
                if testcase_id not in grouped_results:
                    grouped_results[testcase_id] = []
                grouped_results[testcase_id].extend(results)
        return grouped_results

    @staticmethod
    def _parse_streamed_json_chunks(text: str) -> list[dict]:
        """Parse concatenated JSON chunks returned by streamed test result endpoints."""
        import re as _re

        cleaned = (text or "").strip()
        if not cleaned:
            return []
        cleaned = _re.sub(r'\bNaN\b', 'null', cleaned)
        cleaned = _re.sub(r'\b-?Infinity\b', 'null', cleaned)

        chunks: list[dict] = []
        decoder = json.JSONDecoder()
        pos = 0
        while pos < len(cleaned):
            while pos < len(cleaned) and cleaned[pos] in ' \t\r\n':
                pos += 1
            if pos >= len(cleaned):
                break
            try:
                obj, end_pos = decoder.raw_decode(cleaned, pos)
                pos = end_pos
            except (json.JSONDecodeError, ValueError):
                break
            if isinstance(obj, dict):
                chunks.append(obj)
        return chunks

    @staticmethod
    def _extract_results_from_chunk(obj: dict, testcase_id: str) -> list[dict]:
        """Extract the results list from a single streamed JSON chunk."""
        # Primary format: {testcase_id: [results...]}
        if testcase_id in obj and isinstance(obj[testcase_id], list):
            return obj[testcase_id]
        # Fallback: check common wrapper keys
        for key in ("data", "results", "test_results", "items"):
            if isinstance(obj.get(key), list):
                return obj[key]
        # Last resort: if the dict has exactly one key whose value is a list
        values = list(obj.values())
        if len(values) == 1 and isinstance(values[0], list):
            return values[0]
        return []

    @staticmethod
    def _extract_grouped_results_from_chunk(obj: dict) -> dict[str, list[dict]]:
        """Extract ``{testcase_id: [results...]}`` mappings from a streamed chunk."""
        grouped: dict[str, list[dict]] = {}

        def _merge(source: dict) -> None:
            for testcase_id, results in source.items():
                if isinstance(results, list):
                    grouped[str(testcase_id)] = [item for item in results if isinstance(item, dict)]

        _merge(obj)
        if grouped:
            return grouped

        for key in ("data", "results", "test_results", "items"):
            value = obj.get(key)
            if isinstance(value, dict):
                _merge(value)
                if grouped:
                    return grouped
        return grouped

    async def run_api_security_scan(self, api_ids: list[str]) -> dict:
        """POST /security/scans/api - trigger security scan for saved APIs."""
        url = f"{self.base_url}/security/scans/api"
        payload = {"api_ids": api_ids}
        response = await self._make_request("POST", url, json=payload, headers=self._get_headers())
        if not response.is_success:
            raise Exception(f"Run API security scan failed: {response.status_code}: {response.text}")
        return self._parse_json(response)

    async def create_application(self, name: str, url: str) -> dict:
        """POST /onboard_app — create a new application.

        Returns the response dict containing ``application_id``.
        """
        endpoint = f"{self.base_url}/onboard_app"
        payload = {
            "name": name,
            "url": url,
            "metadata_properties": {},
        }

        logger.info("creating application", name=name, url=endpoint)

        response = await self._make_request(
            "POST", endpoint, json=payload, headers=self._get_headers()
        )

        if not response.is_success:
            raise Exception(
                f"Create application failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response)

    async def get_plans(self) -> list[dict]:
        """GET /plans — list all available plans (public endpoint)."""
        endpoint = f"{self.base_url}/plans"
        logger.info("fetching plans", url=endpoint)

        response = await self._make_request("GET", endpoint, headers=self._get_headers())

        if not response.is_success:
            raise Exception(
                f"Get plans failed with status {response.status_code}: {response.text}"
            )

        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["plans", "data", "results", "items"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def get_active_plan(self) -> dict:
        """GET /application/plans/active — get the active subscription for the current app."""
        endpoint = f"{self.base_url}/application/plans/active"
        logger.info("fetching active plan", url=endpoint)

        response = await self._make_request("GET", endpoint, headers=self._get_headers())

        if not response.is_success:
            raise Exception(
                f"Get active plan failed with status {response.status_code}: {response.text}"
            )

        return self._parse_json(response)

    async def get_components_by_flu_ids(self, flu_ids: list[str]) -> list[dict]:
        """Fetch components for the given flu_ids via GET /components/flu_ids."""
        url = f"{self.base_url}/components/flu_ids"
        response = await self._make_request(
            "GET",
            url,
            headers=self._get_headers(),
            json={"flu_ids": flu_ids},
        )
        if not response.is_success:
            raise Exception(
                f"Get components by flu_ids failed with status {response.status_code}: {response.text}"
            )
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["components", "data", "results"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def get_flus(self) -> list[dict]:
        """Fetch all FLUs for the current app via GET /flus."""
        url = f"{self.base_url}/flus"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(
                f"Get flus failed with status {response.status_code}: {response.text}"
            )
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["flus", "data", "results"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def edit_manual_testcase(self, testcase_id: str, payload: dict) -> dict:
        """Edit a testcase (steps, name, etc.) via PUT /tests/manual-edit/<testcase_id>.

        The payload ``steps`` array should include ALL steps with ``step_id`` set to the
        flu_id of each existing step.  Fields: step_id, name, cssSelector, xpathSelector,
        actionType, mockInput, type.  Pass ``replace_all_steps=True`` to fully replace the
        step list.
        """
        url = f"{self.base_url}/tests/manual-edit/{testcase_id}"
        response = await self._make_request("PUT", url, headers=self._get_headers(), json=payload)
        if not response.is_success:
            raise Exception(
                f"Edit manual testcase failed with status {response.status_code}: {response.text}"
            )
        return response.json() if response.text else {}

    async def edit_test_step(self, payload: dict) -> dict:
        """Edit a single test step in-place via PUT /teststeps.

        Required keys: flu_id (or component_id), plus at least one of
        css_selector / xpath / name / mock_input / action_taken.
        """
        url = f"{self.base_url}/teststeps"
        response = await self._make_request("PUT", url, headers=self._get_headers(), json=payload)
        if not response.is_success:
            raise Exception(
                f"Edit test step failed with status {response.status_code}: {response.text}"
            )
        return self._parse_json(response) if response.text else {}

    async def get_all_testcase_verifications(self) -> list[dict]:
        """GET /get_all_testcases_verification — fetch all verification steps for the app."""
        url = f"{self.base_url}/get_all_testcases_verification"
        response = await self._make_request("GET", url, headers=self._get_headers())
        if not response.is_success:
            raise Exception(
                f"Get testcase verifications failed: {response.status_code}: {response.text}"
            )
        result = self._parse_json(response)
        if isinstance(result, list):
            return result
        for key in ["data", "verifications", "items", "results"]:
            if isinstance(result.get(key), list):
                return result[key]
        return []

    async def edit_testcase_verification(self, payload: dict) -> dict:
        """PUT /edit_testcases_verification_by_testcases_verification_id.

        Required keys: testcases_verification_id, plus at least one of
        css_selector / xpath / assertion_type / assertion_value / component_id.
        """
        url = f"{self.base_url}/edit_testcases_verification_by_testcases_verification_id"
        response = await self._make_request("PUT", url, headers=self._get_headers(), json=payload)
        if not response.is_success:
            raise Exception(
                f"Edit testcase verification failed: {response.status_code}: {response.text}"
            )
        return self._parse_json(response) if response.text else {}

    async def delete_test_step(self, flu_id: str, testcase_id: str) -> dict:
        """Remove a step from a testcase via DELETE /teststeps."""
        url = f"{self.base_url}/teststeps"
        response = await self._make_request(
            "DELETE", url,
            headers=self._get_headers(),
            json={"flu_id": flu_id, "testcase_id": testcase_id},
        )
        if not response.is_success:
            raise Exception(
                f"Delete test step failed with status {response.status_code}: {response.text}"
            )
        return self._parse_json(response) if response.text else {}

    async def add_test_step(
        self, testcase_id: str, step_index: int, component: dict
    ) -> dict:
        """Insert a new step into a testcase via PUT /edit_testcase (is_add_step=true)."""
        url = f"{self.base_url}/edit_testcase"
        payload = [{
            "id": testcase_id,
            "is_add_step": True,
            "step_index": step_index,
            "component_of_new_flu": component,
        }]
        response = await self._make_request("PUT", url, headers=self._get_headers(), json=payload)
        if not response.is_success:
            raise Exception(
                f"Add test step failed with status {response.status_code}: {response.text}"
            )
        return self._parse_json(response) if response.text else {}

    async def search_components(
        self, query: str, page_url: str | None = None, top_k: int = 10
    ) -> list[dict]:
        """Query Pinecone for real DOM selectors matching a natural language description.

        Returns list of {css_selector, score, description, page_url} dicts.
        Returns empty list if the app has not been crawled or on any error.
        """
        url = f"{self.base_url}/components/search"
        body: dict = {"query": query, "top_k": top_k}
        if page_url:
            body["page_url"] = page_url
        try:
            response = await self._make_request(
                "POST", url, headers=self._get_headers(), json=body
            )
            if response.is_success:
                return response.json().get("results", [])
        except Exception:
            pass
        return []
