"""A small client for the Slflo integration API, with the rules every connector needs.

- 401: the token can't be used any more. Stop the job and alert; retrying won't help.
- 429: wait the Retry-After seconds, then send the same call again.
- 5xx, a lost connection, or 409 REQUEST_IN_PROGRESS: back off (1 s, 2 s, 4 s ...) and retry.
  Every write carries an Idempotency-Key, so a retry can never apply twice.
- Any other 4xx: raise ApiError with the code, message and details; fix the data.

Standard library only (Python 3.8+). Settings come from the environment:
  SLFLO_TOKEN  your token, sfi_live_... (sfi_test_... on a copy of Slflo you run yourself)
  SLFLO_BASE   default https://api.slflo.com/api/integration/v1
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_BASE = "https://api.slflo.com/api/integration/v1"


class TokenRefused(Exception):
    """401: TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED or COMPANY_SUSPENDED."""

    def __init__(self, code, message):
        super().__init__(f"{code}: {message}")
        self.code = code


class ApiError(Exception):
    """A refusal to fix on your side (or a call that kept failing)."""

    def __init__(self, status, error):
        self.status = status
        self.code = error.get("code", "")
        self.details = error.get("details", [])
        self.retryable = error.get("retryable", False)
        self.request_id = error.get("request_id")
        super().__init__(f"{status} {self.code}: {error.get('message', '')}")


class Slflo:
    def __init__(self, token=None, base=None, max_attempts=6, first_backoff=1.0, timeout=30):
        self.token = token or os.environ["SLFLO_TOKEN"]
        self.base = (base or os.environ.get("SLFLO_BASE") or DEFAULT_BASE).rstrip("/")
        self.max_attempts = max_attempts
        self.first_backoff = first_backoff
        self.timeout = timeout

    def get(self, path, query=None):
        return self.call("GET", path, query=query)

    def put(self, path, body, key, query=None):
        return self.call("PUT", path, query=query, body=body, key=key)

    def post(self, path, body, key):
        return self.call("POST", path, body=body, key=key)

    def call(self, method, path, query=None, body=None, key=None):
        url = self.base + path
        if query:
            url += "?" + urllib.parse.urlencode(query)
        headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
        data = None
        if body is not None:
            data = json.dumps(body).encode()
            headers["Content-Type"] = "application/json"
        if key is not None:
            headers["Idempotency-Key"] = key

        backoff = self.first_backoff
        for attempt in range(1, self.max_attempts + 1):
            last = attempt == self.max_attempts
            request = urllib.request.Request(url, data=data, headers=headers, method=method)
            try:
                with urllib.request.urlopen(request, timeout=self.timeout) as response:
                    return json.loads(response.read() or b"{}")
            except urllib.error.HTTPError as refused:
                status, error = refused.code, _error_of(refused)
                retry_after = refused.headers.get("Retry-After")
                if status == 401:
                    raise TokenRefused(error.get("code", "TOKEN_INVALID"), error.get("message", ""))
                if status == 429:
                    wait = int(retry_after) if retry_after and retry_after.isdigit() else backoff
                    _log(f"{method} {path}: 429 {error.get('code', '')}, waiting {wait}s (Retry-After)")
                    if last:
                        raise ApiError(status, error)
                    time.sleep(wait)
                    continue
                if status >= 500 or error.get("code") == "REQUEST_IN_PROGRESS":
                    if last:
                        raise ApiError(status, error)
                    wait = int(retry_after) if retry_after and retry_after.isdigit() else backoff
                    _log(f"{method} {path}: {status} {error.get('code', '')}, backing off {wait}s")
                    time.sleep(wait)
                    backoff *= 2
                    continue
                raise ApiError(status, error)
            except OSError as lost:  # connection refused or reset, timeout
                # The answer was lost: the call may or may not have landed. The retry
                # carries the same Idempotency-Key, so it can't apply twice.
                if last:
                    raise
                _log(f"{method} {path}: {lost}, backing off {backoff}s")
                time.sleep(backoff)
                backoff *= 2
        raise AssertionError("unreachable")

    def pages(self, path, query=None, after=0, per_page=100):
        """Every page of a collection by the version cursor, oldest change first."""
        while True:
            page = self.get(path, {**(query or {}), "filter[version_after]": after, "per_page": per_page})
            yield page
            if not page["meta"]["has_more"]:
                return
            after = page["meta"]["next_version_after"]


def _error_of(refused):
    try:
        return json.loads(refused.read()).get("error", {})
    except ValueError:
        return {}  # a gateway's HTML or plain-text answer


def _log(message):
    print(f"slflo: {message}", file=sys.stderr, flush=True)


def report(summary):
    """Each sample ends by printing one line of JSON: what it did."""
    print(json.dumps(summary, sort_keys=True), flush=True)
