"""Private activity receipts. Python 3.9+, standard library only; explicitly opt in.

Only a local evidence hash and fields explicitly passed to record are uploaded.
Keep evidence bytes, private openings and exports securely. Receipts establish
recorded claims, not independently that an action occurred or was authorized.
"""
from __future__ import annotations

import asyncio
import functools
import hashlib
import http.client
import inspect
import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable, Optional


# Server error text is untrusted, including identifier-shaped strings. Only
# stable protocol codes can become a loggable client error.
_HTTP_CODES = frozenset(("invalid_api_key", "malformed_api_key", "api_key_required", "invalid_json", "invalid_activity_receipt", "invalid_idempotency_key", "activity_receipt_not_found", "invalid_statement_request", "not_found", "account_closed", "account_frozen", "sandbox_account_expired", "rate_limited", "too_many_requests", "internal_error", "unavailable", "service_unavailable"))


class ActivityError(Exception):
    def __init__(self, code: str, status: Optional[int] = None):
        self.code, self.status = code, status
        super().__init__(f"Rightshold activity request failed ({code}" + (f", HTTP {status}" if status is not None else "") + ").")


def encode_evidence(value: Any) -> bytes:
    """Return exact local bytes. Preserve strings/bytes; objects use sorted-key JSON.
    Retain these bytes; no cross-language numeric canonicalization is implied."""
    if isinstance(value, (bytes, bytearray)):
        return bytes(value)
    if isinstance(value, str):
        return value.encode("utf-8")
    return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")


def hash_evidence(value: Any) -> str:
    return hashlib.sha256(encode_evidence(value)).hexdigest()


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


class RightsholdActivity:
    def __init__(self, api_key: Optional[str] = None, base_url: str = "https://www.rightshold.com",
                 enabled: bool = False, fail_open: bool = True, timeout_seconds: float = 3,
                 max_attempts: int = 2):
        self.enabled, self.fail_open = enabled, fail_open
        self.api_key = os.environ.get("RIGHTSHOLD_API_KEY", "") if api_key is None else api_key
        self.last_error: Optional[ActivityError] = None
        try:
            parsed = urllib.parse.urlsplit(base_url)
            if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/") or not parsed.hostname:
                raise ValueError()
            if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")):
                raise ValueError()
            parsed.port  # validate malformed ports without printing their value
        except (ValueError, TypeError):
            raise ActivityError("invalid_base_url") from None
        if isinstance(max_attempts, bool) or not isinstance(max_attempts, int) or not 1 <= max_attempts <= 3 or not isinstance(timeout_seconds, (float, int)) or not 0 < timeout_seconds <= 30:
            raise ActivityError("invalid_client_options")
        self.base_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
        self.timeout_seconds, self.max_attempts = timeout_seconds, max_attempts
        self._opener = urllib.request.build_opener(_NoRedirect())

    def _guard(self, action: Callable):
        if not self.enabled:
            return None
        self.last_error = None
        try:
            if not self.api_key:
                raise ActivityError("api_key_required")
            return action()
        except Exception as error:
            safe = error if isinstance(error, ActivityError) else ActivityError("request_failed")
            self.last_error = safe
            if not self.fail_open:
                raise safe from None
            return None

    def _request(self, path: str, body: Optional[dict] = None, key: Optional[str] = None, authenticated: bool = True) -> dict:
        data = None if body is None else json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
        headers = {"accept": "application/json"}
        if authenticated:
            headers["authorization"] = "Bearer " + self.api_key
        if data is not None:
            headers["content-type"] = "application/json"
        if key:
            headers["idempotency-key"] = key
        for attempt in range(self.max_attempts):
            request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method="GET" if body is None else "POST")
            try:
                with self._opener.open(request, timeout=self.timeout_seconds) as response:
                    try:
                        result = json.load(response)
                    except (ValueError, UnicodeError):
                        raise ActivityError("invalid_response") from None
                    if not isinstance(result, dict):
                        raise ActivityError("invalid_response")
                    return result
            except urllib.error.HTTPError as error:
                try:
                    parsed = json.loads(error.read())
                    code = parsed.get("error") if isinstance(parsed, dict) else None
                except (ValueError, UnicodeError, OSError, http.client.HTTPException):
                    code = None
                finally:
                    error.close()
                safe = ActivityError(code if isinstance(code, str) and code in _HTTP_CODES and not (self.api_key and self.api_key in code) else "http_error", error.code)
                if (error.code != 429 and error.code < 500) or attempt + 1 == self.max_attempts:
                    raise safe from None
            except ActivityError:
                raise
            except (OSError, urllib.error.URLError, http.client.HTTPException):
                if attempt + 1 == self.max_attempts:
                    raise ActivityError("network_error") from None
            time.sleep(0.1 * (attempt + 1))
        raise ActivityError("request_failed")

    @staticmethod
    def _id(receipt_id: str) -> str:
        if not isinstance(receipt_id, str) or not re.fullmatch(r"act_[a-f0-9]{20}", receipt_id):
            raise ActivityError("invalid_receipt_id")
        return receipt_id

    @staticmethod
    def _envelope(value: dict, expected_id: Optional[str] = None) -> dict:
        receipt = value.get("receipt")
        if not isinstance(receipt, dict) or not isinstance(receipt.get("id"), str) or not re.fullmatch(r"act_[a-f0-9]{20}", receipt["id"]) or (expected_id is not None and receipt["id"] != expected_id):
            raise ActivityError("invalid_response")
        seq, commitment = receipt.get("ledger_seq"), receipt.get("commitment")
        if "ledger_seq" not in receipt or "commitment" not in receipt or (seq is not None and (isinstance(seq, bool) or not isinstance(seq, int) or seq < 1)) or (commitment is not None and (not isinstance(commitment, str) or not re.fullmatch(r"[a-f0-9]{64}", commitment))):
            raise ActivityError("invalid_response")
        return value

    def record(self, event_id: str, action_type: str, summary: Optional[str] = None,
               evidence: Any = None, action_sha256: Optional[str] = None,
               agent_id: Optional[str] = None, counterparty_ref: Optional[str] = None,
               amount: Optional[float] = None, currency: Optional[str] = None):
        """Record AFTER a real action completes. Reuse its event id only for retries within 24h."""
        def perform():
            if not isinstance(event_id, str) or not event_id.strip() or len(event_id) > 1024:
                raise ActivityError("invalid_event_id")
            if evidence is not None and action_sha256 is not None:
                raise ActivityError("ambiguous_evidence")
            body = {"action_type": action_type, "summary": action_type if summary is None else summary}
            optional = {"action_sha256": hash_evidence(evidence) if evidence is not None else action_sha256,
                        "agent_id": agent_id, "counterparty_ref": counterparty_ref, "amount": amount, "currency": currency}
            body.update({k: v for k, v in optional.items() if v is not None})
            result = self._request("/v1/me/activity-receipts", body, "activity:" + hashlib.sha256(event_id.encode("utf-8")).hexdigest())
            return self._envelope(result)
        return self._guard(perform)

    def get(self, receipt_id: str):
        return self._guard(lambda: self._envelope(self._request("/v1/me/activity-receipts/" + self._id(receipt_id)), receipt_id))

    def proof(self, receipt_id: str):
        def perform():
            owner = self._envelope(self._request("/v1/me/activity-receipts/" + self._id(receipt_id)), receipt_id)
            receipt = owner.get("receipt")
            seq = receipt.get("ledger_seq") if isinstance(receipt, dict) else None
            if seq is None:
                raise ActivityError("receipt_not_on_ledger")
            return {"receipt": receipt, "ledger": self._request("/v1/ledger/entries/" + str(seq), authenticated=False)}
        return self._guard(perform)

    def export(self, receipt_id: str):
        return self._guard(lambda: self._request("/v1/me/activity-receipts/" + self._id(receipt_id) + "/export"))

    def statement(self, days: int = 30):
        def perform():
            if isinstance(days, bool) or not isinstance(days, int) or not 1 <= days <= 366:
                raise ActivityError("invalid_statement_days")
            return self._request("/v1/me/statement?days=" + str(days))
        return self._guard(perform)

    def wrap(self, fn: Callable, action_type: str, *, event_id: Callable,
             on_receipt: Optional[Callable] = None) -> Callable:
        """Receipt successful invocations, with a caller-supplied stable invocation id.
        Original tool errors propagate untouched. Strict recording errors follow tool success."""
        def record_result(args, kwargs, result):
            def perform():
                return self.record(event_id=event_id(*args, **kwargs), action_type=action_type,
                                   evidence={"args": list(args), "kwargs": kwargs, "result": result})
            envelope = self._guard(perform)
            if on_receipt:
                try:
                    on_receipt(envelope)
                except Exception:
                    pass
        if inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(getattr(fn, "__call__", None)):
            @functools.wraps(fn)
            async def wrapped_async(*args, **kwargs):
                result = await fn(*args, **kwargs)
                if self.enabled:
                    try:
                        await asyncio.to_thread(record_result, args, kwargs, result)
                    except ActivityError:
                        raise
                    except Exception:
                        self.last_error = ActivityError("executor_error")
                        if not self.fail_open:
                            raise self.last_error from None
                return result
            return wrapped_async
        @functools.wraps(fn)
        def wrapped(*args, **kwargs):
            result = fn(*args, **kwargs)
            if self.enabled:
                record_result(args, kwargs, result)
            return result
        return wrapped
