"""Dependency-free CleanedWeb reference client.

Keep CLEANEDWEB_API_KEY in a trusted process. This example intentionally stops on
unknown or unsafe responses instead of guessing.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen


API_BASE = "https://app.cleanedweb.com"


class CleanedWebError(RuntimeError):
    """A transport or stable API-contract failure."""

    def __init__(self, message: str, *, status: int | None = None, code: str | None = None):
        super().__init__(message)
        self.status = status
        self.code = code


@dataclass(frozen=True)
class AcceptedSearch:
    response: dict[str, Any]
    request_id: str | None
    units: str | None
    balance: str | None


class CleanedWebClient:
    def __init__(self, api_key: str, *, timeout_seconds: float = 30.0) -> None:
        if not api_key.startswith("cw_"):
            raise ValueError("A CleanedWeb workspace API key is required")
        self._api_key = api_key
        self._timeout = timeout_seconds

    @classmethod
    def from_environment(cls) -> "CleanedWebClient":
        return cls(os.environ["CLEANEDWEB_API_KEY"])

    def capabilities(self) -> dict[str, Any]:
        payload, _ = self._request("GET", "/v1/capabilities", authenticated=False)
        return payload

    def markets(self) -> dict[str, Any]:
        payload, _ = self._request("GET", "/v1/markets")
        return payload

    def locations(self, market: str, query: str, *, limit: int = 8) -> dict[str, Any]:
        params = urlencode({"market": market, "q": query, "limit": limit})
        payload, _ = self._request("GET", f"/v1/properties/locations?{params}")
        return payload

    def get_property(self, property_entity_id: str) -> dict[str, Any]:
        identity = quote(property_entity_id, safe="")
        payload, _ = self._request("GET", f"/v1/properties/{identity}")
        return payload

    def property_changes(
        self,
        property_entity_id: str,
        *,
        limit: int = 50,
    ) -> dict[str, Any]:
        if limit < 1 or limit > 200:
            raise ValueError("limit must be between 1 and 200")
        identity = quote(property_entity_id, safe="")
        payload, _ = self._request(
            "GET",
            f"/v1/properties/{identity}/changes?{urlencode({'limit': limit})}",
        )
        return payload

    def search(
        self,
        query: dict[str, Any],
        *,
        idempotency_key: str,
        require_complete: bool = True,
    ) -> AcceptedSearch:
        capabilities = self.capabilities()
        if capabilities.get("contract_version") != "v1":
            raise CleanedWebError("Unsupported capabilities contract")
        if capabilities.get("machine_access", {}).get("enabled") is not True:
            raise CleanedWebError("Machine access is not enabled by runtime capabilities")
        if not capabilities.get("unit_costs"):
            raise CleanedWebError("Runtime Unit costs are not published")
        if not isinstance(query.get("limit"), int) or query["limit"] < 1:
            raise ValueError("query.limit must be a positive integer")
        if not isinstance(query.get("max_units"), (int, float)) or query["max_units"] < 0:
            raise ValueError("query.max_units must be a non-negative number")
        if not idempotency_key.strip():
            raise ValueError("idempotency_key is required")
        if query.get("response_schema") != "property-profile-v2":
            raise ValueError("query.response_schema must be property-profile-v2")

        payload, headers = self._request(
            "POST",
            "/v1/properties/search",
            body=query,
            headers={"Idempotency-Key": idempotency_key},
        )
        self._accept_search(payload, require_complete=require_complete)
        return AcceptedSearch(
            response=payload,
            request_id=headers.get("x-cleanedweb-request-id"),
            units=headers.get("x-cleanedweb-units"),
            balance=headers.get("x-cleanedweb-unit-balance"),
        )

    @staticmethod
    def _accept_search(payload: dict[str, Any], *, require_complete: bool) -> None:
        results = payload.get("results")
        if not isinstance(results, list) or payload.get("returned") != len(results):
            raise CleanedWebError("Search response count mismatch")
        identities: set[str] = set()
        for record in results:
            if record.get("schema_version") != "property-profile-v2":
                raise CleanedWebError("Unsupported property schema")
            identity = record.get("property_entity_id")
            if not isinstance(identity, str) or not identity or identity in identities:
                raise CleanedWebError("Missing or duplicate canonical property identity")
            identities.add(identity)
        if require_complete and payload.get("truncated") is not False:
            raise CleanedWebError("Complete search required but response is truncated")
        if not isinstance(payload.get("snapshot", {}).get("dataUpdatedAt"), str):
            raise CleanedWebError("Search snapshot timestamp is unavailable")

    def _request(
        self,
        method: str,
        path: str,
        *,
        authenticated: bool = True,
        body: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
    ) -> tuple[dict[str, Any], dict[str, str]]:
        request_headers = {"Accept": "application/json", **(headers or {})}
        if authenticated:
            request_headers["Authorization"] = f"Bearer {self._api_key}"
        data = None
        if body is not None:
            request_headers["Content-Type"] = "application/json"
            data = json.dumps(body, separators=(",", ":")).encode("utf-8")
        request = Request(API_BASE + path, data=data, headers=request_headers, method=method)
        try:
            with urlopen(request, timeout=self._timeout) as response:
                payload = json.load(response)
                response_headers = {key.casefold(): value for key, value in response.headers.items()}
                return payload, response_headers
        except HTTPError as error:
            try:
                payload = json.load(error)
            except (json.JSONDecodeError, UnicodeDecodeError):
                payload = {}
            contract_error = payload.get("error", {}) if isinstance(payload, dict) else {}
            raise CleanedWebError(
                contract_error.get("message") or f"CleanedWeb returned HTTP {error.code}",
                status=error.code,
                code=contract_error.get("code"),
            ) from error
        except URLError as error:
            raise CleanedWebError(f"CleanedWeb request failed: {error.reason}") from error
