#!/usr/bin/env python3
"""ymcs_client.py — Yealink Management Cloud Service (YMCS) v2 open-API client.

Manages ACG-managed client Yealink phone fleets. Pairs with the `packetdial`
skill (NetSapiens PBX): YMCS owns the physical phones (provisioning, config,
firmware); the PBX owns the SIP accounts the phones register to.

Auth (v2): POST /v2/token with HTTP Basic (AccessKeyID:Secret) -> bearer token
(cached ~24h). Subsequent calls send `Authorization: Bearer <token>`. Every
request also carries `timestamp` (ms) + `nonce` headers. Yealink's servers
require LEGACY TLS renegotiation — handled via an SSL context with
OP_LEGACY_SERVER_CONNECT.

Credentials come from the SOPS vault entry `services/yealink-ymcs.sops.yaml`
(credentials.access_key_id / access_key_secret), or env overrides
YMCS_ACCESS_KEY_ID / YMCS_ACCESS_KEY_SECRET. Region via YMCS_REGION (default us).

List endpoints are POST with {skip, limit, autoCount} and return {total, data:[]}.
"""
import sys, os, json, ssl, base64, uuid, time, subprocess
import urllib.request, urllib.error, urllib.parse

REGION_HOSTS = {
    "us": "https://us-api.ymcs.yealink.com",
    "eu": "https://eu-api.ymcs.yealink.com",
    "au": "https://au-api.ymcs.yealink.com",
}
VAULT_ENTRY = "services/yealink-ymcs.sops.yaml"


def _log_skill_error(msg, context=""):
    try:
        root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
            os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
        )
        h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
        if not os.path.exists(h):
            return
        a = ["bash", h, "yealink-ymcs", msg]
        if context:
            a += ["--context", context]
        subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
    except Exception:
        pass


class YmcsError(Exception):
    pass


def _repo_root():
    return os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
        os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
    )


def _vault_field(field):
    """Read a field from the YMCS vault entry via vault.sh (None if absent)."""
    vault_sh = os.path.join(_repo_root(), ".claude", "scripts", "vault.sh")
    if not os.path.exists(vault_sh):
        return None
    try:
        r = subprocess.run(["bash", vault_sh, "get-field", VAULT_ENTRY, field],
                           capture_output=True, text=True, timeout=30)
        v = (r.stdout or "").strip()
        return v or None
    except Exception:
        return None


def _legacy_tls_context():
    """SSL context that permits Yealink's legacy TLS renegotiation."""
    ctx = ssl.create_default_context()
    legacy = getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4)
    ctx.options |= legacy
    return ctx


class YmcsClient:
    def __init__(self):
        self.key_id = (os.environ.get("YMCS_ACCESS_KEY_ID")
                       or _vault_field("credentials.access_key_id"))
        self.key_secret = (os.environ.get("YMCS_ACCESS_KEY_SECRET")
                           or _vault_field("credentials.access_key_secret"))
        if not self.key_id or not self.key_secret:
            raise YmcsError(
                "No YMCS credentials. Expected vault " + VAULT_ENTRY +
                " (credentials.access_key_id/access_key_secret) or env "
                "YMCS_ACCESS_KEY_ID/YMCS_ACCESS_KEY_SECRET."
            )
        self.region = (os.environ.get("YMCS_REGION") or "us").lower()
        self.base = REGION_HOSTS.get(self.region, REGION_HOSTS["us"])
        self._ctx = _legacy_tls_context()
        self._token = None

    # ---- low-level HTTP ----
    def _http(self, method, url, headers, body=None):
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(url, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=30, context=self._ctx) as r:
                raw = r.read().decode("utf-8", "replace")
                return r.status, (json.loads(raw) if raw.strip() else None)
        except urllib.error.HTTPError as e:
            raw = e.read().decode("utf-8", "replace")
            try:
                return e.code, json.loads(raw)
            except Exception:
                return e.code, {"error": raw[:400]}
        except Exception as e:
            return 0, {"error": str(e)}

    def _stamp_headers(self, extra=None):
        h = {"Content-Type": "application/json",
             "timestamp": str(int(time.time() * 1000)),
             "nonce": uuid.uuid4().hex}
        if extra:
            h.update(extra)
        return h

    # ---- auth ----
    def token(self):
        if self._token:
            return self._token
        basic = base64.b64encode(f"{self.key_id}:{self.key_secret}".encode()).decode()
        h = self._stamp_headers({"Authorization": "Basic " + basic})
        st, r = self._http("POST", self.base + "/v2/token", h, {"grant_type": "client_credentials"})
        if st != 200 or not isinstance(r, dict) or not r.get("access_token"):
            _log_skill_error(f"token request failed (HTTP {st})", context=f"resp={json.dumps(r)[:120]}")
            raise YmcsError(f"YMCS token request failed (HTTP {st}): {json.dumps(r)[:300]}")
        self._token = r["access_token"]
        return self._token

    # ---- authenticated request ----
    def request(self, method, endpoint, body=None, params=None):
        if not endpoint.startswith("/"):
            endpoint = "/" + endpoint
        url = self.base + endpoint
        if params:
            q = {k: v for k, v in params.items() if v is not None}
            if q:
                url += "?" + urllib.parse.urlencode(q)
        h = self._stamp_headers({"Authorization": "Bearer " + self.token()})
        st, r = self._http(method, url, h, body)
        if st == 401:  # token may have expired mid-life; refresh once
            self._token = None
            h = self._stamp_headers({"Authorization": "Bearer " + self.token()})
            st, r = self._http(method, url, h, body)
        if st not in (200, 201, 204):
            raise YmcsError(f"YMCS API {method} {endpoint} -> HTTP {st}: {json.dumps(r)[:300]}")
        return r

    def _list(self, endpoint, limit=500, extra=None, fetch_all=True):
        """POST a paged list endpoint ({skip,limit,autoCount}); returns the data list.

        Set fetch_all=False (or limit small) for a single page.
        """
        out, skip, total = [], 0, None
        while True:
            body = {"skip": skip, "limit": limit, "autoCount": total is None}
            if extra:
                body.update(extra)
            r = self.request("POST", endpoint, body=body)
            if total is None:
                total = (r or {}).get("total", 0)
            items = (r or {}).get("data", []) or []
            out.extend(items)
            skip += len(items)
            if not fetch_all or not items or skip >= (total or 0):
                break
        return {"total": total, "count": len(out), "data": out}

    # ================= READ wrappers (safe) =================
    def list_sites(self, **f):            return self._list("/v2/dm/listSites", extra=f or None)
    def list_devices(self, **f):          return self._list("/v2/dm/listDevices", extra=f or None)
    def list_accounts(self, **f):         return self._list("/v2/dm/listAccounts", extra=f or None)
    def list_device_groups(self, **f):    return self._list("/v2/dm/listDeviceGroups", extra=f or None)
    def list_device_configs(self, **f):   return self._list("/v2/dm/listDeviceConfigs", extra=f or None)
    def list_firmwares(self, **f):        return self._list("/v2/dm/listFirmwares", extra=f or None)
    def list_official_firmwares(self, **f): return self._list("/v2/dm/listOfficalFirmwares", extra=f or None)
    def list_alarms(self, **f):           return self._list("/v2/dm/listAlarms", extra=f or None)
    def list_oplogs(self, **f):           return self._list("/v2/dm/listOpLogs", extra=f or None)
    def models(self, **f):                return self.request("POST", "/v2/dm/models", body=(f or {}))
    def rps_list_servers(self, **f):      return self._list("/v2/rps/listServers", extra=f or None)
    def rps_list_devices(self, **f):      return self._list("/v2/rps/listDevices", extra=f or None)

    # ================= WRITE wrappers (gated by the CLI --confirm) =================
    def add_devices_by_mac(self, body):   return self.request("POST", "/v2/dm/addDevicesByMac", body=body)
    def add_devices(self, body):          return self.request("POST", "/v2/dm/addDevices", body=body)
    def del_devices(self, body):          return self.request("POST", "/v2/dm/delDevices", body=body)
    def device_reboot(self, body):        return self.request("POST", "/v2/dm/device/reboot", body=body)
    def device_reset(self, body):         return self.request("POST", "/v2/dm/device/reset", body=body)
    def rps_add_devices(self, body):      return self.request("POST", "/v2/rps/addDevices", body=body)
    def rps_delete_devices(self, body):   return self.request("POST", "/v2/rps/deleteDevices", body=body)
    # SIP-account create = the PBX glue: push a NetSapiens SIP cred onto a device.
    # POST /v2/dm/sipAccounts requires sipServer1, register name/user/password, mac, etc.
    def add_sip_account(self, body):      return self.request("POST", "/v2/dm/sipAccounts", body=body)
