"""
BookAfterwork API — official Python client (v1.2.0)
Requires: Python 3.9+ and `requests` (pip install requests).

SECURITY: partner keys are secrets — use them server-side only.

    from bookafterwork import BookAfterwork, verify_webhook
    with BookAfterwork(api_key=os.environ["BAK_API_KEY"]) as bak:
        req = bak.create_request(message="Afterwork 35 personnes à Lyon le 12 mars")
        reco = bak.recommend(request_id=req["request_id"], limit=3)

Concurrency: one client per thread (requests.Session is not guaranteed thread-safe).

Docs: https://bookafterwork.com/developpeurs/documentation
Spec: https://bookafterwork.com/openapi.json
"""
from __future__ import annotations

import email.utils
import hashlib
import hmac
import json
import random
import re
import time
import uuid
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from urllib.parse import quote, urlencode, urlparse

import requests

__version__ = "1.2.0"
__all__ = ["BookAfterwork", "BookAfterworkError", "verify_webhook", "parse_retry_after", "__version__"]

DEFAULT_BASE = "https://bookafterwork.com/api/public/v1"
ALLOWED_HOSTS = {"bookafterwork.com", "www.bookafterwork.com", "localhost", "127.0.0.1"}
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
HHMM = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
JsonDict = Dict[str, Any]


class BookAfterworkError(Exception):
    """API or transport error.

    status: HTTP status, or 0 for network/timeout errors (outcome unknown on writes:
    retry with the SAME idempotency_key). code: business code (e.g. INSUFFICIENT_SCOPE)
    or SDK code (NETWORK_ERROR, TIMEOUT, INVALID_RESPONSE, HTTP_ERROR).
    """

    def __init__(self, status: int, code: str, message: str, retry_after: Optional[int] = None, required_scope: Optional[str] = None):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.message = message
        self.retry_after = retry_after
        self.required_scope = required_scope

    @property
    def uncertain(self) -> bool:
        return self.status == 0


def _key() -> str:
    return str(uuid.uuid4())


def _seg(value: str, name: str = "id") -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{name} must be a non-empty string")
    return quote(value, safe="")


def _limit(limit: Optional[int]) -> None:
    if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 25):
        raise ValueError("limit must be an integer between 1 and 25")


def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[int]:
    """Parses Retry-After as delta-seconds or HTTP-date. Returns seconds or None."""
    if not value:
        return None
    v = value.strip()
    if v.isdigit():
        return int(v)
    try:
        dt = email.utils.parsedate_to_datetime(v)
    except (TypeError, ValueError):
        return None
    if dt is None:
        return None
    return max(0, int(dt.timestamp() - (now if now is not None else time.time()) + 0.999))


class BookAfterwork:
    """BookAfterwork API client.

    Retries 429/5xx/network errors only for GET/DELETE or writes carrying an
    Idempotency-Key (every SDK write sends one), reusing the same key and body.
    """

    def __init__(
        self,
        api_key: str,
        base_url: str = DEFAULT_BASE,
        max_retries: int = 2,
        timeout: Union[float, Tuple[float, float]] = (5.0, 30.0),
        max_delay: float = 30.0,
        session: Optional[requests.Session] = None,
    ):
        if not api_key or not re.match(r"^bak_(live|test)_", api_key):
            raise ValueError("api_key must start with bak_live_ or bak_test_")
        url = urlparse(base_url)
        local = url.hostname in ("localhost", "127.0.0.1")
        if url.scheme != "https" and not local:
            raise ValueError("base_url must use https")
        if url.hostname not in ALLOWED_HOSTS:
            raise ValueError(f"base_url host {url.hostname} is not an approved BookAfterwork host")
        self._api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.max_retries = min(max(max_retries, 0), 5)
        self.timeout = timeout
        self.max_delay = max_delay
        self._own_session = session is None
        self.session = session or requests.Session()

    def __repr__(self) -> str:
        return f"BookAfterwork(base_url={self.base_url!r}, api_key='{self._api_key[:9]}…')"

    def close(self) -> None:
        if self._own_session:
            self.session.close()

    def __enter__(self) -> "BookAfterwork":
        return self

    def __exit__(self, *exc: Any) -> None:
        self.close()

    def _delay(self, attempt: int, retry_after: Optional[int]) -> float:
        if retry_after is not None:
            return min(float(retry_after), self.max_delay)
        base = min(0.5 * 2 ** attempt, self.max_delay)
        return base / 2 + random.random() * base / 2

    def request(self, method: str, path: str, body: Optional[Mapping[str, Any]] = None, idempotency_key: Optional[str] = None) -> JsonDict:
        headers = {
            "Authorization": f"Bearer {self._api_key}",
            "Accept": "application/json",
            "User-Agent": f"bookafterwork-python/{__version__}",
        }
        data: Optional[bytes] = None
        if body is not None:
            headers["Content-Type"] = "application/json"
            data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")  # same bytes each attempt
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key
        retry_safe = method in ("GET", "DELETE") or bool(idempotency_key)

        attempt = 0
        while True:
            try:
                res = self.session.request(method, self.base_url + path, data=data, headers=headers, timeout=self.timeout, allow_redirects=False)
            except requests.Timeout as exc:
                if retry_safe and attempt < self.max_retries:
                    time.sleep(self._delay(attempt, None)); attempt += 1; continue
                raise BookAfterworkError(0, "TIMEOUT", "No response before timeout") from exc
            except requests.RequestException as exc:
                if retry_safe and attempt < self.max_retries:
                    time.sleep(self._delay(attempt, None)); attempt += 1; continue
                raise BookAfterworkError(0, "NETWORK_ERROR", type(exc).__name__) from exc

            if 300 <= res.status_code < 400:
                raise BookAfterworkError(res.status_code, "HTTP_ERROR", "Unexpected redirect refused (credentials are never forwarded)")

            payload: Any = None
            if res.content:
                try:
                    payload = res.json()
                except ValueError as exc:
                    if res.ok:
                        raise BookAfterworkError(res.status_code, "INVALID_RESPONSE", "Response is not valid JSON") from exc
            if res.ok:
                if res.status_code != 204 and not isinstance(payload, dict):
                    raise BookAfterworkError(res.status_code, "INVALID_RESPONSE", "Expected a JSON object")
                return payload or {}

            retry_after = parse_retry_after(res.headers.get("Retry-After"))
            if (res.status_code == 429 or res.status_code >= 500) and retry_safe and attempt < self.max_retries:
                time.sleep(self._delay(attempt, retry_after)); attempt += 1; continue
            err = payload.get("error", {}) if isinstance(payload, dict) else {}
            err = err if isinstance(err, dict) else {}
            raise BookAfterworkError(res.status_code, str(err.get("code", "HTTP_ERROR")), str(err.get("message", res.reason or f"HTTP {res.status_code}")), retry_after, err.get("required_scope"))

    # ── AI ──
    def parse_request(self, message: str, locale: str = "fr") -> JsonDict:
        """Free text → ParseResponse {brief, confidence_score, missing_fields, ambiguous_fields, extraction_engine}."""
        if not message or len(message.strip()) < 8:
            raise ValueError("message must be at least 8 characters")
        return self.request("POST", "/ai/parse-request", {"message": message, "locale": locale})

    def recommend(self, request_id: Optional[str] = None, brief: Optional[JsonDict] = None, limit: int = 6) -> JsonDict:
        """Ranked venues (RecommendResponse). Provide request_id or brief."""
        _limit(limit)
        if not request_id and not brief:
            raise ValueError("request_id or brief is required")
        return self.request("POST", "/ai/recommend", {k: v for k, v in {"request_id": request_id, "brief": brief, "limit": limit}.items() if v is not None})

    def recommend_providers(self, request_id: Optional[str] = None, brief: Optional[JsonDict] = None, limit: int = 6) -> JsonDict:
        """Ranked providers (RecommendProvidersResponse)."""
        _limit(limit)
        if not request_id and not brief:
            raise ValueError("request_id or brief is required")
        return self.request("POST", "/ai/recommend-providers", {k: v for k, v in {"request_id": request_id, "brief": brief, "limit": limit}.items() if v is not None})

    def compare_quotes(self, request_id: str) -> JsonDict:
        _seg(request_id, "request_id")
        return self.request("POST", "/ai/compare-quotes", {"request_id": request_id})

    # ── Venues ──
    def search_venues(self, **criteria: Any) -> JsonDict:
        """SearchVenuesResponse {results, candidate_count}."""
        _limit(criteria.get("limit"))
        g = criteria.get("guests")
        if g is not None and (not isinstance(g, int) or g < 1):
            raise ValueError("guests must be a positive integer")
        if criteria.get("date") is not None and not ISO_DATE.match(str(criteria["date"])):
            raise ValueError("date must be YYYY-MM-DD")
        return self.request("POST", "/venues/search", criteria)

    def get_availability(self, venue_id: str, date_from: Optional[str] = None, date_to: Optional[str] = None) -> JsonDict:
        for d in (date_from, date_to):
            if d is not None and not ISO_DATE.match(d):
                raise ValueError("date_from/date_to must be YYYY-MM-DD")
        q = urlencode({k: v for k, v in {"from": date_from, "to": date_to}.items() if v})
        return self.request("GET", f"/venues/{_seg(venue_id, 'venue_id')}/availability" + (f"?{q}" if q else ""))

    def publish_availability(self, venue_id: str, days: Optional[List[JsonDict]] = None, slots: Optional[List[JsonDict]] = None, idempotency_key: Optional[str] = None) -> JsonDict:
        return self.request("POST", f"/venues/{_seg(venue_id, 'venue_id')}/availability", {"days": days or [], "slots": slots or []}, idempotency_key or _key())

    # ── Requests ──
    def create_request(self, idempotency_key: Optional[str] = None, **body: Any) -> JsonDict:
        if not body.get("message") and not body.get("brief"):
            raise ValueError("message or brief is required")
        return self.request("POST", "/requests", body, idempotency_key or _key())

    def get_request(self, request_id: str) -> JsonDict:
        return self.request("GET", f"/requests/{_seg(request_id, 'request_id')}")

    def dispatch(self, request_id: str, venue_ids: List[str], idempotency_key: Optional[str] = None) -> JsonDict:
        if not isinstance(venue_ids, list) or not 1 <= len(venue_ids) <= 10:
            raise ValueError("venue_ids must contain 1 to 10 ids")
        return self.request("POST", f"/requests/{_seg(request_id, 'request_id')}/dispatch", {"venue_ids": venue_ids}, idempotency_key or _key())

    def dispatch_providers(self, request_id: str, idempotency_key: Optional[str] = None, **body: Any) -> JsonDict:
        return self.request("POST", f"/requests/{_seg(request_id, 'request_id')}/dispatch-providers", body, idempotency_key or _key())

    def list_quotes(self, request_id: str) -> JsonDict:
        return self.request("GET", f"/requests/{_seg(request_id, 'request_id')}/quotes")

    # ── Bookings ──
    def create_booking(self, quote_id: str, idempotency_key: Optional[str] = None) -> JsonDict:
        _seg(quote_id, "quote_id")
        return self.request("POST", "/bookings", {"quote_id": quote_id}, idempotency_key or _key())

    def get_booking(self, booking_id: str) -> JsonDict:
        return self.request("GET", f"/bookings/{_seg(booking_id, 'booking_id')}")

    def update_booking(self, booking_id: str, guests: Optional[int] = None, event_time: Optional[str] = None, notes: Optional[str] = None, idempotency_key: Optional[str] = None) -> JsonDict:
        if event_time is not None and not HHMM.match(event_time):
            raise ValueError("event_time must be HH:MM")
        if guests is not None and (not isinstance(guests, int) or guests < 1):
            raise ValueError("guests must be a positive integer")
        body = {k: v for k, v in {"guests": guests, "event_time": event_time, "notes": notes}.items() if v is not None}
        return self.request("PATCH", f"/bookings/{_seg(booking_id, 'booking_id')}", body, idempotency_key or _key())

    def cancel_booking(self, booking_id: str, reason: str, idempotency_key: Optional[str] = None) -> JsonDict:
        if not reason or len(reason.strip()) < 3:
            raise ValueError("reason must be at least 3 characters")
        return self.request("POST", f"/bookings/{_seg(booking_id, 'booking_id')}/cancel", {"reason": reason}, idempotency_key or _key())

    def create_payment(self, booking_id: str, idempotency_key: Optional[str] = None) -> JsonDict:
        """Returns a hosted checkout URL. NOT a payment: wait for the `payment.succeeded` webhook.
        Reuse the same idempotency_key to avoid creating duplicate payment links."""
        return self.request("POST", f"/bookings/{_seg(booking_id, 'booking_id')}/payment", {}, idempotency_key or _key())

    # ── Webhooks ──
    def list_webhooks(self) -> JsonDict:
        return self.request("GET", "/webhooks")

    def create_webhook(self, url: str, events: List[str], idempotency_key: Optional[str] = None) -> JsonDict:
        if not url.startswith("https://"):
            raise ValueError("url must be https")
        return self.request("POST", "/webhooks", {"url": url, "events": events}, idempotency_key or _key())

    def delete_webhook(self, webhook_id: str) -> JsonDict:
        return self.request("DELETE", f"/webhooks/{_seg(webhook_id, 'webhook_id')}")


def verify_webhook(raw_body: bytes, signature_header: Optional[str], secret: str, tolerance_sec: int = 300, now: Optional[float] = None) -> bool:
    """Verifies X-BookAfterwork-Signature: t=<unix>,v1=<hex>[,v1=<hex>].

    HMAC-SHA256 over b"<t>." + exact raw body bytes. Rejects malformed headers and
    timestamps outside ±tolerance_sec. Deduplicate on event["id"] in your storage to block replays.
    """
    if not signature_header or not secret or not isinstance(raw_body, (bytes, bytearray)):
        return False
    t: Optional[int] = None
    sigs: List[str] = []
    for part in signature_header.split(","):
        k, sep, v = part.partition("=")
        k, v = k.strip(), v.strip()
        if not sep or not k:
            return False
        if k == "t":
            if t is not None or not re.fullmatch(r"\d{1,12}", v):
                return False
            t = int(v)
        elif k == "v1":
            if not re.fullmatch(r"[0-9a-f]{64}", v):
                return False
            sigs.append(v)
    current = time.time() if now is None else now
    if t is None or not sigs or abs(current - t) > tolerance_sec:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + bytes(raw_body), hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)
