#!/usr/bin/env python3
"""ALIS (Medtelligent assisted-living EHR) REST API client for the `alis` skill.

ALIS is a partner/App-Store integration API at https://api.alisonline.com. A
tenant (e.g. Cascades of Tucson) lives at <tenant>.alisonline.com but ALL API
traffic goes to the shared api.alisonline.com host, scoped by the logged-in
user's company + a communityId.

Auth (verified live 2026-06-29):
  POST /user/tokens  {"username":"<user>@<tenantKey>","password":"..."}
    -> {accessToken (JWT, ~1h), expiresIn, refreshToken}
  Send  Authorization: Bearer <accessToken>  on every call.
  Refresh: POST /user/tokens/refresh {accessToken, refreshToken}.
  CRITICAL: the username MUST be tenant-qualified (e.g. howard.enos@cascadestucson)
  or /user/tokens returns 400. The bare login name alone fails.
  Global API security is OR(Bearer | BasicAuth | VendorKey) - a user JWT alone
  authorizes the integration reads we use.

Scope reality: this API is READ-ONLY for staff (only GET endpoints exist - no
create/update/delete). Staff are CHANGED via the ALIS web-UI bulk import (see
import_builder.py). This client's job is to READ current staff so new staff can
be set up the same way (the job-role -> security-role reference map).

Credentials load from the SOPS vault; env overrides exist for testing.
Transport: prefers httpx if installed, else stdlib urllib (no hard dependency).
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Optional

try:
    import httpx  # type: ignore

    _HAS_HTTPX = True
except ImportError:  # pragma: no cover - depends on environment
    _HAS_HTTPX = False

ERROR_BODY_MAX_CHARS = 500

ALIS_BASE_URL = os.environ.get("ALIS_BASE_URL", "https://api.alisonline.com")
TIMEOUT_SECONDS = 60.0
CONNECT_TIMEOUT_SECONDS = 10.0

# Cascades of Tucson is the only tenant this credential sees today.
DEFAULT_COMMUNITY_ID = int(os.environ.get("ALIS_COMMUNITY_ID", "622"))

VAULT_ENTRY = "clients/cascades-tucson/alis-api-howard-user.sops.yaml"
VAULT_USERNAME_FIELD = "credentials.username"
VAULT_PASSWORD_FIELD = "credentials.password"

SKILL_DIR = Path(__file__).resolve().parent.parent


class ALISError(RuntimeError):
    """Raised for transport or API errors."""


# --- credential loading -------------------------------------------------------
def _resolve_claudetools_root() -> Path:
    derived_root = SKILL_DIR.parent.parent.parent  # skills/alis -> repo root
    env_root = os.environ.get("CLAUDETOOLS_ROOT")
    if env_root:
        return Path(env_root)
    identity_path = derived_root / ".claude" / "identity.json"
    if identity_path.exists():
        try:
            data = json.loads(identity_path.read_text(encoding="utf-8"))
            root = data.get("claudetools_root")
            if root:
                return Path(root)
        except (json.JSONDecodeError, OSError):
            pass
    return derived_root


def _vault_get(field: str) -> str:
    root = _resolve_claudetools_root()
    vault_script = root / ".claude" / "scripts" / "vault.sh"
    if not vault_script.exists():
        raise ALISError(f"vault wrapper not found at {vault_script}")
    try:
        completed = subprocess.run(
            ["bash", str(vault_script), "get-field", VAULT_ENTRY, field],
            capture_output=True, text=True, timeout=60,
        )
    except FileNotFoundError as exc:
        raise ALISError("'bash' not found on PATH; install Git Bash.") from exc
    except subprocess.TimeoutExpired as exc:
        raise ALISError("vault call timed out.") from exc
    if completed.returncode != 0:
        raise ALISError(
            f"vault read failed for {field} (exit {completed.returncode}): "
            f"{completed.stderr.strip()}"
        )
    val = completed.stdout.strip()
    if not val:
        raise ALISError(f"vault returned empty value for {field}.")
    return val


def load_credentials() -> tuple[str, str]:
    """Return (username, password). Env overrides ALIS_USERNAME/ALIS_PASSWORD,
    else the SOPS vault. Username must already be tenant-qualified."""
    user = os.environ.get("ALIS_USERNAME")
    pw = os.environ.get("ALIS_PASSWORD")
    if not user:
        user = _vault_get(VAULT_USERNAME_FIELD)
    if not pw:
        pw = _vault_get(VAULT_PASSWORD_FIELD)
    return user, pw


# --- client -------------------------------------------------------------------
class ALISClient:
    def __init__(
        self,
        username: Optional[str] = None,
        password: Optional[str] = None,
        api_base_url: str = ALIS_BASE_URL,
        community_id: int = DEFAULT_COMMUNITY_ID,
        timeout: float = TIMEOUT_SECONDS,
        connect_timeout: float = CONNECT_TIMEOUT_SECONDS,
    ):
        self.api_base_url = api_base_url.rstrip("/")
        self.community_id = community_id
        self._username = username
        self._password = password
        self._access_token: Optional[str] = None
        self._refresh_token: Optional[str] = None
        self.timeout = timeout
        self.connect_timeout = connect_timeout

    # -- auth ------------------------------------------------------------------
    def _ensure_credentials(self) -> None:
        if not self._username or not self._password:
            self._username, self._password = load_credentials()

    @property
    def access_token(self) -> str:
        if not self._access_token:
            self.authenticate()
        return self._access_token  # type: ignore[return-value]

    def authenticate(self) -> None:
        """Mint a fresh JWT via POST /user/tokens."""
        self._ensure_credentials()
        body = {"username": self._username, "password": self._password}
        url = f"{self.api_base_url}/user/tokens"
        data = json.dumps(body).encode("utf-8")
        result = self._send("POST", url, data, with_auth=False)
        if not isinstance(result, dict) or "accessToken" not in result:
            raise ALISError(f"Unexpected token response: {str(result)[:200]}")
        self._access_token = result["accessToken"]
        self._refresh_token = result.get("refreshToken")

    # -- core transport --------------------------------------------------------
    def _request(self, method: str, path: str,
                 params: Optional[dict] = None,
                 body: Optional[dict] = None) -> Any:
        url = f"{self.api_base_url}/{path.lstrip('/')}"
        if params:
            url = f"{url}?{urllib.parse.urlencode(params)}"
        data = json.dumps(body).encode("utf-8") if body is not None else None
        return self._send(method, url, data, with_auth=True)

    def _send(self, method: str, url: str, data: Optional[bytes],
              with_auth: bool) -> Any:
        headers = {"Content-Type": "application/json", "Accept": "application/json"}
        if with_auth:
            headers["Authorization"] = f"Bearer {self.access_token}"
        if _HAS_HTTPX:
            try:
                timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
                with httpx.Client(timeout=timeout) as client:
                    resp = client.request(method, url, content=data, headers=headers)
                resp.raise_for_status()
                return resp.json() if resp.content else None
            except httpx.TimeoutException as exc:
                raise ALISError(f"ALIS request timed out: {exc}") from exc
            except httpx.HTTPStatusError as exc:
                detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
                raise ALISError(
                    f"ALIS HTTP {exc.response.status_code} [{method} {url}]: {detail}"
                ) from exc
            except httpx.HTTPError as exc:
                raise ALISError(f"ALIS request failed: {exc}") from exc

        req = urllib.request.Request(url, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                raw = resp.read()
            return json.loads(raw.decode("utf-8")) if raw else None
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
            raise ALISError(f"ALIS HTTP {exc.code} [{method} {url}]: {detail}") from exc
        except urllib.error.URLError as exc:
            raise ALISError(f"ALIS request failed: {exc}") from exc

    # ======================================================================
    # READ METHODS (this API is read-only for staff)
    # ======================================================================
    def list_communities(self) -> list[dict]:
        """Communities the logged-in user can see. VERIFIED LIVE."""
        return self._request("GET", "/v1/integration/communities") or []

    def list_staff(self, community_id: Optional[int] = None,
                   status: Optional[str] = None,
                   include_associated: bool = False) -> list[dict]:
        """Staff roster for a community. VERIFIED LIVE.
        communityId is REQUIRED - omitting it 403s 'Not authorized for facility 0'.
        Each record: staffId, firstName, lastName, staffRecordNumber, primaryEmail,
        mobilePhoneNumber, dateOfBirth, status, hireDate, dischargeDate, jobRole,
        securityRoles (list)."""
        params: dict = {"communityId": community_id or self.community_id}
        if status:
            params["status"] = status
        if include_associated:
            params["includeAssociatedStaff"] = "true"
        res = self._request("GET", "/v1/integration/staff", params=params)
        return res if isinstance(res, list) else (res or {}).get("data", []) or []

    def get_staff(self, staff_id: int) -> dict:
        """One staff member's full record. VERIFIED LIVE."""
        return self._request("GET", f"/v1/integration/staff/{staff_id}") or {}

    def get_staff_basic_info(self, staff_id: int) -> dict:
        """Staff basicInfo: address, license, jobRole, securityRoles, etc."""
        return self._request(
            "GET", f"/v1/integration/staff/{staff_id}/basicInfo") or {}

    # -- derived reference model -----------------------------------------------
    def build_role_map(self, community_id: Optional[int] = None) -> dict:
        """Derive the setup-reference from LIVE staff: the security-role and
        job-role vocabularies actually in use, and a job-role -> security-role(s)
        map learned from HIRED staff. This is how a new hire of a given job role
        should be configured to match existing staff."""
        staff = self.list_staff(community_id=community_id)
        hired = [s for s in staff if s.get("status") == "Hired"]
        sr_all: Counter = Counter()
        jr_all: Counter = Counter()
        jr_to_sr: dict[str, Counter] = defaultdict(Counter)
        for s in staff:
            sr = s.get("securityRoles") or []
            if isinstance(sr, str):
                sr = [sr]
            for x in sr:
                sr_all[x] += 1
            jr = (s.get("jobRole") or "").strip()
            if jr:
                jr_all[jr] += 1
        for s in hired:
            jr = (s.get("jobRole") or "").strip()
            if not jr:
                continue
            sr = s.get("securityRoles") or []
            if isinstance(sr, str):
                sr = [sr]
            for x in sr:
                jr_to_sr[jr][x] += 1
        return {
            "community": {"id": community_id or self.community_id},
            "sourceStaffCount": len(staff),
            "hiredCount": len(hired),
            "securityRoleVocabulary": sorted(sr_all),
            "jobRoleVocabulary": sorted(jr_all),
            "jobRoleToSecurityRoles": {
                jr: [r for r, _ in c.most_common()] for jr, c in jr_to_sr.items()
            },
        }

    # ======================================================================
    # POWER TOOL
    # ======================================================================
    def raw(self, method: str, path: str,
            params: Optional[dict] = None, body: Optional[dict] = None) -> Any:
        """Call any endpoint directly (read-only API - no staff writes exist)."""
        return self._request(method.upper(), path, params=params, body=body)


def main() -> int:
    """Self-check: mint a token (live) and confirm community scope."""
    try:
        client = ALISClient()
        client.authenticate()
        print("[OK] authenticated; transport =",
              "httpx" if _HAS_HTTPX else "urllib")
        comms = client.list_communities()
        print("[INFO] base =", client.api_base_url)
        print("[INFO] communities:",
              [(c.get("communityId"), c.get("communityName")) for c in comms])
        return 0
    except ALISError as exc:
        print(f"[ERROR] {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
