#!/usr/bin/env python3
"""
List Atlassian Cloud workspaces (product instances: Jira, Confluence, etc.) for your org via the
Organization Admin API, and write data/atlassian-apps.json for the dashboard.

Requires an Organization Admin API key (not a normal Jira user API token):
  https://admin.atlassian.com → your org → Settings → API keys (or Security → API keys)

Also set ATLASSIAN_ORG_ID from the admin URL: https://admin.atlassian.com/o/<ORG_ID>/...

API: POST https://api.atlassian.com/admin/v2/orgs/{orgId}/workspaces
     Scope on the key: read:workspaces:admin
Docs: https://developer.atlassian.com/cloud/admin/organization/rest/api-group-workspaces/

Plan names come from embedded relationship entitlements (same data admin.atlassian.com uses).

User counts: the org workspaces API usually omits `usage`. When JIRA_EMAIL and JIRA_API_TOKEN are set
(the same vars as fetch_jira.py), this script calls Jira `GET /rest/api/3/applicationrole` once for the site
given by `JIRA_BASE_URL` (default https://ilabs.atlassian.net, same default as fetch_jira.py).
`GET /rest/api/3/applicationrole` once and maps each workspace `typeKey` to the matching role’s
`userCount` (Jira Software, JSM, JPD). Bundled site products (Confluence, Goals, Projects, Assets,
Jira administration) use the Jira Software count so the table matches admin.atlassian.com. Loom,
Rovo, etc. stay "—" unless the workspace payload includes `usage`.

Sandbox rows (separate sandbox sites / CHILD sandboxes) are omitted by default so the snapshot
matches admin.atlassian.com “production” lists. Set ATLASSIAN_APPS_INCLUDE_SANDBOX=1 to keep them.
"""
from __future__ import annotations

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

import requests

from store import write_json_snapshot

from env_config import jira_base_url
from paths import DATA_DIR, REPO_ROOT, load_env_file

ROOT = REPO_ROOT
OUT = Path(os.environ.get("ATLASSIAN_APPS_OUT", str(DATA_DIR / "atlassian-apps.json")))
API_BASE = "https://api.atlassian.com/admin/v2"

# Friendly labels when the API does not set attributes.type (common typeKey values).
# Map workspace typeKey → Jira applicationrole key for userCount (see /rest/api/3/applicationrole).
TYPEKEY_TO_JIRA_APP_ROLE: dict[str, str] = {
    "jira-software": "jira-software",
    "jira-servicedesk": "jira-servicedesk",
    "jira-product-discovery": "jira-product-discovery",
    # Bundled on the Jira site — admin “users” column tracks JSW seats for these.
    "jira": "jira-software",
    "confluence": "jira-software",
    "goal": "jira-software",
    "project": "jira-software",
    # Jira Assets (CMDB) — no separate applicationrole; count matches JPD in typical setups.
    "cmdb": "jira-product-discovery",
}

TYPE_KEY_PRETTY: dict[str, str] = {
    "cmdb": "Assets",
    "confluence": "Confluence",
    "goal": "Goals",
    "jira": "Jira",
    "jira-product-discovery": "Jira Product Discovery",
    "jira-servicedesk": "Jira Service Management",
    "jira-software": "Jira Software",
    "loom": "Loom",
    "project": "Projects",
    "rovo": "Rovo",
}


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 = val.strip().strip('"').strip("'")
        if not key:
            continue
        os.environ[key] = val


def pretty_type_key(type_key: str) -> str:
    tk = type_key.strip()
    if tk in TYPE_KEY_PRETTY:
        return TYPE_KEY_PRETTY[tk]
    if not tk:
        return "—"
    return tk.replace("-", " ").title()


def app_display(attrs: dict[str, Any], type_key: str) -> str:
    t = str(attrs.get("type") or "").strip()
    if t:
        return t
    return pretty_type_key(type_key)


def plan_display(attrs: dict[str, Any]) -> str:
    for k in ("planName", "plan", "edition", "offerEdition", "billingPlan", "tier"):
        v = attrs.get(k)
        if v is not None and str(v).strip():
            return str(v).strip()
    return "—"


def format_plan_key(plan_key: str) -> str:
    pk = plan_key.strip()
    if not pk or pk.lower() == "none":
        return ""
    if pk.islower():
        return pk.replace("-", " ").title()
    return pk


def plan_from_item(item: dict[str, Any]) -> str:
    rel = item.get("relationships")
    if isinstance(rel, dict):
        rfe = rel.get("relatesFromEntitlement")
        found: list[str] = []
        if isinstance(rfe, list):
            for block in rfe:
                if not isinstance(block, dict):
                    continue
                inner = (block.get("attributes") or {}).get("entitlement")
                if not isinstance(inner, dict):
                    continue
                off = inner.get("offering")
                if not isinstance(off, dict):
                    continue
                listing = off.get("productListing")
                ln = (
                    str((listing or {}).get("name") or "").strip()
                    if isinstance(listing, dict)
                    else ""
                )
                on = str(off.get("name") or "").strip()
                if ln and on:
                    label = f"{ln} {on}"
                elif on:
                    label = on
                elif ln:
                    label = ln
                else:
                    continue
                if label not in found:
                    found.append(label)
        if found:
            return found[0] if len(found) == 1 else " · ".join(found)
        ents = rel.get("entitlement")
        if isinstance(ents, list):
            for ent in ents:
                attr = ent.get("attributes") if isinstance(ent, dict) else None
                if not isinstance(attr, dict):
                    continue
                pl = str(attr.get("plan") or "").strip()
                pk = str(attr.get("planKey") or "").strip()
                if pl and pl.lower() != "none":
                    return pl
                if pk and pk.lower() != "none":
                    fmt = format_plan_key(pk)
                    if fmt:
                        return fmt
    attrs = item.get("attributes")
    if isinstance(attrs, dict):
        return plan_display(attrs)
    return "—"


def users_display(attrs: dict[str, Any]) -> str:
    u = attrs.get("usage")
    if u is None:
        return "—"
    try:
        if isinstance(u, bool):
            return "—"
        if isinstance(u, int):
            return str(u)
        if isinstance(u, float):
            return str(int(u))
        s = str(u).strip()
        return s if s else "—"
    except (TypeError, ValueError, AttributeError):
        return "—"


def normalize_site_root(url: str) -> str:
    u = (url or "").strip()
    if not u:
        return ""
    if not u.startswith(("http://", "https://")):
        u = "https://" + u
    p = urlparse(u)
    if not p.netloc:
        return ""
    scheme = p.scheme or "https"
    return f"{scheme}://{p.netloc.lower()}"


def fetch_jira_application_role_counts(base: str, email: str, api_token: str) -> dict[str, int]:
    """role key -> licensed userCount from Jira Cloud (same numbers as admin product table)."""
    root = normalize_site_root(base)
    if not root:
        return {}
    s = requests.Session()
    s.auth = (email, api_token)
    s.headers["Accept"] = "application/json"
    try:
        r = s.get(f"{root}/rest/api/3/applicationrole", timeout=120)
    except requests.RequestException:
        return {}
    if not r.ok:
        return {}
    data = r.json()
    if not isinstance(data, list):
        return {}
    out: dict[str, int] = {}
    for role in data:
        if not isinstance(role, dict):
            continue
        k = str(role.get("key") or "").strip()
        if not k:
            continue
        try:
            out[k] = int(role.get("userCount") or 0)
        except (TypeError, ValueError):
            out[k] = 0
    return out


def resolve_jira_role_counts(items: list[dict[str, Any]]) -> dict[str, int]:
    """Load application roles once if any row is on the JIRA site host."""
    jira_base = normalize_site_root(
        jira_base_url()
    )
    email = (os.environ.get("JIRA_EMAIL") or "").strip()
    token = (os.environ.get("JIRA_API_TOKEN") or "").strip()
    if not (jira_base and email and token):
        return {}
    for item in items:
        attrs = item.get("attributes")
        if not isinstance(attrs, dict):
            continue
        host_root = normalize_site_root(str(attrs.get("hostUrl") or ""))
        if host_root == jira_base:
            return fetch_jira_application_role_counts(jira_base, email, token)
    return {}


def users_for_row(
    type_key: str, attrs: dict[str, Any], role_counts: dict[str, int]
) -> str:
    top = users_display(attrs)
    if top != "—":
        return top
    role_key = TYPEKEY_TO_JIRA_APP_ROLE.get(type_key.strip())
    if role_key and role_key in role_counts:
        return str(role_counts[role_key])
    return "—"


def workspace_is_sandbox(attrs: dict[str, Any]) -> bool:
    sb = attrs.get("sandbox")
    if isinstance(sb, dict) and sb:
        return True
    labels = attrs.get("labels")
    if isinstance(labels, list):
        for lab in labels:
            if isinstance(lab, str) and "sandbox" in lab.lower():
                return True
    host = str(attrs.get("hostUrl") or "").lower()
    site = str(attrs.get("name") or "").lower()
    if "sandbox" in host or "-sandbox-" in site:
        return True
    return False


def map_workspace_row(
    item: dict[str, Any], *, role_counts: dict[str, int]
) -> dict[str, str] | None:
    if not isinstance(item, dict):
        return None
    attrs = item.get("attributes")
    if not isinstance(attrs, dict):
        return None
    type_key = str(attrs.get("typeKey") or attrs.get("type") or "").strip() or "—"
    host = str(attrs.get("hostUrl") or "").strip()
    status = str(attrs.get("status") or "").strip() or "—"
    site_name = str(attrs.get("name") or "").strip() or "—"
    return {
        "site": site_name,
        "name": site_name,
        "app": app_display(attrs, type_key),
        "typeKey": type_key,
        "plan": plan_from_item(item),
        "users": users_for_row(type_key, attrs, role_counts),
        "hostUrl": host,
        "status": status,
        "url": host,
    }


def fetch_workspace_items(
    session: requests.Session, org_id: str, *, include_sandbox: bool
) -> tuple[list[dict[str, Any]], str | None, int, int]:
    """Returns (raw workspace JSON:API items after sandbox filter, err, seen, excluded)."""
    url = f"{API_BASE}/orgs/{org_id}/workspaces"
    items: list[dict[str, Any]] = []
    cursor: str | None = None
    next_get_url: str | None = None
    pages = 0
    err: str | None = None
    seen = 0
    excluded = 0

    while pages < 40:
        pages += 1
        try:
            if next_get_url:
                r = session.get(next_get_url.strip(), timeout=120)
            else:
                body: dict[str, Any] = {"limit": 50}
                if cursor:
                    body["cursor"] = cursor
                r = session.post(url, json=body, timeout=120)
        except requests.RequestException as ex:
            return items, str(ex)[:400], seen, excluded

        if not r.ok:
            return items, f"HTTP {r.status_code}: {r.text[:500]}", seen, excluded

        try:
            payload = r.json()
        except ValueError:
            return items, "Invalid JSON from workspaces API", seen, excluded

        if not isinstance(payload, dict):
            return items, "Unexpected workspaces response", seen, excluded

        for item in payload.get("data") or []:
            if not isinstance(item, dict):
                continue
            attrs = item.get("attributes")
            if not isinstance(attrs, dict):
                continue
            seen += 1
            if not include_sandbox and workspace_is_sandbox(attrs):
                excluded += 1
                continue
            items.append(item)

        links = payload.get("links")
        nxt = None
        if isinstance(links, dict):
            nxt = links.get("next")

        if not nxt or not str(nxt).strip():
            break

        nxt = str(nxt).strip()
        if nxt.startswith("http://") or nxt.startswith("https://"):
            next_get_url = nxt
            cursor = None
        else:
            next_get_url = None
            cursor = nxt

    return items, err, seen, excluded


def main() -> int:
    load_env_file()
    org_id = (os.environ.get("ATLASSIAN_ORG_ID") or "").strip()
    token = (os.environ.get("ATLASSIAN_ORG_ADMIN_TOKEN") or "").strip()

    if not org_id or not token:
        missing = []
        if not org_id:
            missing.append("ATLASSIAN_ORG_ID")
        if not token:
            missing.append("ATLASSIAN_ORG_ADMIN_TOKEN")
        print(
            "Missing or empty in .env: "
            + ", ".join(missing)
            + ". See env.example (Organization Admin API key, scope read:workspaces:admin).",
            file=sys.stderr,
        )
        return 1

    include_sandbox = (os.environ.get("ATLASSIAN_APPS_INCLUDE_SANDBOX") or "").strip().lower() in (
        "1",
        "true",
        "yes",
    )

    session = requests.Session()
    session.headers.update(
        {
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        }
    )

    raw_items, err, seen, excluded = fetch_workspace_items(
        session, org_id, include_sandbox=include_sandbox
    )
    if err and "401" in err:
        print(
            "[hint] Organization Admin API returned 401 — Atlassian rejected ATLASSIAN_ORG_ADMIN_TOKEN.\n"
            "  • admin.atlassian.com → your organization → Settings → API keys → create key with scope read:workspaces:admin.\n"
            "  • Use that secret in .env as ATLASSIAN_ORG_ADMIN_TOKEN (not JIRA_API_TOKEN).\n"
            "  • If the key was rotated or restricted, create a new key and update .env.\n"
            "  Docs: https://developer.atlassian.com/cloud/admin/organization/rest/api-group-workspaces/",
            file=sys.stderr,
        )
    role_counts = resolve_jira_role_counts(raw_items)
    apps: list[dict[str, str]] = []
    for item in raw_items:
        row = map_workspace_row(item, role_counts=role_counts)
        if row:
            apps.append(row)

    payload: dict[str, Any] = {
        "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "orgId": org_id,
        "appsTotal": len(apps),
        "workspacesFetchedTotal": seen,
        "sandboxesExcluded": excluded,
        "atlassianAppsError": err,
        "apps": apps,
    }

    out_path = write_json_snapshot(OUT, payload)
    print(f"Wrote {out_path}")
    msg = f"Wrote {OUT} ({len(apps)} workspace(s)"
    if excluded:
        msg += f", {excluded} sandbox(es) omitted"
    msg += ")"
    print(msg)
    return 1 if err else 0


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