"""Synchronous HTTP client for the MCIAS API."""
from __future__ import annotations

import ssl
from types import TracebackType
from typing import Any

import httpx

from ._errors import raise_for_status
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims


class Client:
    """Synchronous MCIAS API client backed by httpx."""
    def __init__(
        self,
        server_url: str,
        *,
        ca_cert_path: str | None = None,
        token: str | None = None,
        timeout: float = 30.0,
        service_name: str | None = None,
        tags: list[str] | None = None,
    ) -> None:
        self._base_url = server_url.rstrip("/")
        self.token = token
        self._service_name = service_name
        self._tags = tags or []
        ssl_context: ssl.SSLContext | bool
        if ca_cert_path is not None:
            ssl_context = ssl.create_default_context(cafile=ca_cert_path)
        else:
            ssl_context = True  # use default SSL verification
        self._http = httpx.Client(
            verify=ssl_context,
            timeout=timeout,
        )
    def __enter__(self) -> Client:
        return self
    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        self.close()
    def close(self) -> None:
        """Close the underlying HTTP client."""
        self._http.close()
    def _request(
        self,
        method: str,
        path: str,
        *,
        json: dict[str, Any] | None = None,
        expected_status: int | None = None,
    ) -> dict[str, Any] | None:
        """Send an HTTP request and return the parsed JSON body.
        Returns None for 204 No Content responses.
        Raises the appropriate MciasError subclass on 4xx/5xx.
        """
        url = f"{self._base_url}{path}"
        headers: dict[str, str] = {}
        if self.token is not None:
            headers["Authorization"] = f"Bearer {self.token}"
        response = self._http.request(method, url, json=json, headers=headers)
        status = response.status_code
        if expected_status is not None:
            success_codes = {expected_status}
        else:
            success_codes = {200, 201, 204}
        if status not in success_codes and status >= 400:
            try:
                body = response.json()
                message = str(body.get("error", response.text))
            except Exception:
                message = response.text
            raise_for_status(status, message)
        if status == 204 or not response.content:
            return None
        return response.json()  # type: ignore[no-any-return]
    def _request_list(
        self,
        method: str,
        path: str,
        *,
        json: dict[str, Any] | None = None,
    ) -> list[dict[str, Any]]:
        """Send a request that returns a JSON array at the top level."""
        url = f"{self._base_url}{path}"
        headers: dict[str, str] = {}
        if self.token is not None:
            headers["Authorization"] = f"Bearer {self.token}"
        response = self._http.request(method, url, json=json, headers=headers)
        status = response.status_code
        if status >= 400:
            try:
                body = response.json()
                message = str(body.get("error", response.text))
            except Exception:
                message = response.text
            raise_for_status(status, message)
        return response.json()  # type: ignore[no-any-return]
    # ── Public ────────────────────────────────────────────────────────────────
    def health(self) -> None:
        """GET /v1/health — liveness check."""
        self._request("GET", "/v1/health")
    def get_public_key(self) -> PublicKey:
        """GET /v1/keys/public — retrieve the server's Ed25519 public key."""
        data = self._request("GET", "/v1/keys/public")
        assert data is not None
        return PublicKey.from_dict(data)
    def login(
        self,
        username: str,
        password: str,
        totp_code: str | None = None,
    ) -> tuple[str, str]:
        """POST /v1/auth/login — authenticate and obtain a JWT.
        Returns (token, expires_at). Stores the token on self.token.

        The client's service_name and tags are included so MCIAS can evaluate
        service-context policy rules (e.g. deny guests from restricted services).
        """
        payload: dict[str, Any] = {
            "username": username,
            "password": password,
        }
        if totp_code is not None:
            payload["totp_code"] = totp_code
        if self._service_name is not None:
            payload["service_name"] = self._service_name
        if self._tags:
            payload["tags"] = self._tags
        data = self._request("POST", "/v1/auth/login", json=payload)
        assert data is not None
        token = str(data["token"])
        expires_at = str(data["expires_at"])
        self.token = token
        return token, expires_at
    def validate_token(self, token: str) -> TokenClaims:
        """POST /v1/token/validate — check whether a token is valid."""
        data = self._request("POST", "/v1/token/validate", json={"token": token})
        assert data is not None
        return TokenClaims.from_dict(data)
    # ── Authenticated ──────────────────────────────────────────────────────────
    def logout(self) -> None:
        """POST /v1/auth/logout — invalidate the current token."""
        self._request("POST", "/v1/auth/logout")
        self.token = None
    def renew_token(self) -> tuple[str, str]:
        """POST /v1/auth/renew — exchange current token for a fresh one.
        Returns (token, expires_at). Updates self.token.
        """
        data = self._request("POST", "/v1/auth/renew")
        assert data is not None
        token = str(data["token"])
        expires_at = str(data["expires_at"])
        self.token = token
        return token, expires_at
    def enroll_totp(self, password: str) -> tuple[str, str]:
        """POST /v1/auth/totp/enroll — begin TOTP enrollment.

        Security (SEC-01): current password is required to prevent session-theft
        escalation to persistent account takeover.

        Returns (secret, otpauth_uri). The secret is shown only once.
        """
        data = self._request("POST", "/v1/auth/totp/enroll", json={"password": password})
        assert data is not None
        return str(data["secret"]), str(data["otpauth_uri"])
    def confirm_totp(self, code: str) -> None:
        """POST /v1/auth/totp/confirm — confirm TOTP enrollment with a code."""
        self._request("POST", "/v1/auth/totp/confirm", json={"code": code})
    def change_password(self, current_password: str, new_password: str) -> None:
        """PUT /v1/auth/password — change own password (self-service)."""
        self._request(
            "PUT",
            "/v1/auth/password",
            json={"current_password": current_password, "new_password": new_password},
        )
    # ── Admin — Auth ──────────────────────────────────────────────────────────
    def remove_totp(self, account_id: str) -> None:
        """DELETE /v1/auth/totp — remove TOTP from an account (admin)."""
        self._request("DELETE", "/v1/auth/totp", json={"account_id": account_id})
    # ── Admin — Tokens ────────────────────────────────────────────────────────
    def issue_service_token(self, account_id: str) -> tuple[str, str]:
        """POST /v1/token/issue — issue a long-lived service token (admin).
        Returns (token, expires_at).
        """
        data = self._request("POST", "/v1/token/issue", json={"account_id": account_id})
        assert data is not None
        return str(data["token"]), str(data["expires_at"])
    def revoke_token(self, jti: str) -> None:
        """DELETE /v1/token/{jti} — revoke a token by JTI (admin)."""
        self._request("DELETE", f"/v1/token/{jti}")
    # ── Admin — Accounts ──────────────────────────────────────────────────────
    def list_accounts(self) -> list[Account]:
        """GET /v1/accounts — list all accounts (admin).
        The API returns a JSON array directly (no wrapper object).
        """
        items = self._request_list("GET", "/v1/accounts")
        return [Account.from_dict(a) for a in items]
    def create_account(
        self,
        username: str,
        account_type: str,
        *,
        password: str | None = None,
    ) -> Account:
        """POST /v1/accounts — create a new account (admin)."""
        payload: dict[str, Any] = {
            "username": username,
            "account_type": account_type,
        }
        if password is not None:
            payload["password"] = password
        data = self._request("POST", "/v1/accounts", json=payload)
        assert data is not None
        return Account.from_dict(data)
    def get_account(self, account_id: str) -> Account:
        """GET /v1/accounts/{id} — retrieve a single account (admin)."""
        data = self._request("GET", f"/v1/accounts/{account_id}")
        assert data is not None
        return Account.from_dict(data)
    def update_account(
        self,
        account_id: str,
        *,
        status: str | None = None,
    ) -> None:
        """PATCH /v1/accounts/{id} — update account fields (admin).
        Currently only `status` is patchable. Returns None (204 No Content).
        """
        payload: dict[str, Any] = {}
        if status is not None:
            payload["status"] = status
        self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
    def delete_account(self, account_id: str) -> None:
        """DELETE /v1/accounts/{id} — soft-delete an account (admin)."""
        self._request("DELETE", f"/v1/accounts/{account_id}")
    def get_roles(self, account_id: str) -> list[str]:
        """GET /v1/accounts/{id}/roles — list roles for an account (admin)."""
        data = self._request("GET", f"/v1/accounts/{account_id}/roles")
        assert data is not None
        roles_raw = data.get("roles") or []
        return [str(r) for r in roles_raw]
    def set_roles(self, account_id: str, roles: list[str]) -> None:
        """PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
        self._request(
            "PUT",
            f"/v1/accounts/{account_id}/roles",
            json={"roles": roles},
        )
    def admin_set_password(self, account_id: str, new_password: str) -> None:
        """PUT /v1/accounts/{id}/password — reset a password without the old one (admin)."""
        self._request(
            "PUT",
            f"/v1/accounts/{account_id}/password",
            json={"new_password": new_password},
        )
    # ── Admin — Credentials ───────────────────────────────────────────────────
    def get_pg_creds(self, account_id: str) -> PGCreds:
        """GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials (admin)."""
        data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
        assert data is not None
        return PGCreds.from_dict(data)
    def set_pg_creds(
        self,
        account_id: str,
        host: str,
        port: int,
        database: str,
        username: str,
        password: str,
    ) -> None:
        """PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
        payload: dict[str, Any] = {
            "host": host,
            "port": port,
            "database": database,
            "username": username,
            "password": password,
        }
        self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
    # ── Admin — Policy ────────────────────────────────────────────────────────
    def get_account_tags(self, account_id: str) -> list[str]:
        """GET /v1/accounts/{id}/tags — get account tags (admin)."""
        data = self._request("GET", f"/v1/accounts/{account_id}/tags")
        assert data is not None
        return [str(t) for t in (data.get("tags") or [])]
    def set_account_tags(self, account_id: str, tags: list[str]) -> list[str]:
        """PUT /v1/accounts/{id}/tags — replace the full tag set (admin).
        Returns the updated tag list.
        """
        data = self._request(
            "PUT",
            f"/v1/accounts/{account_id}/tags",
            json={"tags": tags},
        )
        assert data is not None
        return [str(t) for t in (data.get("tags") or [])]
    def list_policy_rules(self) -> list[PolicyRule]:
        """GET /v1/policy/rules — list all operator policy rules (admin)."""
        items = self._request_list("GET", "/v1/policy/rules")
        return [PolicyRule.from_dict(r) for r in items]
    def create_policy_rule(
        self,
        description: str,
        rule: RuleBody,
        *,
        priority: int | None = None,
        not_before: str | None = None,
        expires_at: str | None = None,
    ) -> PolicyRule:
        """POST /v1/policy/rules — create a policy rule (admin)."""
        payload: dict[str, Any] = {
            "description": description,
            "rule": rule.to_dict(),
        }
        if priority is not None:
            payload["priority"] = priority
        if not_before is not None:
            payload["not_before"] = not_before
        if expires_at is not None:
            payload["expires_at"] = expires_at
        data = self._request("POST", "/v1/policy/rules", json=payload)
        assert data is not None
        return PolicyRule.from_dict(data)
    def get_policy_rule(self, rule_id: int) -> PolicyRule:
        """GET /v1/policy/rules/{id} — get a policy rule (admin)."""
        data = self._request("GET", f"/v1/policy/rules/{rule_id}")
        assert data is not None
        return PolicyRule.from_dict(data)
    def update_policy_rule(
        self,
        rule_id: int,
        *,
        description: str | None = None,
        priority: int | None = None,
        enabled: bool | None = None,
        rule: RuleBody | None = None,
        not_before: str | None = None,
        expires_at: str | None = None,
        clear_not_before: bool | None = None,
        clear_expires_at: bool | None = None,
    ) -> PolicyRule:
        """PATCH /v1/policy/rules/{id} — update a policy rule (admin)."""
        payload: dict[str, Any] = {}
        if description is not None:
            payload["description"] = description
        if priority is not None:
            payload["priority"] = priority
        if enabled is not None:
            payload["enabled"] = enabled
        if rule is not None:
            payload["rule"] = rule.to_dict()
        if not_before is not None:
            payload["not_before"] = not_before
        if expires_at is not None:
            payload["expires_at"] = expires_at
        if clear_not_before is not None:
            payload["clear_not_before"] = clear_not_before
        if clear_expires_at is not None:
            payload["clear_expires_at"] = clear_expires_at
        data = self._request("PATCH", f"/v1/policy/rules/{rule_id}", json=payload)
        assert data is not None
        return PolicyRule.from_dict(data)
    def delete_policy_rule(self, rule_id: int) -> None:
        """DELETE /v1/policy/rules/{id} — delete a policy rule (admin)."""
        self._request("DELETE", f"/v1/policy/rules/{rule_id}")
