"""GridPermit API client for Python 3.8+. One file, standard library only.

    from gridpermit import GridPermit
    gp = GridPermit(api_key=os.environ["GRIDPERMIT_KEY"])
    screen = gp.screen("35.7697, -96.7205")
    batch = gp.screen_batch(["35.7697, -96.7205", "600 Main St, Stroud, OK"])
    done = gp.wait_for_batch(batch["batchId"])

Keys starting gp_test_ are sandbox keys: they answer with a fixed sample and use no quota, so you can build an integration first.
"""

import hashlib
import hmac
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_BASE_URL = "https://gridpermit.backburnerlabs.net"


class GridPermitError(Exception):
    """Raised for any non-2xx answer. `status`, `code` and `request_id` come from the API's error body when it sent one."""

    def __init__(self, message, status=None, code=None, request_id=None, body=None):
        super().__init__(message)
        self.status = status
        self.code = code
        self.request_id = request_id
        self.body = body


def _clean_query(query):
    out = {}
    for key, value in (query or {}).items():
        if value is None or value == "":
            continue
        out[key] = ",".join(str(v) for v in value) if isinstance(value, (list, tuple)) else str(value)
    return out


class GridPermit:
    def __init__(self, api_key, base_url=DEFAULT_BASE_URL, retries=2, timeout=120):
        if not api_key:
            raise ValueError("GridPermit: api_key is required.")
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.retries = retries
        self.timeout = timeout
        # Quota numbers from the most recent response that carried them: {"limit", "remaining", "resets", "overage"}.
        self.last_quota = None

    # -- transport ----------------------------------------------------------------------------------------------------------------------

    def _request(self, method, path, query=None, body=None, raw=False):
        url = self.base_url + path
        params = _clean_query(query)
        if params:
            url += "?" + urllib.parse.urlencode(params)
        headers = {"Authorization": "Bearer " + self.api_key, "Accept": "application/json"}
        data = None
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        attempt = 0
        while True:
            req = urllib.request.Request(url, data=data, method=method, headers=headers)
            try:
                resp = urllib.request.urlopen(req, timeout=self.timeout)
                status, resp_headers, payload = resp.status, resp.headers, resp.read()
            except urllib.error.HTTPError as e:
                status, resp_headers, payload = e.code, e.headers, e.read()
            except (urllib.error.URLError, TimeoutError) as e:
                if method == "GET" and attempt < self.retries:
                    time.sleep(0.5 * 2 ** attempt)
                    attempt += 1
                    continue
                raise GridPermitError("Network error: %s" % e)
            limit = resp_headers.get("X-Quota-Limit")
            if limit is not None:
                self.last_quota = {
                    "limit": int(limit),
                    "remaining": int(resp_headers.get("X-Quota-Remaining", "0")),
                    "resets": resp_headers.get("X-Quota-Resets"),
                    "overage": resp_headers.get("X-Quota-Overage") == "enabled",
                }
            retryable = status == 429 or (method == "GET" and status in (502, 503))
            if retryable and attempt < self.retries:
                try:
                    wait = float(resp_headers.get("Retry-After", ""))
                except ValueError:
                    wait = 0.5 * 2 ** attempt
                time.sleep(min(max(wait, 0), 60))
                attempt += 1
                continue
            if status < 200 or status >= 300:
                parsed = None
                try:
                    parsed = json.loads(payload.decode("utf-8"))
                except ValueError:
                    pass
                message = (parsed or {}).get("error") or "HTTP %d" % status
                raise GridPermitError(message, status=status, code=(parsed or {}).get("code"), request_id=(parsed or {}).get("requestId"), body=parsed)
            if raw:
                return payload
            if "json" in (resp_headers.get("Content-Type") or ""):
                return json.loads(payload.decode("utf-8"))
            return payload.decode("utf-8")

    # -- screening ----------------------------------------------------------------------------------------------------------------------

    def screen(self, query, **options):
        """Screen one site. options: include, loadMw, demandChargeUsdPerKwMonth, exitAfterYear."""
        return self._request("POST", "/api/v1/screen", body=dict(query=query, **options))

    def screen_pdf(self, query, prepared_for=None, prepared_by=None):
        """The client-ready PDF screen as bytes."""
        body = {"query": query}
        if prepared_for:
            body["preparedFor"] = prepared_for
        if prepared_by:
            body["preparedBy"] = prepared_by
        return self._request("POST", "/api/v1/screen/pdf", body=body, raw=True)

    def screen_batch(self, sites, callback_url=None):
        """Start a batch (up to your plan's limit). sites: strings or {"id", "query"} dicts."""
        body = {"sites": list(sites)}
        if callback_url:
            body["callbackUrl"] = callback_url
        return self._request("POST", "/api/v1/screen/batch", body=body)

    def batch(self, batch_id, format=None):
        return self._request("GET", "/api/v1/screen/batch/" + urllib.parse.quote(batch_id, safe=""), query={"format": format})

    def wait_for_batch(self, batch_id, interval=5.0, timeout=900.0):
        """Polls until the batch is done (or `timeout` seconds pass) and returns its ranked results."""
        stop = time.time() + timeout
        while True:
            b = self.batch(batch_id)
            if b.get("status") == "done":
                return b
            if time.time() > stop:
                raise GridPermitError("Batch %s was not finished after %ds." % (batch_id, timeout), code="timeout")
            time.sleep(interval)

    def batch_site(self, batch_id, ref):
        return self._request("GET", "/api/v1/screen/batch/%s/sites/%s" % (urllib.parse.quote(batch_id, safe=""), urllib.parse.quote(str(ref), safe="")))

    def delete_batch(self, batch_id):
        return self._request("DELETE", "/api/v1/screen/batch/" + urllib.parse.quote(batch_id, safe=""))

    # -- tariffs and area data ----------------------------------------------------------------------------------------------------------

    def tariffs_at(self, query, mw=None, rate=None, exit=None):
        """Which large-load tariffs apply at a point."""
        return self._request("GET", "/api/v1/tariffs/at", query={"query": query, "mw": mw, "rate": rate, "exit": exit})

    def commitment(self, **body):
        return self._request("POST", "/api/v1/commitment", body=body)

    def tariffs(self, state=None, format=None):
        return self._request("GET", "/api/v1/tariffs", query={"state": state, "format": format})

    def nearby(self, query, miles=None, layers=None, format=None):
        """Everything tracked within a radius (up to 50 miles). layers: a list drawn from gas_pipelines, gas_plants, generation, data_centers, planned."""
        return self._request("GET", "/api/v1/nearby", query={"query": query, "miles": miles, "layers": layers, "format": format})

    # -- changes ------------------------------------------------------------------------------------------------------------------------

    def changes(self, since=None, states=None, format=None):
        return self._request("GET", "/api/v1/changes", query={"since": since, "states": states, "format": format})

    def history(self, states=None, since=None, until=None, limit=None, format=None):
        return self._request("GET", "/api/v1/history", query={"states": states, "since": since, "until": until, "limit": limit, "format": format})

    # -- account ------------------------------------------------------------------------------------------------------------------------

    def benchmarks(self, **terms):
        """Pro, Team, Enterprise and Sandbox keys. Pass the terms you were offered (camelCase names: minTermYears, minBillPct, collateralMonths, exitYears, noticeMonths, maxRampYears)."""
        return self._request("GET", "/api/v1/benchmarks", query=terms)

    def usage(self):
        return self._request("GET", "/api/v1/usage")

    def terms(self):
        """The API terms of use and whether this key has accepted them. A new paid key must accept once before any other call works."""
        return self._request("GET", "/api/v1/terms")

    def accept_terms(self, name, version=None):
        """Accept the API terms. name is the full name of the person accepting. Read the terms first (terms()["sections"] or https://gridpermit.backburnerlabs.net/api-terms)."""
        body = {"accept": True, "name": name}
        if version:
            body["version"] = version
        return self._request("POST", "/api/v1/terms/accept", body=body)

    def status(self):
        return self._request("GET", "/api/v1/status")

    # -- webhooks -----------------------------------------------------------------------------------------------------------------------

    def create_webhook(self, url, events=None, states=None, utilities=None, format=None):
        """format: "json" (default), "slack" or "teams"."""
        body = {"url": url}
        for key, value in (("events", events), ("states", states), ("utilities", utilities), ("format", format)):
            if value is not None:
                body[key] = value
        return self._request("POST", "/api/v1/webhooks", body=body)

    def webhooks(self):
        return self._request("GET", "/api/v1/webhooks")

    def update_webhook(self, webhook_id, **changes):
        return self._request("PATCH", "/api/v1/webhooks/" + urllib.parse.quote(webhook_id, safe=""), body=changes)

    def delete_webhook(self, webhook_id):
        return self._request("DELETE", "/api/v1/webhooks/" + urllib.parse.quote(webhook_id, safe=""))

    def test_webhook(self, webhook_id):
        return self._request("POST", "/api/v1/webhooks/%s/test" % urllib.parse.quote(webhook_id, safe=""))

    def webhook_events(self):
        return self._request("GET", "/api/v1/webhooks/events")

    def replay_event(self, event_id):
        return self._request("POST", "/api/v1/webhooks/events/%s/replay" % urllib.parse.quote(event_id, safe=""))

    @staticmethod
    def verify_webhook(secret, signature_header, raw_body, tolerance_seconds=300, now=None):
        """True only when the GridPermit-Signature header matches the exact request body and the timestamp is recent."""
        t = re.search(r"t=(\d+)", signature_header or "")
        v = re.search(r"v1=([0-9a-f]+)", signature_header or "")
        if not t or not v:
            return False
        if abs((now if now is not None else time.time()) - int(t.group(1))) > tolerance_seconds:
            return False
        if isinstance(raw_body, str):
            raw_body = raw_body.encode("utf-8")
        expected = hmac.new(secret.encode("utf-8"), t.group(1).encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, v.group(1))

    # -- site watches -------------------------------------------------------------------------------------------------------------------

    def watch_site(self, query, label=None):
        return self._request("POST", "/api/v1/watches", body={"query": query, "label": label})

    def watch_sites(self, sites):
        """sites: strings or {"query", "label"} dicts; up to 25 per call."""
        return self._request("POST", "/api/v1/watches/bulk", body={"sites": list(sites)})

    def watches(self):
        return self._request("GET", "/api/v1/watches")

    def watch(self, watch_id):
        return self._request("GET", "/api/v1/watches/" + urllib.parse.quote(watch_id, safe=""))

    def delete_watch(self, watch_id):
        return self._request("DELETE", "/api/v1/watches/" + urllib.parse.quote(watch_id, safe=""))
