#!/usr/bin/env python3
"""c21-client.py -- single-file, stdlib-only Python 3.8+ client for the
Century 21 Global feed API (urllib.request + json; nothing to pip-install).

Full API specification: https://c21global.cservices.au/ai-integration-guide.md
Interactive docs:       https://api.cservices.au/docs

The intended CRM workflow ("golden path"):
  1. whoami()        -- startup health check: verifies your key + office scope.
  2. validate()      -- open sandbox (NO auth, never persists). Runs the identical
                        mapping code as live ingestion: clean validate = working push.
  3. push()          -- authenticated ingestion. mode "incremental" (default) upserts
                        only what you send; a daily mode "full" snapshot also archives
                        listings absent from it (mass-delete circuit breaker applies).
  4. get_listing() / get_project() -- read the canonical document back to verify.

Every non-2xx response raises RuntimeError carrying the FULL response body --
that body is where the API explains itself (validation issues, scope errors...).
"""

import json
import os
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_BASE_URL = "https://api.cservices.au"


class C21Client:
    """Thin, explicit wrapper over the seven endpoints a CRM integration needs.

    The push/validate "envelope" is a plain dict (max 25 MiB serialized):
        {
          "format": "reaxml" | "reso" | "project",
          "mode": "incremental" | "full",          # optional, default incremental
          "context": {
            "currency": "AUD",                     # REQUIRED (ISO-4217) -- no
                                                   # inbound format carries one
            "defaultCountry": "AU",                # optional ISO-3166 alpha-2
            "sourceLocale": "en-AU",               # optional BCP-47
            "sourceOfficeId": "OFF-1",             # optional override
          },
          "xml": "<feed document as a string>",    # REAXML XML, or a JSON *string*
        }                                          # for reso/project -- yes, JSON
                                                   # also travels in the "xml" field
    """

    def __init__(self, base_url=None, key=None):
        # key ("c21_...") is only needed for whoami() and push(); reads are public.
        self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
        self.key = key

    # ------------------------------------------------------------ endpoints

    def whoami(self):
        """GET /v1/feeds/whoami -- verify the key; {name, allowedOffices}.
        allowedOffices None/null = unrestricted. Call this at startup: a 401
        means a bad/revoked key; unexpected offices mean a mis-scoped key."""
        return self._request("GET", "/v1/feeds/whoami", auth=True)

    def validate(self, envelope):
        """POST /v1/validate -- open sandbox, no auth, never persists. Fix every
        'error'-level entry in the returned 'issues'; warnings are advisory."""
        return self._request("POST", "/v1/validate", body=envelope)

    def push(self, envelope):
        """POST /v1/feeds -- authenticated ingestion. Idempotent: re-pushing
        identical content reports 'unchanged'; older modTimes 'stale_ignored'."""
        return self._request("POST", "/v1/feeds", auth=True, body=envelope)

    def search_listings(self, params=None):
        """GET /v1/listings -- public search, e.g. {"country": "AU", "limit": 5}."""
        return self._request("GET", "/v1/listings", query=params)

    def get_listing(self, office, unique_id):
        """GET /v1/listings/{office}/{uniqueId} -- one canonical listing."""
        return self._request("GET", "/v1/listings/%s/%s"
                             % (urllib.parse.quote(office, safe=""),
                                urllib.parse.quote(unique_id, safe="")))

    def search_projects(self, params=None):
        """GET /v1/projects -- public search, e.g. {"stage": "offThePlan"}."""
        return self._request("GET", "/v1/projects", query=params)

    def get_project(self, office, unique_id):
        """GET /v1/projects/{office}/{uniqueId} -- project + published children."""
        return self._request("GET", "/v1/projects/%s/%s"
                             % (urllib.parse.quote(office, safe=""),
                                urllib.parse.quote(unique_id, safe="")))

    # ------------------------------------------------------------- plumbing

    def _request(self, method, path, auth=False, body=None, query=None):
        """One choke point for every call. FAILS LOUDLY on non-2xx: the raised
        RuntimeError message includes the HTTP status AND the response body."""
        url = self.base_url + path
        if query:
            filtered = {k: v for k, v in query.items() if v is not None}
            if filtered:
                url += "?" + urllib.parse.urlencode(filtered)

        headers = {"Accept": "application/json"}
        data = None
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if auth:
            if not self.key:
                raise RuntimeError(
                    "C21Client: %s requires an API key -- C21Client(key='c21_...')" % path)
            headers["Authorization"] = "Bearer " + self.key

        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                text = resp.read().decode("utf-8")
        except urllib.error.HTTPError as err:
            # err.read() is the API's error body -- surface it verbatim.
            detail = err.read().decode("utf-8", "replace")
            raise RuntimeError("C21 API %s %s -> HTTP %d: %s"
                               % (method, path, err.code, detail)) from err
        return json.loads(text) if text else None


if __name__ == "__main__":
    # Demo: verify connectivity + key scope. Reads are public, so the search
    # always runs; whoami only runs when C21_KEY is set in the environment.
    client = C21Client(base_url=os.environ.get("C21_BASE_URL"),
                       key=os.environ.get("C21_KEY"))
    if client.key:
        print("whoami:", json.dumps(client.whoami(), indent=2))
    else:
        print("C21_KEY not set -- skipping whoami (set it to health-check your key).")
    listings = client.search_listings({"limit": 1})
    print("public read OK -- /v1/listings returned:",
          json.dumps(listings, indent=2)[:400], "...")
