from __future__ import annotations

import hashlib
import json
import os
import re
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping, Optional
from urllib.parse import urlsplit

import httpx
import jwt

from .. import settings

HUBSPOT_WRITE_GATE_ISSUER = "foundryops.hubspot.write-control"
HUBSPOT_WRITE_GATE_AUDIENCE = "hubspot-write-control"
HUBSPOT_PROVIDER = "hubspot"

SAFE_PROVIDER_HEADERS = (
    "retry-after",
    "x-hubspot-ratelimit-secondly-remaining",
    "x-hubspot-ratelimit-secondly",
    "x-hubspot-ratelimit-daily-remaining",
    "x-hubspot-ratelimit-daily",
)

NEXT_STEP_BY_ERROR: dict[str, str] = {
    "UPGRADE_REQUIRED": "UPGRADE",
    "PROVIDER_NOT_CONNECTED": "REAUTH",
    "WRITE_APP_NOT_CONNECTED": "REAUTH",
    "GATE_REPLAYED": "CONTACT_SUPPORT",
    "GATE_EXPIRED": "RETRY_LATER",
    "ALLOWLIST_DENIED": "CONTACT_SUPPORT",
    "PAYLOAD_HASH_MISMATCH": "CONTACT_SUPPORT",
    "INTENT_FINGERPRINT_MISMATCH": "CONTACT_SUPPORT",
    "IDEMPOTENCY_CONFLICT": "CONTACT_SUPPORT",
    "TENANT_MISMATCH": "CONTACT_SUPPORT",
    "TENANT_RATE_LIMITED": "RETRY_LATER",
}

# Phase A action registry: command-level monetized effects + Phase B JSON actions.
ACTION_REGISTRY: dict[str, str] = {
    "hubspot.pack.apply": "schema_write",
    "hubspot.props.apply": "schema_write",
    "hubspot.objects.apply": "schema_write",
    "hubspot.lists.apply": "schema_write",
    "hubspot.pipelines.apply": "schema_write",
    "hubspot.validations.apply": "schema_write",
    "hubspot.workflows.apply": "schema_write",
    "hubspot.imports.start": "import_write",
    "hubspot.imports.cancel": "import_write",
    "hubspot.merge.apply_plan": "merge_write",
    "hubspot.crm.objects.batch_upsert": "object_batch_write",
    "hubspot.crm.lists.membership_mutation": "object_batch_write",
}


@dataclass(frozen=True)
class AllowlistRule:
    methods: tuple[str, ...]
    path_pattern: re.Pattern[str]
    allowed_query_keys: frozenset[str]
    max_payload_bytes: int
    max_batch_size: int | None = None


# Phase B allowlist (deny-by-default for all other JSON writes).
ALLOWLIST_RULES: dict[str, tuple[AllowlistRule, ...]] = {
    "hubspot.crm.objects.batch_upsert": (
        AllowlistRule(
            methods=("POST",),
            path_pattern=re.compile(
                r"^/crm/v3/objects/(contacts|companies|deals|tickets|leads)/batch/(create|update|upsert)$"
            ),
            allowed_query_keys=frozenset(),
            max_payload_bytes=1_500_000,
            max_batch_size=200,
        ),
    ),
    "hubspot.crm.lists.membership_mutation": (
        AllowlistRule(
            methods=("POST",),
            path_pattern=re.compile(
                r"^/crm/v3/lists/[0-9]+/memberships/(add|remove)$"
            ),
            allowed_query_keys=frozenset(),
            max_payload_bytes=1_000_000,
            max_batch_size=500,
        ),
    ),
}


@dataclass(frozen=True)
class UpgradeContract:
    requires_gate_version: str | None
    upgrade_required: bool
    upgrade_message: str | None
    upgrade_url: str | None

    def as_dict(self) -> dict[str, Any]:
        return {
            "requires_gate_version": self.requires_gate_version,
            "upgrade_required": self.upgrade_required,
            "upgrade_message": self.upgrade_message,
            "upgrade_url": self.upgrade_url,
        }


@dataclass(frozen=True)
class ValidationResult:
    operation_class: str
    payload_sha256: str
    intent_fingerprint: str
    normalized_path: str


@dataclass(frozen=True)
class SigningKey:
    kid: str
    secret: str


class WriteControlDenied(Exception):
    def __init__(
        self,
        *,
        error: str,
        message: str,
        status_code: int,
        next_step: str | None = None,
        retry_after_ms: int | None = None,
        provider_headers: Mapping[str, str] | None = None,
        upgrade_url: str | None = None,
        support_bundle_hint: str | None = None,
    ) -> None:
        self.error = error
        self.message = message
        self.status_code = status_code
        self.next_step = next_step or NEXT_STEP_BY_ERROR.get(error, "CONTACT_SUPPORT")
        self.retry_after_ms = retry_after_ms
        self.provider_headers = dict(provider_headers or {})
        self.upgrade_url = upgrade_url
        self.support_bundle_hint = support_bundle_hint
        super().__init__(f"{error}: {message}")

    def to_detail(self) -> dict[str, Any]:
        detail: dict[str, Any] = {
            "error": self.error,
            "message": self.message,
            "next_step": self.next_step,
        }
        if self.upgrade_url:
            detail["upgrade_url"] = self.upgrade_url
        if self.support_bundle_hint:
            detail["support_bundle_hint"] = self.support_bundle_hint
        if self.retry_after_ms is not None:
            detail["retry_after_ms"] = int(self.retry_after_ms)
        if self.provider_headers:
            detail["provider_headers"] = dict(self.provider_headers)
        return {"detail": detail}


def action_operation_class(action_id: str) -> str | None:
    return ACTION_REGISTRY.get(str(action_id or "").strip())


def effective_mode() -> str:
    if bool(getattr(settings, "HUBSPOT_WRITE_CONTROL_KILL_SWITCH", False)):
        return "shadow"
    mode = str(getattr(settings, "HUBSPOT_WRITE_CONTROL_MODE", "shadow") or "shadow").strip().lower()
    if mode not in {"shadow", "enforce"}:
        return "shadow"
    return mode


def _gate_signing_secret() -> str:
    secret = getattr(settings, "ENCRYPTION_KEY", "") or getattr(settings, "JWT_SECRET_KEY", "")
    if secret:
        return str(secret)
    env = str(getattr(settings, "ENV", "") or "").strip().lower()
    if env in {"dev", "development", "test", "local"} or os.getenv("FM_TEST_MODE") == "1":
        return "dev-secret"
    raise RuntimeError("HubSpot write gate signing secret is not configured.")


def _allowed_signing_keys() -> list[SigningKey]:
    current_secret = str(getattr(settings, "HUBSPOT_WRITE_GATE_SIGNING_KEY_CURRENT", "") or "").strip()
    current_kid = str(getattr(settings, "HUBSPOT_WRITE_GATE_SIGNING_KEY_CURRENT_KID", "") or "").strip() or "current"
    previous_secret = str(getattr(settings, "HUBSPOT_WRITE_GATE_SIGNING_KEY_PREVIOUS", "") or "").strip()
    previous_kid = str(getattr(settings, "HUBSPOT_WRITE_GATE_SIGNING_KEY_PREVIOUS_KID", "") or "").strip() or "previous"

    if current_secret:
        keys = [SigningKey(kid=current_kid, secret=current_secret)]
        if previous_secret and previous_secret != current_secret:
            keys.append(SigningKey(kid=previous_kid, secret=previous_secret))
        return keys

    # Legacy fallback for existing deployments.
    return [SigningKey(kid="legacy-hs256", secret=_gate_signing_secret())]


def _current_signing_key() -> SigningKey:
    return _allowed_signing_keys()[0]


def parse_cli_version(
    *,
    cli_version_header: str | None,
    user_agent: str | None,
) -> str | None:
    header_version = str(cli_version_header or "").strip()
    if header_version:
        return header_version
    ua = str(user_agent or "")
    match = re.search(r"g-gremlin/([0-9A-Za-z._+-]+)", ua)
    if match:
        return match.group(1)
    return None


def _version_tuple(version: str | None) -> tuple[int, ...]:
    if not version:
        return tuple()
    chunks = [int(part) for part in re.findall(r"\d+", version)]
    return tuple(chunks)


def _is_version_lt(current: str | None, required: str) -> bool:
    cur = _version_tuple(current)
    req = _version_tuple(required)
    if not req:
        return False
    size = max(len(cur), len(req))
    cur = cur + (0,) * (size - len(cur))
    req = req + (0,) * (size - len(req))
    return cur < req


def build_upgrade_contract(cli_version: str | None) -> UpgradeContract:
    required = str(getattr(settings, "HUBSPOT_WRITE_GATE_REQUIRED_VERSION", "") or "").strip() or None
    upgrade_url = (
        str(getattr(settings, "HUBSPOT_WRITE_GATE_UPGRADE_URL", "") or "").strip()
        or "https://foundryops.io/pricing"
    )
    if not required:
        return UpgradeContract(
            requires_gate_version=None,
            upgrade_required=False,
            upgrade_message=None,
            upgrade_url=upgrade_url,
        )
    upgrade_required = _is_version_lt(cli_version, required)
    upgrade_message = None
    if upgrade_required:
        upgrade_message = (
            f"Upgrade g-gremlin to >= {required} to use HubSpot monetized write operations."
        )
    return UpgradeContract(
        requires_gate_version=required,
        upgrade_required=upgrade_required,
        upgrade_message=upgrade_message,
        upgrade_url=upgrade_url,
    )


def is_426_cutoff_enabled() -> bool:
    return bool(getattr(settings, "HUBSPOT_WRITE_GATE_ENABLE_426", False))


def _normalize_path(path: str) -> str:
    raw = str(path or "").strip()
    if not raw:
        return "/"
    parts = urlsplit(raw)
    normalized = parts.path or "/"
    if not normalized.startswith("/"):
        normalized = f"/{normalized}"
    return normalized


def _canonical_query(query: Mapping[str, Any] | None) -> dict[str, str]:
    if not query:
        return {}
    normalized: dict[str, str] = {}
    for key, value in sorted(query.items(), key=lambda item: str(item[0])):
        normalized[str(key)] = "" if value is None else str(value)
    return normalized


def payload_bytes(body: Any) -> bytes:
    return json.dumps(body, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8")


def payload_sha256(body: Any) -> str:
    return hashlib.sha256(payload_bytes(body)).hexdigest()


def intent_fingerprint(
    *,
    action_id: str,
    method: str,
    path: str,
    query: Mapping[str, Any] | None,
    payload_hash: str,
) -> str:
    canonical = {
        "action_id": action_id,
        "method": str(method or "").upper(),
        "path": _normalize_path(path),
        "query": _canonical_query(query),
        "payload_sha256": payload_hash,
    }
    raw = json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


async def _redis_get(redis: Any, key: str) -> str | None:
    raw = await redis.get(key)
    if raw is None:
        return None
    if isinstance(raw, (bytes, bytearray)):
        return raw.decode("utf-8", errors="ignore")
    return str(raw)


async def _redis_setex(redis: Any, key: str, ttl_seconds: int, value: str) -> None:
    await redis.setex(key, max(int(ttl_seconds), 1), value)


async def _redis_expire(redis: Any, key: str, ttl_seconds: int) -> None:
    expire = getattr(redis, "expire", None)
    if callable(expire):
        await expire(key, max(int(ttl_seconds), 1))
        return
    existing = await _redis_get(redis, key)
    if existing is not None:
        await _redis_setex(redis, key, ttl_seconds, existing)


async def _redis_getset(redis: Any, key: str, value: str) -> str | None:
    getter_setter = getattr(redis, "getset", None)
    if callable(getter_setter):
        raw = await getter_setter(key, value)
    else:
        execute_command = getattr(redis, "execute_command", None)
        if callable(execute_command):
            raw = await execute_command("GETSET", key, value)
        else:
            raise RuntimeError("Redis client does not support atomic GETSET for replay protection.")
    if raw is None:
        return None
    if isinstance(raw, (bytes, bytearray)):
        return raw.decode("utf-8", errors="ignore")
    return str(raw)


async def _redis_incr(redis: Any, key: str, ttl_seconds: int = 60) -> int:
    incr = getattr(redis, "incr", None)
    if callable(incr):
        value = await incr(key)
        return int(value)
    raw = await _redis_get(redis, key)
    current = int(raw or "0")
    current += 1
    await _redis_setex(redis, key, max(int(ttl_seconds), 1), str(current))
    return current


def _gate_jti_key(jti: str) -> str:
    return f"hubspot:write-gate:jti:{jti}"


def _gate_issue_rate_key(workspace_id: str) -> str:
    return f"hubspot:write-gate:issue-rate:{workspace_id}"


def _idempotency_key(
    *,
    workspace_id: str,
    provider_tenant_id: str,
    action_id: str,
    idempotency_key: str,
) -> str:
    return (
        "hubspot:write-proxy:idem:"
        f"{workspace_id}:{HUBSPOT_PROVIDER}:{provider_tenant_id}:{action_id}:{idempotency_key}"
    )


async def enforce_gate_issue_rate_limit(
    *,
    redis: Any,
    workspace_id: str,
) -> None:
    max_requests = int(getattr(settings, "HUBSPOT_WRITE_GATE_ISSUE_RATE_LIMIT_MAX", 0) or 0)
    if max_requests <= 0:
        return

    window_seconds = int(getattr(settings, "HUBSPOT_WRITE_GATE_ISSUE_RATE_LIMIT_WINDOW_SECONDS", 60) or 60)
    window_seconds = max(window_seconds, 1)
    key = _gate_issue_rate_key(str(workspace_id or "unknown"))
    count = await _redis_incr(redis, key, window_seconds)
    if count == 1:
        await _redis_expire(redis, key, window_seconds)
    if count > max_requests:
        raise WriteControlDenied(
            status_code=429,
            error="TENANT_RATE_LIMITED",
            message="Write gate issuance is rate limited. Retry shortly.",
            next_step="RETRY_LATER",
            retry_after_ms=window_seconds * 1000,
        )


async def issue_gate(
    *,
    redis: Any,
    workspace_id: str,
    principal_id: str,
    provider_tenant_id: str,
    action_id: str,
    command_id: str | None,
    method: str,
    path: str,
    query: Mapping[str, Any] | None,
    body: Any,
    explicit_intent_fingerprint: str | None = None,
) -> dict[str, Any]:
    operation_class = action_operation_class(action_id)
    if not operation_class:
        raise WriteControlDenied(
            status_code=403,
            error="ALLOWLIST_DENIED",
            message="Action is not registered for HubSpot write control.",
        )

    method_normalized = str(method or "POST").upper()
    path_normalized = _normalize_path(path or "/")
    body_hash = payload_sha256(body)
    intent_hash = explicit_intent_fingerprint or intent_fingerprint(
        action_id=action_id,
        method=method_normalized,
        path=path_normalized,
        query=query,
        payload_hash=body_hash,
    )
    ttl_seconds = int(getattr(settings, "HUBSPOT_WRITE_GATE_TTL_SECONDS", 120) or 120)
    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(seconds=max(ttl_seconds, 1))
    jti = uuid.uuid4().hex

    claims = {
        "iss": HUBSPOT_WRITE_GATE_ISSUER,
        "aud": HUBSPOT_WRITE_GATE_AUDIENCE,
        "iat": int(now.timestamp()),
        "nbf": int(now.timestamp()),
        "exp": int(expires_at.timestamp()),
        "jti": jti,
        "sub": principal_id,
        "workspace_id": workspace_id,
        "provider": HUBSPOT_PROVIDER,
        "provider_tenant_id": provider_tenant_id,
        "action_id": action_id,
        "command_id": command_id,
        "capability": "hubspot_admin_write",
        "operation_class": operation_class,
        "method": method_normalized,
        "path": path_normalized,
        "query_keys": sorted(_canonical_query(query).keys()),
        "payload_sha256": body_hash,
        "intent_fingerprint": intent_hash,
    }
    signing_key = _current_signing_key()
    claims["kid"] = signing_key.kid
    token = jwt.encode(
        claims,
        signing_key.secret,
        algorithm="HS256",
        headers={"kid": signing_key.kid},
    )

    replay_ttl = int(getattr(settings, "HUBSPOT_WRITE_GATE_REPLAY_TTL_SECONDS", ttl_seconds * 2) or ttl_seconds * 2)
    await _redis_setex(redis, _gate_jti_key(jti), replay_ttl, "issued")

    return {
        "gate_token": token,
        "jti": jti,
        "action_id": action_id,
        "operation_class": operation_class,
        "expires_at": expires_at.isoformat(),
        "payload_sha256": body_hash,
        "intent_fingerprint": intent_hash,
    }


def decode_gate_token(token: str) -> dict[str, Any]:
    try:
        unverified_header = jwt.get_unverified_header(token)
    except jwt.InvalidTokenError as exc:
        raise WriteControlDenied(
            status_code=401,
            error="GATE_EXPIRED",
            message="Write gate is invalid.",
        ) from exc
    header_kid = str(unverified_header.get("kid") or "")
    keys = _allowed_signing_keys()
    if header_kid:
        matching = [item for item in keys if item.kid == header_kid]
        if not matching:
            raise WriteControlDenied(
                status_code=401,
                error="GATE_EXPIRED",
                message="Write gate is invalid.",
            )
        keys = matching

    expired_error: Exception | None = None
    try:
        for key in keys:
            try:
                decoded = jwt.decode(
                    token,
                    key.secret,
                    algorithms=["HS256"],
                    audience=HUBSPOT_WRITE_GATE_AUDIENCE,
                    issuer=HUBSPOT_WRITE_GATE_ISSUER,
                )
            except jwt.ExpiredSignatureError as exc:
                expired_error = exc
                continue
            except jwt.InvalidTokenError:
                continue
            if isinstance(decoded, dict):
                claim_kid = str(decoded.get("kid") or "")
                if claim_kid and claim_kid != key.kid:
                    continue
                return decoded
    except Exception as exc:
        raise WriteControlDenied(
            status_code=401,
            error="GATE_EXPIRED",
            message="Write gate is invalid.",
        ) from exc

    if expired_error is not None:
        raise WriteControlDenied(
            status_code=401,
            error="GATE_EXPIRED",
            message="Write gate has expired.",
        ) from expired_error

    raise WriteControlDenied(
        status_code=401,
        error="GATE_EXPIRED",
        message="Write gate is invalid.",
    )


def _allowlist_check(
    *,
    action_id: str,
    method: str,
    path: str,
    query: Mapping[str, Any] | None,
    payload: Any,
) -> None:
    rules = ALLOWLIST_RULES.get(action_id, ())
    if not rules:
        raise WriteControlDenied(
            status_code=403,
            error="ALLOWLIST_DENIED",
            message="JSON write action is not allowlisted.",
        )

    method_token = str(method or "").upper()
    path_token = _normalize_path(path)
    query_keys = set(_canonical_query(query).keys())
    payload_size = len(payload_bytes(payload))

    for rule in rules:
        if method_token not in rule.methods:
            continue
        if not rule.path_pattern.match(path_token):
            continue
        if not query_keys.issubset(rule.allowed_query_keys):
            continue
        if payload_size > rule.max_payload_bytes:
            continue
        if rule.max_batch_size is not None and isinstance(payload, dict):
            inputs = payload.get("inputs")
            if isinstance(inputs, list) and len(inputs) > rule.max_batch_size:
                continue
        return

    raise WriteControlDenied(
        status_code=403,
        error="ALLOWLIST_DENIED",
        message="Write operation does not satisfy allowlist constraints.",
    )


def validate_bound_request(
    *,
    gate_claims: Mapping[str, Any],
    workspace_id: str,
    provider_tenant_id: str,
    action_id: str,
    method: str,
    path: str,
    query: Mapping[str, Any] | None,
    body: Any,
) -> ValidationResult:
    claim_workspace = str(gate_claims.get("workspace_id") or "")
    if claim_workspace != workspace_id:
        raise WriteControlDenied(
            status_code=403,
            error="TENANT_MISMATCH",
            message="Workspace context does not match gate token.",
        )
    claim_provider = str(gate_claims.get("provider") or "")
    if claim_provider and claim_provider != HUBSPOT_PROVIDER:
        raise WriteControlDenied(
            status_code=403,
            error="TENANT_MISMATCH",
            message="Provider context does not match gate token.",
        )
    claim_tenant = str(gate_claims.get("provider_tenant_id") or "")
    current_tenant = str(provider_tenant_id or "")
    if claim_tenant != current_tenant:
        raise WriteControlDenied(
            status_code=403,
            error="TENANT_MISMATCH",
            message="Provider tenant does not match gate token.",
        )

    claim_action = str(gate_claims.get("action_id") or "")
    if claim_action != action_id:
        raise WriteControlDenied(
            status_code=403,
            error="ALLOWLIST_DENIED",
            message="Action id does not match gate token.",
        )

    operation_class = action_operation_class(action_id)
    if not operation_class:
        raise WriteControlDenied(
            status_code=403,
            error="ALLOWLIST_DENIED",
            message="Action is not registered for HubSpot write control.",
        )
    claim_operation_class = str(gate_claims.get("operation_class") or "")
    if claim_operation_class and claim_operation_class != operation_class:
        raise WriteControlDenied(
            status_code=403,
            error="ALLOWLIST_DENIED",
            message="Operation class does not match gate token.",
        )

    normalized_path = _normalize_path(path)
    current_hash = payload_sha256(body)
    claim_hash = str(gate_claims.get("payload_sha256") or "")
    if claim_hash and claim_hash != current_hash:
        raise WriteControlDenied(
            status_code=400,
            error="PAYLOAD_HASH_MISMATCH",
            message="Payload hash does not match write gate token.",
        )

    claim_fingerprint = str(gate_claims.get("intent_fingerprint") or "")
    current_fingerprint = intent_fingerprint(
        action_id=action_id,
        method=method,
        path=normalized_path,
        query=query,
        payload_hash=current_hash,
    )
    if claim_fingerprint and claim_fingerprint != current_fingerprint:
        raise WriteControlDenied(
            status_code=400,
            error="INTENT_FINGERPRINT_MISMATCH",
            message="Request fingerprint does not match write gate token.",
        )

    _allowlist_check(
        action_id=action_id,
        method=method,
        path=normalized_path,
        query=query,
        payload=body,
    )

    return ValidationResult(
        operation_class=operation_class,
        payload_sha256=current_hash,
        intent_fingerprint=current_fingerprint,
        normalized_path=normalized_path,
    )


async def consume_gate_jti(
    *,
    redis: Any,
    jti: str,
) -> None:
    key = _gate_jti_key(str(jti or ""))
    try:
        previous = await _redis_getset(redis, key, "consumed")
    except RuntimeError as exc:
        raise WriteControlDenied(
            status_code=503,
            error="TENANT_RATE_LIMITED",
            message=str(exc),
            next_step="RETRY_LATER",
        ) from exc
    if previous != "issued":
        raise WriteControlDenied(
            status_code=409,
            error="GATE_REPLAYED",
            message="Write gate has already been used.",
        )
    replay_ttl = int(getattr(settings, "HUBSPOT_WRITE_GATE_REPLAY_TTL_SECONDS", 300) or 300)
    await _redis_expire(redis, key, replay_ttl)


async def lookup_idempotency(
    *,
    redis: Any,
    workspace_id: str,
    provider_tenant_id: str,
    action_id: str,
    idempotency_key: str,
    request_sha256: str,
) -> dict[str, Any] | None:
    cache_key = _idempotency_key(
        workspace_id=workspace_id,
        provider_tenant_id=provider_tenant_id,
        action_id=action_id,
        idempotency_key=idempotency_key,
    )
    raw = await _redis_get(redis, cache_key)
    if not raw:
        return None
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return None
    if not isinstance(payload, dict):
        return None
    cached_hash = str(payload.get("request_sha256") or "")
    if cached_hash and cached_hash != request_sha256:
        raise WriteControlDenied(
            status_code=409,
            error="IDEMPOTENCY_CONFLICT",
            message="Idempotency key was already used with a different request payload.",
        )
    response_payload = payload.get("response")
    if isinstance(response_payload, dict):
        return response_payload
    return None


async def store_idempotency(
    *,
    redis: Any,
    workspace_id: str,
    provider_tenant_id: str,
    action_id: str,
    idempotency_key: str,
    request_sha256: str,
    response_payload: Mapping[str, Any],
) -> None:
    cache_key = _idempotency_key(
        workspace_id=workspace_id,
        provider_tenant_id=provider_tenant_id,
        action_id=action_id,
        idempotency_key=idempotency_key,
    )
    ttl = int(getattr(settings, "HUBSPOT_WRITE_PROXY_IDEMPOTENCY_TTL_SECONDS", 900) or 900)
    payload = json.dumps(
        {"request_sha256": request_sha256, "response": dict(response_payload)},
        separators=(",", ":"),
        sort_keys=True,
        ensure_ascii=False,
    )
    await _redis_setex(redis, cache_key, ttl, payload)


def provider_request_id(headers: Mapping[str, str]) -> str | None:
    for key in (
        "x-hubspot-request-id",
        "x-hubspot-correlation-id",
        "x-request-id",
    ):
        value = headers.get(key) if isinstance(headers, Mapping) else None
        if value:
            return str(value)
    return None


def safe_retry_headers(headers: Mapping[str, str]) -> dict[str, str]:
    output: dict[str, str] = {}
    for key in SAFE_PROVIDER_HEADERS:
        value = headers.get(key)
        if value is not None:
            output[key] = str(value)
    return output


def retry_after_ms(headers: Mapping[str, str]) -> int | None:
    value = headers.get("retry-after")
    if value is None:
        return None
    try:
        return max(int(float(str(value)) * 1000), 0)
    except (TypeError, ValueError):
        return None


def parse_provider_body(response: httpx.Response) -> Any:
    try:
        return response.json()
    except Exception:
        text = response.text
        return {"raw": text[:5000]}


async def execute_hubspot_json(
    *,
    access_token: str,
    method: str,
    path: str,
    query: Mapping[str, Any] | None,
    body: Any,
) -> dict[str, Any]:
    if not access_token:
        raise WriteControlDenied(
            status_code=412,
            error="WRITE_APP_NOT_CONNECTED",
            message="HubSpot write app is not connected.",
        )

    timeout_seconds = float(getattr(settings, "HUBSPOT_WRITE_PROXY_TIMEOUT_SECONDS", 30) or 30)
    normalized_path = _normalize_path(path)
    url = f"https://api.hubapi.com{normalized_path}"
    method_token = str(method or "POST").upper()
    request_headers = {
        "Authorization": f"Bearer {access_token}",
        "Accept": "application/json",
        "Content-Type": "application/json",
    }
    started = time.perf_counter()
    async with httpx.AsyncClient(timeout=timeout_seconds) as client:
        response = await client.request(
            method_token,
            url,
            params=_canonical_query(query),
            json=body,
            headers=request_headers,
        )
    latency_ms = int((time.perf_counter() - started) * 1000)
    response_headers = {str(k).lower(): str(v) for k, v in response.headers.items()}
    return {
        "status_code": int(response.status_code),
        "provider_request_id": provider_request_id(response_headers),
        "provider_headers": safe_retry_headers(response_headers),
        "retry_after_ms": retry_after_ms(response_headers),
        "body": parse_provider_body(response),
        "proxy_latency_ms": latency_ms,
    }
