from __future__ import annotations

import json
import time
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Protocol
from urllib import parse, request as urllib_request

from .cdp import CdpClient
from .errors import ChaserTimeoutError, api_error_from_response
from .types import (
    AccountInfo,
    AccountMemberInfo,
    ApiKeyInfo,
    AuditEventInfo,
    BillingSummaryResponse,
    BrowserActionJobInput,
    BrowserCdpTargetInfo,
    BrowserCdpVersionInfo,
    BulkDeleteWorkspacesRequest,
    BulkDeleteWorkspacesResponse,
    BulkTerminateRequest,
    BulkTerminateResponse,
    CreateApiKeyResponse,
    CreateJobRequest,
    CreateSessionRequest,
    CreateWorkspaceRequest,
    CreateCustomerWebhookResponse,
    CreateServiceAccountKeyResponse,
    CustomerWebhookDeliveryInfo,
    CustomerWebhookInfo,
    GithubRepositoriesResponse,
    GithubRunnerJobInput,
    ImportWorkspaceRequest,
    ImportWorkspaceResponse,
    JobInfo,
    JobKindsInfo,
    ListSessionsResponse,
    ListJobsPage,
    BrowserScrapeJobInput,
    PipelineRunJobInput,
    SandboxExecResponse,
    ServiceAccountInfo,
    ServiceAccountKeyInfo,
    SessionCommandInfo,
    SessionBulkTerminateJobInput,
    SessionInfo,
    SessionSelfTestResponse,
    SessionTerminateJobInput,
    SshKeyInfo,
    UploadFileResponse,
    WorkspaceBulkDeleteJobInput,
    WorkspaceDeleteJobInput,
    WorkspaceInfo,
    WorkspaceNameResponse,
    WorkspaceSnapshotInfo,
    WorkspaceTemplateResponse,
)

DEFAULT_BASE_URL = "https://api.chaser.sh"
DEFAULT_WAIT_TIMEOUT_SECS = 120.0
DEFAULT_WAIT_INTERVAL_SECS = 1.0
READY_SESSION_STATUSES = {"running", "ready", "active"}
TERMINAL_SESSION_STATUSES = {"terminated", "failed"}
TERMINAL_JOB_STATUSES = {"completed", "failed", "canceled"}
RUNNING_COMMAND_STATUSES = {"queued", "starting", "running"}


@dataclass(slots=True)
class HttpResponse:
    status: int
    headers: dict[str, str]
    body: bytes


class Transport(Protocol):
    def request(
        self,
        method: str,
        url: str,
        headers: Mapping[str, str],
        body: bytes | None,
        timeout: float | None,
    ) -> HttpResponse: ...


class UrllibTransport:
    def request(
        self,
        method: str,
        url: str,
        headers: Mapping[str, str],
        body: bytes | None,
        timeout: float | None,
    ) -> HttpResponse:
        req = urllib_request.Request(url=url, data=body, method=method)
        for key, value in headers.items():
            req.add_header(key, value)
        try:
            with urllib_request.urlopen(req, timeout=timeout) as response:
                return HttpResponse(
                    status=response.status,
                    headers={k.lower(): v for k, v in response.headers.items()},
                    body=response.read(),
                )
        except urllib_request.HTTPError as exc:
            return HttpResponse(
                status=exc.code,
                headers={k.lower(): v for k, v in exc.headers.items()},
                body=exc.read(),
            )


def _normalize_list_sessions_response(payload: Any) -> ListSessionsResponse:
    if isinstance(payload, list):
        return {"sessions": payload, "total": len(payload)}
    return payload


class AccountsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client
        self.members = AccountMembersAPI(client)
        self.service_accounts = ServiceAccountsAPI(client)

    def list(self) -> list[AccountInfo]:
        return self._client._request_json("GET", "/v1/accounts")["accounts"]

    def current(self) -> AccountInfo:
        return self._client._request_json("GET", "/v1/accounts/current")["account"]

    def create_organization(self, name: str) -> AccountInfo:
        return self._client._request_json(
            "POST", "/v1/accounts/organizations", json_body={"name": name.strip()}
        )["account"]


class AccountMembersAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self) -> list[AccountMemberInfo]:
        return self._client._request_json("GET", "/v1/accounts/current/members")["members"]

    def add(self, email: str, role: str | None = None) -> AccountMemberInfo:
        payload: dict[str, Any] = {"email": email.strip()}
        if role:
            payload["role"] = role
        return self._client._request_json(
            "POST", "/v1/accounts/current/members", json_body=payload
        )["member"]

    def update(self, user_id: str, role: str) -> AccountMemberInfo:
        return self._client._request_json(
            "PATCH",
            f"/v1/accounts/current/members/{parse.quote(user_id, safe='')}",
            json_body={"role": role.strip()},
        )["member"]

    def remove(self, user_id: str) -> None:
        self._client._request_json(
            "DELETE", f"/v1/accounts/current/members/{parse.quote(user_id, safe='')}"
        )


class ServiceAccountsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client
        self.keys = ServiceAccountKeysAPI(client)

    def list(self) -> list[ServiceAccountInfo]:
        return self._client._request_json("GET", "/v1/accounts/current/service-accounts")[
            "service_accounts"
        ]

    def create(self, name: str) -> ServiceAccountInfo:
        return self._client._request_json(
            "POST",
            "/v1/accounts/current/service-accounts",
            json_body={"name": name.strip()},
        )["service_account"]

    def delete(self, service_account_id: str) -> None:
        self._client._request_json(
            "DELETE",
            f"/v1/accounts/current/service-accounts/{parse.quote(service_account_id, safe='')}",
        )


class ServiceAccountKeysAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self, service_account_id: str) -> list[ServiceAccountKeyInfo]:
        return self._client._request_json(
            "GET",
            f"/v1/accounts/current/service-accounts/{parse.quote(service_account_id, safe='')}/keys",
        )["keys"]

    def create(
        self,
        service_account_id: str,
        *,
        name: str | None = None,
        scopes: list[str] | None = None,
    ) -> CreateServiceAccountKeyResponse:
        payload: dict[str, Any] = {}
        if name is not None:
            payload["name"] = name
        if scopes is not None:
            payload["scopes"] = scopes
        return self._client._request_json(
            "POST",
            f"/v1/accounts/current/service-accounts/{parse.quote(service_account_id, safe='')}/keys",
            json_body=payload,
        )

    def revoke(self, service_account_id: str, key_id: str) -> None:
        self._client._request_json(
            "DELETE",
            f"/v1/accounts/current/service-accounts/{parse.quote(service_account_id, safe='')}/keys/{parse.quote(key_id, safe='')}",
        )


class KeysAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self) -> list[ApiKeyInfo]:
        return self._client._request_json("GET", "/v1/keys")["keys"]

    def create(self, *, name: str | None = None, scopes: list[str] | None = None) -> CreateApiKeyResponse:
        payload: dict[str, Any] = {}
        if name is not None:
            payload["name"] = name
        if scopes is not None:
            payload["scopes"] = scopes
        return self._client._request_json("POST", "/v1/keys", json_body=payload)

    def revoke(self, key_id: str) -> dict[str, Any]:
        return self._client._request_json("DELETE", f"/v1/keys/{parse.quote(key_id, safe='')}")


class SshKeysAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self) -> list[SshKeyInfo]:
        return self._client._request_json("GET", "/v1/ssh-keys")["keys"]

    def create(self, public_key: str, *, name: str | None = None) -> SshKeyInfo:
        payload: dict[str, Any] = {"public_key": public_key}
        if name is not None:
            payload["name"] = name
        return self._client._request_json("POST", "/v1/ssh-keys", json_body=payload)["key"]

    def delete(self, key_id: str) -> None:
        self._client._request_json("DELETE", f"/v1/ssh-keys/{parse.quote(key_id, safe='')}")


class SessionsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def create(self, payload: CreateSessionRequest | Mapping[str, Any]) -> SessionInfo:
        return self._client._request_json("POST", "/v1/sessions", json_body=dict(payload))

    def list(self) -> list[SessionInfo]:
        return self.list_page()["sessions"]

    def list_page(self) -> ListSessionsResponse:
        payload = self._client._request_json("GET", "/v1/sessions")
        return _normalize_list_sessions_response(payload)

    def get(self, session_id: str) -> SessionInfo:
        return self._client._request_json("GET", f"/v1/sessions/{parse.quote(session_id, safe='')}")

    def self_test(self, session_id: str) -> SessionSelfTestResponse:
        return self._client._request_json(
            "POST", f"/v1/sessions/{parse.quote(session_id, safe='')}/self-test"
        )

    def terminate(self, session_id: str, *, force: bool = False) -> None:
        query = {"force": "true"} if force else None
        self._client._request_json(
            "DELETE",
            f"/v1/sessions/{parse.quote(session_id, safe='')}",
            query=query,
        )

    def bulk_terminate(self, payload: Mapping[str, Any]) -> BulkTerminateResponse:
        return self._client._request_json(
            "POST", "/v1/sessions/bulk-terminate", json_body=dict(payload)
        )

    def resize_pty(self, session_id: str, rows: int, cols: int) -> None:
        self._client._request_json(
            "POST",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/resize",
            json_body={"rows": rows, "cols": cols},
        )

    def wait_until_ready(
        self,
        session_id: str,
        *,
        timeout_secs: float = DEFAULT_WAIT_TIMEOUT_SECS,
        interval_secs: float = DEFAULT_WAIT_INTERVAL_SECS,
        predicate: Callable[[SessionInfo], bool] | None = None,
    ) -> SessionInfo:
        deadline = time.monotonic() + timeout_secs
        while True:
            session = self.get(session_id)
            check = predicate or (lambda candidate: candidate.get("status", "").lower() in READY_SESSION_STATUSES)
            if check(session):
                return session
            if session.get("status", "").lower() in TERMINAL_SESSION_STATUSES:
                raise ChaserTimeoutError(
                    f"Session {session_id} reached terminal status {session.get('status')} before becoming ready"
                )
            if time.monotonic() >= deadline:
                raise ChaserTimeoutError(f"Timed out waiting for session {session_id} to become ready")
            time.sleep(interval_secs)

    def pty_websocket_url(self, session_id: str, *, mode: str = "human") -> str:
        parsed = parse.urlsplit(f"{self._client.base_url}/v1/sessions/{parse.quote(session_id, safe='')}/pty")
        scheme = "wss" if parsed.scheme == "https" else "ws"
        query = parse.urlencode({"mode": mode})
        return parse.urlunsplit((scheme, parsed.netloc, parsed.path, query, parsed.fragment))

    def gateway_url(self, session_id: str, tail: str = "") -> str:
        normalized_tail = tail if not tail or tail.startswith("/") else f"/{tail}"
        return f"{self._client.base_url}/s/{parse.quote(session_id, safe='')}{normalized_tail}"

    def forward_url(self, session_id: str, port: int, tail: str = "/") -> str:
        normalized_tail = tail[1:] if tail.startswith("/") else tail
        return f"{self._client.base_url}/s/{parse.quote(session_id, safe='')}/forward/{port}/{normalized_tail}"


class ExecAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def run(self, payload: Mapping[str, Any]) -> SandboxExecResponse:
        return self._client._request_json("POST", "/v1/exec", json_body=dict(payload))

    def in_session(self, session_id: str, payload: Mapping[str, Any]) -> SandboxExecResponse:
        return self._client._request_json(
            "POST",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/exec",
            json_body=dict(payload),
        )


class CommandsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self, session_id: str) -> list[SessionCommandInfo]:
        return self._client._request_json(
            "GET", f"/v1/sessions/{parse.quote(session_id, safe='')}/commands"
        )["commands"]

    def get(self, session_id: str, command_id: str) -> SessionCommandInfo:
        return self._client._request_json(
            "GET",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/commands/{parse.quote(command_id, safe='')}",
        )

    def send_stdin(
        self,
        session_id: str,
        command_id: str,
        input_text: str,
        *,
        encoding: str | None = None,
    ) -> dict[str, Any]:
        payload: dict[str, Any] = {"input": input_text}
        if encoding is not None:
            payload["encoding"] = encoding
        return self._client._request_json(
            "POST",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/commands/{parse.quote(command_id, safe='')}/stdin",
            json_body=payload,
        )

    def kill(self, session_id: str, command_id: str, *, signal: str | None = None) -> dict[str, Any]:
        payload: dict[str, Any] = {}
        if signal is not None:
            payload["signal"] = signal
        return self._client._request_json(
            "POST",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/commands/{parse.quote(command_id, safe='')}/kill",
            json_body=payload,
        )

    def stream_url(
        self,
        session_id: str,
        command_id: str,
        *,
        offset: int | None = None,
        interval_ms: int | None = None,
        max_bytes: int | None = None,
    ) -> str:
        query: dict[str, Any] = {}
        if offset is not None:
            query["from"] = offset
        if interval_ms is not None:
            query["interval_ms"] = interval_ms
        if max_bytes is not None:
            query["max_bytes"] = max_bytes
        return self._client._build_url(
            f"/v1/sessions/{parse.quote(session_id, safe='')}/commands/{parse.quote(command_id, safe='')}/stream",
            query,
        )

    def wait_for_completion(
        self,
        session_id: str,
        command_id: str,
        *,
        timeout_secs: float = DEFAULT_WAIT_TIMEOUT_SECS,
        interval_secs: float = DEFAULT_WAIT_INTERVAL_SECS,
        predicate: Callable[[SessionCommandInfo], bool] | None = None,
    ) -> SessionCommandInfo:
        deadline = time.monotonic() + timeout_secs
        while True:
            command = self.get(session_id, command_id)
            check = predicate or (
                lambda candidate: candidate.get("status", "").lower() not in RUNNING_COMMAND_STATUSES
            )
            if check(command):
                return command
            if time.monotonic() >= deadline:
                raise ChaserTimeoutError(f"Timed out waiting for command {command_id} to finish")
            time.sleep(interval_secs)


class FilesAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def upload(
        self,
        session_id: str,
        *,
        path: str,
        data: bytes | bytearray | memoryview | str,
        filename: str = "upload.bin",
        content_type: str = "application/octet-stream",
    ) -> UploadFileResponse:
        body, multipart_content_type = _encode_multipart(
            fields={"path": path},
            files={"file": (filename, _coerce_bytes(data), content_type)},
        )
        return self._client._request_json(
            "POST",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/upload",
            body=body,
            headers={"Content-Type": multipart_content_type},
        )

    def upload_text(self, session_id: str, path: str, text: str, *, filename: str = "upload.txt") -> UploadFileResponse:
        return self.upload(
            session_id,
            path=path,
            data=text,
            filename=filename,
            content_type="text/plain; charset=utf-8",
        )

    def download(self, session_id: str, path: str) -> bytes:
        return self._client._request_bytes(
            "GET",
            f"/v1/sessions/{parse.quote(session_id, safe='')}/download",
            query={"path": path},
        )

    def download_text(self, session_id: str, path: str, *, encoding: str = "utf-8") -> str:
        return self.download(session_id, path).decode(encoding)


class WorkspacesAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client
        self.snapshots = WorkspaceSnapshotsAPI(client)

    def list(self) -> list[WorkspaceInfo]:
        return self._client._request_json("GET", "/v1/workspaces")

    def create(self, payload: CreateWorkspaceRequest | Mapping[str, Any]) -> WorkspaceInfo:
        return self._client._request_json("POST", "/v1/workspaces", json_body=dict(payload))

    def import_from_github(
        self, payload: ImportWorkspaceRequest | Mapping[str, Any]
    ) -> ImportWorkspaceResponse:
        return self._client._request_json(
            "POST", "/v1/workspaces/import", json_body=dict(payload)
        )

    def rename(self, workspace: str, name: str | None) -> WorkspaceNameResponse:
        return self._client._request_json(
            "PUT",
            f"/v1/workspaces/{parse.quote(workspace, safe='')}/name",
            json_body={"name": name},
        )

    def set_template(self, workspace: str, template: bool) -> WorkspaceTemplateResponse:
        return self._client._request_json(
            "PUT",
            f"/v1/workspaces/{parse.quote(workspace, safe='')}/template",
            json_body={"template": template},
        )

    def delete(self, workspace: str, *, force: bool = False) -> None:
        query = {"force": "true"} if force else None
        self._client._request_json(
            "DELETE", f"/v1/workspaces/{parse.quote(workspace, safe='')}", query=query
        )

    def bulk_delete(self, payload: Mapping[str, Any]) -> BulkDeleteWorkspacesResponse:
        return self._client._request_json(
            "POST", "/v1/workspaces/bulk-delete", json_body=dict(payload)
        )


class WorkspaceSnapshotsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(self, workspace: str) -> list[WorkspaceSnapshotInfo]:
        return self._client._request_json(
            "GET", f"/v1/workspaces/{parse.quote(workspace, safe='')}/snapshots"
        )

    def create(self, workspace: str, *, name: str | None = None) -> WorkspaceSnapshotInfo:
        payload: dict[str, Any] = {}
        if name is not None:
            payload["name"] = name
        return self._client._request_json(
            "POST",
            f"/v1/workspaces/{parse.quote(workspace, safe='')}/snapshots",
            json_body=payload,
        )

    def restore(self, workspace: str, snapshot: str, *, force: bool = False) -> WorkspaceSnapshotInfo:
        payload: dict[str, Any] = {"force": force} if force else {}
        return self._client._request_json(
            "POST",
            f"/v1/workspaces/{parse.quote(workspace, safe='')}/snapshots/{parse.quote(snapshot, safe='')}/restore",
            json_body=payload,
        )

    def delete(self, workspace: str, snapshot: str) -> None:
        self._client._request_json(
            "DELETE",
            f"/v1/workspaces/{parse.quote(workspace, safe='')}/snapshots/{parse.quote(snapshot, safe='')}",
        )


class BrowserAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def version(self, session_id: str) -> BrowserCdpVersionInfo:
        payload = self._client._request_json(
            "GET", f"/v1/sessions/{parse.quote(session_id, safe='')}/json/version"
        )
        return _normalize_cdp_version(payload)

    def list_targets(self, session_id: str) -> list[BrowserCdpTargetInfo]:
        return self._client._request_json(
            "GET", f"/v1/sessions/{parse.quote(session_id, safe='')}/json/list"
        )

    def cdp_websocket_url(
        self,
        session_id: str,
        *,
        wait_until_ready: bool = True,
        timeout_secs: float = DEFAULT_WAIT_TIMEOUT_SECS,
        interval_secs: float = DEFAULT_WAIT_INTERVAL_SECS,
    ) -> str:
        if wait_until_ready:
            self._client.sessions.wait_until_ready(
                session_id, timeout_secs=timeout_secs, interval_secs=interval_secs
            )
        version = self.version(session_id)
        websocket_url = version.get("webSocketDebuggerUrl")
        if not websocket_url:
            raise ChaserTimeoutError(
                f"Browser session {session_id} did not expose webSocketDebuggerUrl"
            )
        return websocket_url

    def connect(self, session_id: str, **kwargs: Any) -> CdpClient:
        websocket_url = self.cdp_websocket_url(session_id, **kwargs)
        client = CdpClient(websocket_url)
        client.connect()
        return client


class GithubAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list_repositories(self) -> GithubRepositoriesResponse:
        return self._client._request_json("GET", "/v1/github/repositories")


class BillingAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def summary(self) -> BillingSummaryResponse:
        return self._client._request_json("GET", "/v1/billing/summary")

    def update_account(self, *, pricing_model: str | None = None, plan_code: str | None = None) -> BillingSummaryResponse:
        payload: dict[str, Any] = {}
        if pricing_model is not None:
            payload["pricing_model"] = pricing_model
        if plan_code is not None:
            payload["plan_code"] = plan_code
        return self._client._request_json("POST", "/v1/billing/account", json_body=payload)

    def create_checkout(
        self,
        *,
        mode: str | None = None,
        plan_code: str | None = None,
        amount_usd: float | None = None,
    ) -> dict[str, Any]:
        payload: dict[str, Any] = {}
        if mode is not None:
            payload["mode"] = mode
        if plan_code is not None:
            payload["plan_code"] = plan_code
        if amount_usd is not None:
            payload["amount_usd"] = amount_usd
        return self._client._request_json("POST", "/v1/billing/checkout", json_body=payload)


class WebhooksAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client
        self.deliveries = WebhookDeliveriesAPI(client)

    def list(self) -> list[CustomerWebhookInfo]:
        return self._client._request_json("GET", "/v1/webhooks")["webhooks"]

    def create(
        self,
        *,
        url: str,
        description: str | None = None,
        events: list[str] | None = None,
    ) -> CreateCustomerWebhookResponse:
        payload: dict[str, Any] = {"url": url}
        if description is not None:
            payload["description"] = description
        if events is not None:
            payload["events"] = events
        return self._client._request_json("POST", "/v1/webhooks", json_body=payload)

    def delete(self, webhook_id: str) -> None:
        self._client._request_json("DELETE", f"/v1/webhooks/{parse.quote(webhook_id, safe='')}")


class WebhookDeliveriesAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(
        self,
        *,
        webhook_id: str | None = None,
        status: str | None = None,
        limit: int | None = None,
    ) -> list[CustomerWebhookDeliveryInfo]:
        query: dict[str, Any] = {}
        if webhook_id is not None:
            query["webhook_id"] = webhook_id
        if status is not None:
            query["status"] = status
        if limit is not None:
            query["limit"] = limit
        return self._client._request_json("GET", "/v1/webhooks/deliveries", query=query)[
            "deliveries"
        ]

    def get(self, delivery_id: str) -> CustomerWebhookDeliveryInfo:
        return self._client._request_json(
            "GET", f"/v1/webhooks/deliveries/{parse.quote(delivery_id, safe='')}"
        )["delivery"]

    def replay(self, delivery_id: str) -> CustomerWebhookDeliveryInfo:
        return self._client._request_json(
            "POST", f"/v1/webhooks/deliveries/{parse.quote(delivery_id, safe='')}/replay"
        )["delivery"]

    def discard(self, delivery_id: str) -> CustomerWebhookDeliveryInfo:
        return self._client._request_json(
            "POST", f"/v1/webhooks/deliveries/{parse.quote(delivery_id, safe='')}/discard"
        )["delivery"]


class AuditAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list(
        self,
        *,
        action: str | None = None,
        resource_type: str | None = None,
        resource_id: str | None = None,
        status: str | None = None,
        before: str | None = None,
        limit: int | None = None,
    ) -> list[AuditEventInfo]:
        query: dict[str, Any] = {}
        if action is not None:
            query["action"] = action
        if resource_type is not None:
            query["resource_type"] = resource_type
        if resource_id is not None:
            query["resource_id"] = resource_id
        if status is not None:
            query["status"] = status
        if before is not None:
            query["before"] = before
        if limit is not None:
            query["limit"] = limit
        return self._client._request_json("GET", "/v1/audit/events", query=query)["events"]


class JobsAPI:
    def __init__(self, client: ChaserClient) -> None:
        self._client = client

    def list_kinds(self) -> JobKindsInfo:
        return self._client._request_json("GET", "/v1/jobs/kinds")

    def create(self, *, kind: str, input: Any, run_at: str | None = None, idempotency_key: str | None = None) -> JobInfo:
        payload: dict[str, Any] = {"kind": kind, "input": input}
        if run_at is not None:
            payload["run_at"] = run_at
        if idempotency_key is not None:
            payload["idempotency_key"] = idempotency_key
        return self._client._request_json("POST", "/v1/jobs", json_body=payload)

    def list(self, *, status: str | None = None, kind: str | None = None, limit: int | None = None, offset: int | None = None) -> ListJobsPage:
        query: dict[str, Any] = {}
        if status is not None:
            query["status"] = status
        if kind is not None:
            query["kind"] = kind
        if limit is not None:
            query["limit"] = limit
        if offset is not None:
            query["offset"] = offset
        return self._client._request_json("GET", "/v1/jobs", query=query)

    def get(self, job_id: str) -> JobInfo:
        return self._client._request_json("GET", f"/v1/jobs/{parse.quote(job_id, safe='')}")

    def cancel(self, job_id: str) -> JobInfo:
        return self._client._request_json("POST", f"/v1/jobs/{parse.quote(job_id, safe='')}/cancel")

    def retry(self, job_id: str) -> JobInfo:
        return self._client._request_json("POST", f"/v1/jobs/{parse.quote(job_id, safe='')}/retry")

    def wait_for_completion(
        self,
        job_id: str,
        *,
        timeout_secs: float = DEFAULT_WAIT_TIMEOUT_SECS,
        interval_secs: float = DEFAULT_WAIT_INTERVAL_SECS,
        predicate: Callable[[JobInfo], bool] | None = None,
    ) -> JobInfo:
        deadline = time.monotonic() + timeout_secs
        while True:
            job = self.get(job_id)
            check = predicate or (
                lambda candidate: candidate.get("status", "").lower() in TERMINAL_JOB_STATUSES
            )
            if check(job):
                return job
            if time.monotonic() >= deadline:
                raise ChaserTimeoutError(f"Timed out waiting for job {job_id} to finish")
            time.sleep(interval_secs)

    def browser_action(
        self,
        payload: BrowserActionJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="browser.action",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def scrape(
        self,
        payload: BrowserScrapeJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="browser.scrape",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def exec(
        self,
        payload: Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="sandbox.exec",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def terminate_session(
        self,
        payload: SessionTerminateJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="session.terminate",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def bulk_terminate_sessions(
        self,
        payload: SessionBulkTerminateJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="session.bulk_terminate",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def delete_workspace(
        self,
        payload: WorkspaceDeleteJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="workspace.delete",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def bulk_delete_workspaces(
        self,
        payload: WorkspaceBulkDeleteJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="workspace.bulk_delete",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def pipeline(
        self,
        payload: PipelineRunJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="pipeline.run",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )

    def github_runner(
        self,
        payload: GithubRunnerJobInput | Mapping[str, Any],
        *,
        run_at: str | None = None,
        idempotency_key: str | None = None,
    ) -> JobInfo:
        return self.create(
            kind="github.runner",
            input=dict(payload),
            run_at=run_at,
            idempotency_key=idempotency_key,
        )


class ChaserClient:
    def __init__(
        self,
        *,
        base_url: str = DEFAULT_BASE_URL,
        api_key: str | None = None,
        token: str | None = None,
        account: str | None = None,
        transport: Transport | None = None,
        timeout: float | None = None,
        default_headers: Mapping[str, str] | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.token = token or api_key
        self.account = account.strip() if account else None
        self.transport = transport or UrllibTransport()
        self.timeout = timeout
        self.default_headers = dict(default_headers or {})

        self.accounts = AccountsAPI(self)
        self.keys = KeysAPI(self)
        self.ssh_keys = SshKeysAPI(self)
        self.sessions = SessionsAPI(self)
        self.exec = ExecAPI(self)
        self.commands = CommandsAPI(self)
        self.files = FilesAPI(self)
        self.workspaces = WorkspacesAPI(self)
        self.github = GithubAPI(self)
        self.browser = BrowserAPI(self)
        self.billing = BillingAPI(self)
        self.webhooks = WebhooksAPI(self)
        self.audit = AuditAPI(self)
        self.jobs = JobsAPI(self)

    def with_account(self, account: str | None) -> ChaserClient:
        return ChaserClient(
            base_url=self.base_url,
            token=self.token,
            account=account,
            transport=self.transport,
            timeout=self.timeout,
            default_headers=self.default_headers,
        )

    def with_token(self, token: str | None) -> ChaserClient:
        return ChaserClient(
            base_url=self.base_url,
            token=token,
            account=self.account,
            transport=self.transport,
            timeout=self.timeout,
            default_headers=self.default_headers,
        )

    def _build_url(self, path: str, query: Mapping[str, Any] | None = None) -> str:
        if not query:
            return f"{self.base_url}{path}"
        filtered: list[tuple[str, str]] = []
        for key, raw_value in query.items():
            if raw_value is None or raw_value == "":
                continue
            if isinstance(raw_value, (list, tuple)):
                for item in raw_value:
                    if item is None or item == "":
                        continue
                    filtered.append((key, str(item)))
            else:
                filtered.append((key, str(raw_value)))
        suffix = parse.urlencode(filtered)
        return f"{self.base_url}{path}{'?' + suffix if suffix else ''}"

    def _build_headers(self, headers: Mapping[str, str] | None = None) -> dict[str, str]:
        merged = {"Accept": "application/json", **self.default_headers}
        if headers:
            merged.update(headers)
        if self.token:
            merged["Authorization"] = f"Bearer {self.token}"
        if self.account:
            merged["X-Chaser-Account"] = self.account
        return merged

    def _request_json(
        self,
        method: str,
        path: str,
        *,
        query: Mapping[str, Any] | None = None,
        json_body: Mapping[str, Any] | None = None,
        body: bytes | None = None,
        headers: Mapping[str, str] | None = None,
    ) -> Any:
        merged_headers = self._build_headers(headers)
        raw_body = body
        if json_body is not None:
            merged_headers["Content-Type"] = "application/json"
            raw_body = json.dumps(json_body).encode("utf-8")
        response = self.transport.request(
            method,
            self._build_url(path, query),
            merged_headers,
            raw_body,
            self.timeout,
        )
        if response.status >= 400:
            raise api_error_from_response(response.status, response.headers, response.body)
        if response.status == 204 or not response.body:
            return {}
        return json.loads(response.body.decode("utf-8"))

    def _request_bytes(
        self,
        method: str,
        path: str,
        *,
        query: Mapping[str, Any] | None = None,
        headers: Mapping[str, str] | None = None,
    ) -> bytes:
        response = self.transport.request(
            method,
            self._build_url(path, query),
            self._build_headers(headers),
            None,
            self.timeout,
        )
        if response.status >= 400:
            raise api_error_from_response(response.status, response.headers, response.body)
        return response.body


def _coerce_bytes(data: bytes | bytearray | memoryview | str) -> bytes:
    if isinstance(data, str):
        return data.encode("utf-8")
    if isinstance(data, memoryview):
        return data.tobytes()
    return bytes(data)


def _encode_multipart(
    *,
    fields: Mapping[str, str],
    files: Mapping[str, tuple[str, bytes, str]],
) -> tuple[bytes, str]:
    boundary = f"chaser-sdk-{uuid.uuid4().hex}"
    chunks: list[bytes] = []
    for name, value in fields.items():
        chunks.extend(
            [
                f"--{boundary}\r\n".encode(),
                f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
                value.encode(),
                b"\r\n",
            ]
        )
    for name, (filename, content, content_type) in files.items():
        chunks.extend(
            [
                f"--{boundary}\r\n".encode(),
                (
                    f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
                ).encode(),
                f"Content-Type: {content_type}\r\n\r\n".encode(),
                content,
                b"\r\n",
            ]
        )
    chunks.append(f"--{boundary}--\r\n".encode())
    return b"".join(chunks), f"multipart/form-data; boundary={boundary}"


def _normalize_cdp_version(payload: Mapping[str, Any]) -> BrowserCdpVersionInfo:
    normalized: dict[str, Any] = dict(payload)
    remap = {
        "Protocol-Version": "Protocol_Version",
        "User-Agent": "User_Agent",
        "V8-Version": "V8_Version",
        "WebKit-Version": "WebKit_Version",
    }
    for source, target in remap.items():
        if source in normalized and target not in normalized:
            normalized[target] = normalized[source]
    return normalized  # type: ignore[return-value]
