#!/usr/bin/env python3
"""
Adobe Admin Console users + product profile assignments → data/adobe.json for the IT dashboard.

Uses Adobe User Management API (UMAPI) with OAuth Server-to-Server credentials from the
Adobe Developer Console (User Management API integration).

Env:
  ADOBE_CLIENT_ID          — API key (Client ID) from Developer Console
  ADOBE_CLIENT_SECRET      — Client secret
  ADOBE_ORG_ID             — Organization id, e.g. A495E53@AdobeOrg (Admin Console URL or integration page)
  ADOBE_ADMIN_URL          — default https://adminconsole.adobe.com
  ADOBE_IMS_URL            — default https://ims-na1.adobelogin.com
  ADOBE_UMAPI_BASE         — default https://usermanagement.adobe.io/v2/usermanagement
  ADOBE_OUT                — default data/adobe.json (or MariaDB feed snapshot)
  ADOBE_USERS_MAX          — max user rows stored (default 5000; max 20000)
  ADOBE_SCOPE              — optional OAuth scope override

Setup:
  1. Adobe Developer Console → Create project → Add User Management API credential (OAuth S2S).
  2. Grant the credential access to your organization in Admin Console → Adobe Products → API credentials.
  3. cp env.example .env and set ADOBE_CLIENT_ID, ADOBE_CLIENT_SECRET, ADOBE_ORG_ID.
  4. python3 app/src/jobs/fetch_adobe.py
"""
from __future__ import annotations

import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote

import requests

from store import json_snapshot_exists, read_json_snapshot, write_json_snapshot

from env_config import env_csv, env_float, env_int, env_str
from paths import DATA_DIR, REPO_ROOT, load_env_file

ROOT = REPO_ROOT
OUT = Path(os.environ.get("ADOBE_OUT", str(DATA_DIR / "adobe.json")))


class AdobeRateLimitError(RuntimeError):
    """UMAPI returned 429 after retries — keep last good snapshot, no adobeError."""


def _strip_env_value(val: str) -> str:
    val = val.strip().strip('"').strip("'")
    if not val:
        return ""
    # Allow trailing comments: ADOBE_ORG_ID=ABC@AdobeOrg  # note
    hash_at = val.find(" #")
    if hash_at >= 0:
        val = val[:hash_at].strip()
    return val


def load_env_file() -> None:
    path = REPO_ROOT / ".env"
    if not path.is_file():
        return
    for raw in path.read_text(encoding="utf-8-sig").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[7:].strip()
        if "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip()
        val = _strip_env_value(val)
        if not key:
            continue
        if val:
            os.environ[key] = val
            continue
        prev = os.environ.get(key)
        if prev is None or not str(prev).strip():
            os.environ[key] = val


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _users_max() -> int:
    return max(1, min(env_int("ADOBE_USERS_MAX"), 20_000))


def _rate_limit_retries() -> int:
    return max(0, min(env_int("ADOBE_RATE_LIMIT_RETRIES"), 12))


def _page_interval_sec() -> float:
    return max(1.0, min(env_float("ADOBE_PAGE_INTERVAL_SEC"), 30.0))


def _retry_after_max_sec() -> float:
    """Max seconds to wait on a single 429 before deferring to the next refresh cycle."""
    try:
        return max(5.0, min(float(os.environ.get("ADOBE_RETRY_AFTER_MAX_SEC", "60") or "60"), 300.0))
    except ValueError:
        return 60.0


def _load_previous_payload() -> dict[str, Any] | None:
    if not json_snapshot_exists(OUT):
        return None
    try:
        data = read_json_snapshot(OUT)
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict):
        return None
    users = data.get("users")
    if not isinstance(users, list) or not users:
        return None
    return data


def _admin_url() -> str:
    raw = env_str("ADOBE_ADMIN_URL").rstrip("/")
    if raw and not raw.startswith("http"):
        raw = "https://" + raw
    return raw


def _admin_group_prefixes() -> tuple[str, ...]:
    return env_csv("ADOBE_ADMIN_GROUP_PREFIXES")


def _is_admin_group_name(name: str) -> bool:
    lowered = name.lower()
    return any(lowered.startswith(prefix) for prefix in _admin_group_prefixes())


def _product_names_preview(products: list[dict[str, Any]], limit: int = 6) -> str:
    names: list[str] = []
    for row in products:
        if not isinstance(row, dict):
            continue
        label = str(row.get("productName") or row.get("profileName") or "").strip()
        if label and label not in names:
            names.append(label)
        if len(names) >= limit:
            break
    extra = len(products) - len(names)
    if extra > 0 and len(names) >= limit:
        return "; ".join(names) + f" (+{extra} more)"
    return "; ".join(names)


def _user_display_name(user: dict[str, Any]) -> str:
    first = str(user.get("firstname") or user.get("firstName") or "").strip()
    last = str(user.get("lastname") or user.get("lastName") or "").strip()
    full = f"{first} {last}".strip()
    if full:
        return full
    username = str(user.get("username") or "").strip()
    if username:
        return username
    return str(user.get("email") or "").strip() or "(no name)"


def _user_row(
    user: dict[str, Any],
    profiles_by_name: dict[str, dict[str, Any]],
    *,
    groups_available: bool = True,
) -> dict[str, Any]:
    raw_groups = user.get("groups") if isinstance(user.get("groups"), list) else []
    group_names = [str(g).strip() for g in raw_groups if g is not None and str(g).strip()]
    products: list[dict[str, str]] = []
    for group_name in group_names:
        if _is_admin_group_name(group_name):
            continue
        profile = profiles_by_name.get(group_name)
        if profile is None:
            # Groups metadata unavailable (e.g. rate limited): best-effort fallback
            # treats each non-admin membership as a product profile.
            if groups_available:
                continue
            products.append(
                {
                    "profileName": group_name,
                    "productName": group_name,
                    "licenseQuota": "",
                }
            )
            continue
        if str(profile.get("type") or "").upper() != "PRODUCT_PROFILE":
            continue
        products.append(
            {
                "profileName": group_name,
                "productName": str(profile.get("productName") or group_name).strip(),
                "licenseQuota": str(profile.get("licenseQuota") or "").strip(),
            }
        )
    products.sort(key=lambda row: (row.get("productName") or "").lower())
    email = str(user.get("email") or user.get("username") or "").strip()
    return {
        "id": str(user.get("id") or email or "").strip(),
        "email": email,
        "name": _user_display_name(user),
        "status": str(user.get("status") or "").strip(),
        "type": str(user.get("type") or user.get("userType") or "").strip(),
        "country": str(user.get("country") or "").strip(),
        "domain": str(user.get("domain") or "").strip(),
        "productCount": len(products),
        "productSummary": _product_names_preview(products),
        "products": products,
    }


def _profile_row(group: dict[str, Any]) -> dict[str, Any]:
    group_name = str(group.get("groupName") or "").strip()
    return {
        "profileName": group_name,
        "productName": str(group.get("productName") or group_name).strip(),
        "type": str(group.get("type") or "").strip(),
        "memberCount": group.get("memberCount"),
        "licenseQuota": str(group.get("licenseQuota") or "").strip(),
        "adminGroupName": str(group.get("adminGroupName") or "").strip(),
    }


class AdobeUmapiClient:
    def __init__(
        self,
        *,
        client_id: str,
        client_secret: str,
        org_id: str,
        ims_url: str,
        api_base: str,
        scope: str,
    ) -> None:
        self.client_id = client_id
        self.client_secret = client_secret
        self.org_id = org_id
        self.ims_url = ims_url.rstrip("/")
        self.api_base = api_base.rstrip("/")
        self.scope = scope
        self._token = ""
        self._token_expires_at = 0.0

    def _ensure_token(self) -> str:
        if self._token and time.time() < self._token_expires_at - 60:
            return self._token
        url = f"{self.ims_url}/ims/token/v3"
        data = {
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "grant_type": "client_credentials",
            "scope": self.scope,
        }
        r = requests.post(url, data=data, timeout=60)
        if not r.ok:
            raise RuntimeError(f"Adobe IMS token: {r.status_code} {r.text[:500]}")
        payload = r.json()
        token = str(payload.get("access_token") or "").strip()
        if not token:
            raise RuntimeError("Adobe IMS token response missing access_token")
        expires_in = payload.get("expires_in")
        try:
            ttl = float(expires_in) if expires_in is not None else 3600.0
        except (TypeError, ValueError):
            ttl = 3600.0
        self._token = token
        self._token_expires_at = time.time() + ttl
        return token

    def get_json(
        self,
        path: str,
        *,
        min_interval_sec: float = 0.0,
        max_retries: int | None = None,
    ) -> dict[str, Any]:
        retries = _rate_limit_retries() if max_retries is None else max(0, max_retries)
        for attempt in range(retries + 1):
            if min_interval_sec > 0:
                time.sleep(min_interval_sec)
            token = self._ensure_token()
            url = f"{self.api_base}/{path.lstrip('/')}"
            headers = {
                "Authorization": f"Bearer {token}",
                "X-Api-Key": self.client_id,
                "Accept": "application/json",
            }
            r = requests.get(url, headers=headers, timeout=120)
            if r.status_code == 429:
                retry_after_hdr = str(r.headers.get("Retry-After") or "").strip()
                retry_after_max = _retry_after_max_sec()
                if retry_after_hdr.isdigit():
                    wait = float(retry_after_hdr)
                else:
                    wait = min(retry_after_max, 8.0 * (2**attempt))
                if wait > retry_after_max:
                    raise AdobeRateLimitError(
                        f"Adobe UMAPI GET {path}: rate limited (429); "
                        f"Retry-After {wait:.0f}s exceeds cap ({retry_after_max:.0f}s)"
                    )
                if attempt >= retries:
                    raise AdobeRateLimitError(f"Adobe UMAPI GET {path}: rate limited (429)")
                print(
                    f"[info] Adobe UMAPI rate limited on {path}; retry {attempt + 1}/{retries} "
                    f"in {wait:.0f}s",
                    file=sys.stderr,
                )
                time.sleep(wait)
                continue
            if not r.ok:
                raise RuntimeError(f"Adobe UMAPI GET {path}: {r.status_code} {r.text[:500]}")
            data = r.json()
            if not isinstance(data, dict):
                raise RuntimeError(f"Adobe UMAPI GET {path}: expected JSON object")
            result = str(data.get("result") or "").strip().lower()
            if result and result not in {"success", "ok"}:
                message = str(data.get("message") or data).strip()
                raise RuntimeError(f"Adobe UMAPI GET {path}: {result} — {message[:300]}")
            return data
        raise AdobeRateLimitError(f"Adobe UMAPI GET {path}: rate limited (429)")

    def paginate_groups(self) -> list[dict[str, Any]]:
        rows: list[dict[str, Any]] = []
        page = 0
        interval = _page_interval_sec()
        while True:
            data = self.get_json(
                f"groups/{quote(self.org_id, safe='')}/{page}",
                min_interval_sec=interval,
            )
            chunk = data.get("groups")
            if isinstance(chunk, list):
                rows.extend(item for item in chunk if isinstance(item, dict))
            if data.get("lastPage") is True:
                break
            page += 1
        return rows

    def paginate_users(self) -> list[dict[str, Any]]:
        rows: list[dict[str, Any]] = []
        page = 0
        interval = _page_interval_sec()
        while True:
            data = self.get_json(
                f"users/{quote(self.org_id, safe='')}/{page}",
                min_interval_sec=interval,
            )
            chunk = data.get("users")
            if isinstance(chunk, list):
                rows.extend(item for item in chunk if isinstance(item, dict))
            if data.get("lastPage") is True:
                break
            page += 1
        return rows


def main() -> int:
    load_env_file()
    client_id = os.environ.get("ADOBE_CLIENT_ID", "").strip()
    client_secret = os.environ.get("ADOBE_CLIENT_SECRET", "").strip()
    org_id = os.environ.get("ADOBE_ORG_ID", "").strip()
    missing = [
        name
        for name, val in (
            ("ADOBE_CLIENT_ID", client_id),
            ("ADOBE_CLIENT_SECRET", client_secret),
            ("ADOBE_ORG_ID", org_id),
        )
        if not val
    ]
    if missing:
        print(
            "Missing or empty in .env: " + ", ".join(missing) + " — see env.example.",
            file=sys.stderr,
        )
        print(
            f"Expected .env at {REPO_ROOT / '.env'} (save the file in your editor if you just added these lines).",
            file=sys.stderr,
        )
        return 1
    if client_secret.startswith("eyJ"):
        print(
            "[warn] ADOBE_CLIENT_SECRET looks like an access token (JWT), not the OAuth client secret. "
            "In Developer Console open your OAuth Server-to-Server credential and use Retrieve client secret.",
            file=sys.stderr,
        )
    if "@" not in org_id:
        print(
            "[warn] ADOBE_ORG_ID should look like A495E53@AdobeOrg (from Developer Console integration or Admin Console).",
            file=sys.stderr,
        )

    ims_url = env_str("ADOBE_IMS_URL")
    api_base = env_str("ADOBE_UMAPI_BASE")
    scope = env_str("ADOBE_SCOPE")
    users_max = _users_max()

    payload: dict[str, Any] = {
        "updatedAt": utc_now_iso(),
        "orgId": org_id,
        "adminUrl": _admin_url(),
        "usersTotal": None,
        "usersWithProducts": None,
        "usersWithoutProducts": None,
        "productProfilesTotal": None,
        "users": [],
        "productProfiles": [],
        "adobeError": None,
    }

    try:
        client = AdobeUmapiClient(
            client_id=client_id,
            client_secret=client_secret,
            org_id=org_id,
            ims_url=ims_url,
            api_base=api_base,
            scope=scope,
        )
        # Users are reliable; fetch first so a throttled groups endpoint
        # never blocks the whole export.
        raw_users = client.paginate_users()

        # Groups (product profile metadata) can be heavily rate limited.
        # Treat it as best-effort: on failure, derive products from each
        # user's group memberships instead of producing an empty dataset.
        profiles_by_name: dict[str, dict[str, Any]] = {}
        product_profiles: list[dict[str, Any]] = []
        groups_available = True
        try:
            raw_groups = client.paginate_groups()
            for group in raw_groups:
                group_name = str(group.get("groupName") or "").strip()
                if not group_name:
                    continue
                profiles_by_name[group_name] = group
                if str(group.get("type") or "").upper() == "PRODUCT_PROFILE":
                    product_profiles.append(_profile_row(group))
            product_profiles.sort(key=lambda row: (row.get("productName") or "").lower())
        except AdobeRateLimitError as ex:
            groups_available = False
            print(f"[info] Adobe groups skipped (rate limited): {ex}", file=sys.stderr)
        except Exception as ex:  # noqa: BLE001
            groups_available = False
            payload["adobeNote"] = (
                "Groups metadata skipped; products derived from user memberships."
            )
            print(f"[warn] Adobe groups skipped: {ex}", file=sys.stderr)

        users_total = len(raw_users)
        user_rows: list[dict[str, Any]] = []
        with_products = 0
        for raw_user in raw_users:
            if len(user_rows) >= users_max:
                break
            row = _user_row(raw_user, profiles_by_name, groups_available=groups_available)
            user_rows.append(row)
            if row["productCount"] > 0:
                with_products += 1
        user_rows.sort(key=lambda row: (row.get("email") or row.get("name") or "").lower())

        if not groups_available:
            seen_profiles: dict[str, dict[str, Any]] = {}
            for row in user_rows:
                for product in row.get("products") or []:
                    name = str(product.get("profileName") or "").strip()
                    if not name:
                        continue
                    entry = seen_profiles.setdefault(
                        name,
                        {
                            "profileName": name,
                            "productName": str(product.get("productName") or name).strip(),
                            "type": "PRODUCT_PROFILE",
                            "memberCount": 0,
                            "licenseQuota": "",
                            "adminGroupName": "",
                        },
                    )
                    entry["memberCount"] = int(entry.get("memberCount") or 0) + 1
            product_profiles = sorted(
                seen_profiles.values(), key=lambda r: (r.get("productName") or "").lower()
            )

        payload["usersTotal"] = users_total
        payload["usersWithProducts"] = with_products
        payload["usersWithoutProducts"] = max(users_total - with_products, 0)
        payload["productProfilesTotal"] = len(product_profiles)
        payload["users"] = user_rows
        payload["productProfiles"] = product_profiles
        if users_total > len(user_rows):
            payload["usersTruncated"] = True
            payload["adobeNote"] = (
                f"Stored {len(user_rows):,} of {users_total:,} users "
                f"(ADOBE_USERS_MAX={users_max}). Raise the cap to export more."
            )
    except AdobeRateLimitError as ex:
        prev = _load_previous_payload()
        if prev:
            payload = prev
            payload["updatedAt"] = utc_now_iso()
            payload.pop("adobeError", None)
            print(f"[info] Adobe rate limited; kept previous snapshot ({len(payload.get('users') or [])} users)", file=sys.stderr)
        else:
            payload.pop("adobeError", None)
            print(f"[info] Adobe rate limited; no prior snapshot to keep", file=sys.stderr)
    except Exception as ex:  # noqa: BLE001
        payload["adobeError"] = str(ex).strip()[:800]
        print(f"[warn] Adobe fetch failed: {ex}", file=sys.stderr)

    out_path = write_json_snapshot(OUT, payload)
    print(f"Wrote {out_path}", flush=True)
    return 0 if not payload.get("adobeError") else 1


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