"""RankRight Python SDK — a thin client for the RankRight API.

    pip install rankright            (PyPI)
    pip install https://app.rankright.dev/sdk/rankright-python.zip

    from rankright import Client
    rr = Client(api_key="rrk_...")                  # or access_token="rro_..." (OAuth)
    rr.get_aeo_summary(client_id=12)                # any RPC, as a method
    rr.rpc("set_aeo_cadence", client_id=12, engines=["claude"])
    job = rr.jobs.run("export_org")                 # dispatch + wait
    rr.exports.download(job["result"]["download_url"], "export.zip")

    # OAuth for CLIs / agents (opens a browser, catches the redirect locally)
    tokens = OAuthFlow("https://app.rankright.dev").login(scopes=["read", "write", "offline_access"])
    rr = Client(access_token=tokens["access_token"])

The SDK contains no business logic: it is the same calls documented at
https://app.rankright.dev/developers.md, with auth, retries (429 →
Retry-After), idempotency keys, the error envelope as an exception, job
polling and webhook signature verification done for you.
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import secrets
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Callable, Iterable, Optional
from urllib.parse import parse_qs, urlencode, urlparse

__version__ = '1.0.0'
__all__ = ['Client', 'RankRightError', 'OAuthFlow', 'verify_webhook_signature', '__version__']

DEFAULT_BASE_URL = 'https://app.rankright.dev'


class RankRightError(Exception):
    """The API's error envelope as an exception: .status, .code, .detail, .hint."""

    def __init__(self, status: int, code: str = 'error', detail: str = '', hint: str = '',
                 retry_after: Optional[float] = None, body: Any = None):
        super().__init__(f'{status} {code}: {detail or ""}'.strip())
        self.status, self.code, self.detail, self.hint, self.retry_after, self.body = status, code, detail, hint, retry_after, body


def verify_webhook_signature(secret: str, header: str, body: str | bytes, tolerance_seconds: int = 300) -> bool:
    """Check an X-RankRight-Signature header against the raw request body."""
    if isinstance(body, bytes):
        body = body.decode('utf-8')
    try:
        parts = dict(p.split('=', 1) for p in (header or '').split(','))
        ts, given = int(parts['t']), parts['v1']
    except Exception:
        return False
    if abs(time.time() - ts) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode('utf-8'), f'{ts}.{body}'.encode('utf-8'), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, given)


# ---------------------------------------------------------------------------
class Client:
    """HTTP client for /api/v1. Pass an rrk_ API key or an rro_ OAuth access
    token (or set RANKRIGHT_API_KEY). Any RPC is available as a method:
    `rr.get_all_clients()`, `rr.get_aeo_items(client_id=12, status="open")`."""

    def __init__(self, api_key: Optional[str] = None, access_token: Optional[str] = None, *,
                 base_url: str = DEFAULT_BASE_URL, session: Any = None, timeout: float = 60.0,
                 max_retries: int = 3, user_agent: Optional[str] = None):
        self.base_url = (base_url or os.environ.get('RANKRIGHT_BASE_URL') or DEFAULT_BASE_URL).rstrip('/')
        self.token = api_key or access_token or os.environ.get('RANKRIGHT_API_KEY') or ''
        self.timeout = timeout
        self.max_retries = max_retries
        self.user_agent = user_agent or f'rankright-python/{__version__}'
        if session is None:
            import requests
            session = requests.Session()
        self._session = session
        self.jobs = _Jobs(self)
        self.exports = _Exports(self)

    # -- low level -----------------------------------------------------------
    def request(self, method: str, path: str, *, json_body: Any = None, data: Any = None,
                headers: Optional[dict] = None, stream: bool = False):
        url = path if path.startswith('http') else self.base_url + path
        h = {'Accept': 'application/json', 'User-Agent': self.user_agent}
        if self.token:
            h['Authorization'] = f'Bearer {self.token}'
        if headers:
            h.update(headers)
        attempt = 0
        while True:
            r = self._session.request(method, url, json=json_body, data=data, headers=h, timeout=self.timeout, stream=stream)
            if r.status_code == 429 and attempt < self.max_retries:
                attempt += 1
                time.sleep(float(r.headers.get('Retry-After') or 2 ** attempt))
                continue
            if r.status_code >= 400:
                raise self._error(r)
            return r

    @staticmethod
    def _error(r) -> RankRightError:
        try:
            body = r.json()
        except Exception:
            body = None
        if isinstance(body, dict):
            code = body.get('code') or body.get('error') or 'error'
            return RankRightError(r.status_code, str(code), str(body.get('detail') or body.get('error_description') or body.get('error') or ''),
                                  str(body.get('hint') or ''), _float(r.headers.get('Retry-After')), body)
        return RankRightError(r.status_code, 'error', (getattr(r, 'text', '') or '')[:300])

    # -- RPC -----------------------------------------------------------------
    def rpc(self, method: str, idempotency_key: Optional[str] = None, **kwargs) -> Any:
        """POST /api/v1/rpc/<method> with keyword arguments; returns the result."""
        headers = {'Idempotency-Key': idempotency_key} if idempotency_key else None
        return self.request('POST', f'/api/v1/rpc/{method}', json_body=kwargs, headers=headers).json().get('result')

    def __getattr__(self, name: str) -> Callable[..., Any]:
        if name.startswith('_') or name in ('jobs', 'exports'):
            raise AttributeError(name)

        def call(**kwargs):
            return self.rpc(name, **kwargs)
        call.__name__ = name
        call.__doc__ = f'RPC {name} — see {self.base_url}/developers.md'
        return call

    # -- meta ----------------------------------------------------------------
    def capabilities(self) -> dict:
        return self.request('GET', '/api/v1/capabilities').json()

    def openapi(self) -> dict:
        return self.request('GET', '/api/v1/openapi.json').json()

    def developers_md(self) -> str:
        return self.request('GET', '/developers.md').text

    def health(self) -> dict:
        return self.request('GET', '/api/v1/health').json()


class _Jobs:
    def __init__(self, client: Client):
        self._c = client

    def create(self, kind: str, *, client_id: Optional[int] = None, group_id: Optional[int] = None,
               org_id: Optional[int] = None, payload: Optional[dict] = None, idempotency_key: Optional[str] = None,
               **extra) -> dict:
        body: dict[str, Any] = {'kind': kind}
        if client_id is not None:
            body['client_id'] = client_id
        if group_id is not None:
            body['group_id'] = group_id
        if org_id is not None:
            body['org_id'] = org_id
        if payload is not None:
            body['payload'] = payload
        body.update(extra)
        headers = {'Idempotency-Key': idempotency_key} if idempotency_key else None
        return self._c.request('POST', '/api/v1/jobs', json_body=body, headers=headers).json()

    def get(self, job_id: int) -> dict:
        return self._c.request('GET', f'/api/v1/jobs/{int(job_id)}').json()

    def cancel(self, job_id: int) -> dict:
        return self._c.request('POST', f'/api/v1/jobs/{int(job_id)}/cancel').json()

    def wait(self, job_id: int, *, timeout: float = 900.0, interval: float = 3.0,
             on_progress: Optional[Callable[[dict], None]] = None) -> dict:
        """Poll until the job is done; raise RankRightError if it failed or was cancelled."""
        deadline = time.time() + timeout
        while True:
            job = self.get(job_id)
            if on_progress:
                on_progress(job)
            status = job.get('status')
            if status == 'done':
                return job
            if status in ('failed', 'cancelled'):
                raise RankRightError(409, f'job_{status}', job.get('error') or f'job {job_id} {status}', body=job)
            if time.time() > deadline:
                raise RankRightError(408, 'job_timeout', f'job {job_id} still {status} after {timeout:.0f}s', body=job)
            time.sleep(interval)

    def run(self, kind: str, *, wait: bool = True, timeout: float = 900.0, **kwargs) -> dict:
        """Dispatch and (by default) wait for the result."""
        created = self.create(kind, **kwargs)
        return self.wait(created['job_id'], timeout=timeout) if wait else created


class _Exports:
    def __init__(self, client: Client):
        self._c = client

    def run(self, *, org_id: Optional[int] = None, wait: bool = True, timeout: float = 900.0) -> dict:
        """POST export_org (+wait). Returns the job; the zip is at job['result']['download_url']."""
        return self._c.jobs.run('export_org', org_id=org_id, wait=wait, timeout=timeout)

    def list(self) -> list:
        return self._c.rpc('list_org_exports') or []

    def download(self, download_url_or_job: Any, dest: str) -> str:
        """Save an export zip to `dest`. Accepts the download_url, a job dict, or an export listing row."""
        url = download_url_or_job
        if isinstance(url, dict):
            url = (url.get('result') or {}).get('download_url') or url.get('download_url')
        if not url:
            raise ValueError('no download_url')
        r = self._c.request('GET', url, headers={'Accept': 'application/zip'})
        with open(dest, 'wb') as f:
            f.write(r.content)
        return dest


# ---------------------------------------------------------------------------
class OAuthFlow:
    """Authorization code + PKCE for a CLI or agent: registers a public client
    (dynamic registration), opens the consent page in the browser, catches
    the redirect on a loopback port, exchanges the code. Returns the token
    response ({access_token, refresh_token?, expires_in, scope})."""

    def __init__(self, base_url: str = DEFAULT_BASE_URL, *, client_name: str = 'rankright-python',
                 session: Any = None, timeout: float = 30.0):
        self.base_url = base_url.rstrip('/')
        self.client_name = client_name
        self.timeout = timeout
        if session is None:
            import requests
            session = requests.Session()
        self._session = session
        self.client_id: Optional[str] = None

    def metadata(self) -> dict:
        r = self._session.request('GET', self.base_url + '/.well-known/oauth-authorization-server', timeout=self.timeout)
        r.raise_for_status()
        return r.json()

    def register(self, redirect_uri: str) -> str:
        r = self._session.request('POST', self.base_url + '/oauth/register', timeout=self.timeout,
                                  json={'client_name': self.client_name, 'redirect_uris': [redirect_uri],
                                        'token_endpoint_auth_method': 'none'})
        if r.status_code >= 400:
            raise RankRightError(r.status_code, 'registration_failed', getattr(r, 'text', '')[:300])
        self.client_id = r.json()['client_id']
        return self.client_id

    @staticmethod
    def pkce() -> tuple[str, str]:
        verifier = secrets.token_urlsafe(48)
        challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()
        return verifier, challenge

    def authorization_url(self, redirect_uri: str, scopes: Iterable[str], state: str, challenge: str) -> str:
        md = self.metadata()
        q = urlencode({'response_type': 'code', 'client_id': self.client_id, 'redirect_uri': redirect_uri,
                       'scope': ' '.join(scopes), 'state': state, 'code_challenge': challenge,
                       'code_challenge_method': 'S256', 'resource': self.base_url + '/api/v1/mcp'})
        return md['authorization_endpoint'] + ('&' if '?' in md['authorization_endpoint'] else '?') + q

    def exchange(self, code: str, verifier: str, redirect_uri: str) -> dict:
        md = self.metadata()
        r = self._session.request('POST', md['token_endpoint'], timeout=self.timeout,
                                  data={'grant_type': 'authorization_code', 'code': code, 'code_verifier': verifier,
                                        'redirect_uri': redirect_uri, 'client_id': self.client_id})
        if r.status_code >= 400:
            raise Client._error(r)
        return r.json()

    def refresh(self, refresh_token: str) -> dict:
        md = self.metadata()
        r = self._session.request('POST', md['token_endpoint'], timeout=self.timeout,
                                  data={'grant_type': 'refresh_token', 'refresh_token': refresh_token, 'client_id': self.client_id})
        if r.status_code >= 400:
            raise Client._error(r)
        return r.json()

    def login(self, scopes: Iterable[str] = ('read', 'write', 'jobs', 'offline_access'), *, open_browser: bool = True,
              timeout: float = 300.0, port: int = 0) -> dict:
        """Interactive login: returns the token response. Prints the URL if the browser cannot be opened."""
        server = HTTPServer(('127.0.0.1', port), _CallbackHandler)
        server.result = {}   # type: ignore[attr-defined]
        redirect_uri = f'http://127.0.0.1:{server.server_port}/callback'
        self.register(redirect_uri)
        verifier, challenge = self.pkce()
        state = secrets.token_urlsafe(16)
        url = self.authorization_url(redirect_uri, scopes, state, challenge)
        t = threading.Thread(target=server.serve_forever, daemon=True)
        t.start()
        try:
            if open_browser:
                webbrowser.open(url)
            print(f'Open this URL to authorize:\n  {url}')
            deadline = time.time() + timeout
            while not server.result and time.time() < deadline:   # type: ignore[attr-defined]
                time.sleep(0.2)
        finally:
            server.shutdown()
        res = server.result   # type: ignore[attr-defined]
        if not res:
            raise RankRightError(408, 'oauth_timeout', 'no authorization received')
        if res.get('error'):
            raise RankRightError(400, res['error'], res.get('error_description', ''))
        if res.get('state') != state:
            raise RankRightError(400, 'state_mismatch', 'authorization response did not match the request')
        return self.exchange(res['code'], verifier, redirect_uri)


class _CallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):  # noqa: N802
        q = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}
        self.server.result = q   # type: ignore[attr-defined]
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.end_headers()
        self.wfile.write(b'<html><body style="font-family:sans-serif"><h2>RankRight: you can close this window.</h2></body></html>')

    def log_message(self, *args):  # silence
        return


def _float(v) -> Optional[float]:
    try:
        return float(v) if v is not None else None
    except (TypeError, ValueError):
        return None
