#!/usr/bin/env python3
"""
Pull Jira Cloud projects into data/jira.json for the dashboard.

Uses GET /rest/api/3/project/search with expand=lead (project lead), then optionally one
POST /rest/api/3/search/jql per listed project for the most recently updated issue’s timestamp
(legacy /rest/api/3/search was removed — CHANGE-2046).

Auth: Basic (Atlassian email + API token).

Setup:
  1. Atlassian account → https://id.atlassian.com/manage-profile/security/api-tokens → Create API token.
  2. cp env.example .env  # set JIRA_EMAIL and JIRA_API_TOKEN
  3. Optional: JIRA_BASE_URL, JIRA_PROJECT_NAME_QUERY, JIRA_HIDE_PROJECT_KEYS, JIRA_SKIP_LAST_ISSUE,
     JIRA_ENRICH_THROTTLE_SEC, ATLASSIAN_ADMIN_APPS_URL (or ATLASIAN_ADMIN_APPS_URL), JIRA_OUT,
     JIRA_IT_OPS_PROJECT_KEY, JIRA_IT_OPS_QUEUE_URL, JIRA_IT_OPS_FILTER_ID,
     JIRA_IT_OPS_WORKLOAD_JQL, JIRA_IT_OPS_UNASSIGNED_JQL, JIRA_IT_OPS_FILTER_LABEL,
     JIRA_IT_OPS_ASSIGNEE_WORKLOAD, JIRA_IT_OPS_ASSIGNEE_SCAN_MAX (IT Ops)
  4. python3 app/src/jobs/fetch_jira.py
"""
from __future__ import annotations

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

import requests

from store import write_json_snapshot

from env_config import ConfigError, env_int, jira_base_url, jira_it_ops_queue_url
from paths import DATA_DIR, REPO_ROOT, load_env_file

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

PAGE_SIZE = 50

_PROJECT_TYPE_LABELS: dict[str, str] = {
    "software": "Software",
    "service_desk": "Service management",
    "business": "Business",
    "product_discovery": "Product discovery",
}


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 project_browse_url(site: str, project_key: str) -> str:
    return f"{site.rstrip('/')}/browse/{quote(project_key, safe='')}"


def project_type_label(project_type_key: str) -> str:
    k = (project_type_key or "").strip().lower()
    if k in _PROJECT_TYPE_LABELS:
        return _PROJECT_TYPE_LABELS[k]
    if not k:
        return "—"
    return k.replace("_", " ").title()


def projects_directory_url(site: str) -> str:
    return f"{site.rstrip('/')}/jira/projects"


def admin_apps_url_from_env() -> str:
    """Org admin apps deep link; common typo ATLASIAN (one s) also accepted."""
    for key in ("ATLASSIAN_ADMIN_APPS_URL", "ATLASIAN_ADMIN_APPS_URL"):
        v = (os.environ.get(key) or "").strip()
        if v:
            return v
    return ""


def parse_hide_project_keys() -> frozenset[str]:
    raw = (os.environ.get("JIRA_HIDE_PROJECT_KEYS") or "").strip()
    if not raw:
        return frozenset()
    out: set[str] = set()
    for part in raw.split(","):
        k = part.strip().upper()
        if k:
            out.add(k)
    return frozenset(out)


def _lead_from_project(p: dict) -> tuple[str, str | None]:
    lead = p.get("lead")
    if isinstance(lead, dict):
        name = str(lead.get("displayName") or "").strip()
        aid = lead.get("accountId")
        account_id = str(aid).strip() if aid is not None and str(aid).strip() else None
        return name, account_id
    return "", None


def _issue_updated_via_get(session: requests.Session, base: str, issue_key_or_id: str) -> str | None:
    """GET /issue/{keyOrId}?fields=updated when search returned a slim issue row."""
    ref = str(issue_key_or_id or "").strip()
    if not ref:
        return None
    u = f"{base.rstrip('/')}/rest/api/3/issue/{quote(ref, safe='')}"
    try:
        r = session.get(u, params={"fields": "updated"}, timeout=30)
        if not r.ok:
            return None
        data = r.json()
    except (requests.RequestException, ValueError, TypeError):
        return None
    if not isinstance(data, dict):
        return None
    fields = data.get("fields")
    if not isinstance(fields, dict):
        return None
    upd = fields.get("updated")
    if isinstance(upd, str) and upd.strip():
        return upd.strip()
    return None


def _issues_from_jql_search_payload(data: dict) -> list | None:
    """JQL search (/search/jql) returns issues under `issues` (and sometimes `values`)."""
    if not isinstance(data, dict):
        return None
    for k in ("issues", "values"):
        v = data.get(k)
        if isinstance(v, list) and v:
            return v
    return None


def _parse_updated_from_jql_search_response(data: dict) -> str | None:
    if not isinstance(data, dict):
        return None
    errs = data.get("errorMessages")
    if isinstance(errs, list) and errs:
        return None
    issues = _issues_from_jql_search_payload(data)
    if not issues:
        return None
    first = issues[0]
    if not isinstance(first, dict):
        return None
    fields = first.get("fields")
    if not isinstance(fields, dict):
        return None
    upd = fields.get("updated")
    if isinstance(upd, str) and upd.strip():
        return upd.strip()
    return None


_search_warned = False


def fetch_last_issue_updated_at(
    session: requests.Session,
    api_root: str,
    project_key: str,
    project_entity_id: str | int | None = None,
) -> str | None:
    """
    Most recently updated issue in the project (ISO 8601), or None if none / error.

    Uses POST /rest/api/3/search/jql (legacy /rest/api/3/search was removed per CHANGE-2046).
    """
    base = api_root.rstrip("/")
    url = f"{base}/rest/api/3/search/jql"
    key = str(project_key or "").strip()
    if not key:
        return None

    # Always quote the project key: unquoted keys that match JQL reserved words (e.g. AS, OR, AND) fail.
    key_esc = key.replace("\\", "\\\\").replace('"', '\\"')
    jql_variants: list[str] = [f'project = "{key_esc}" ORDER BY updated DESC']
    if project_entity_id is not None and str(project_entity_id).strip():
        pid = str(project_entity_id).strip()
        if pid.isdigit():
            jql_variants.append(f"project = {pid} ORDER BY updated DESC")

    def _try_payload(jql: str, body: dict) -> str | None:
        global _search_warned
        try:
            r = session.post(url, json=body, timeout=45)
        except (requests.RequestException, ValueError, TypeError):
            return None
        if not r.ok:
            if not _search_warned:
                print(
                    f"[warn] Jira POST /rest/api/3/search/jql HTTP {r.status_code}: "
                    f"{r.text[:350]!r}",
                    file=sys.stderr,
                )
                _search_warned = True
            return None
        try:
            data = r.json()
        except (ValueError, TypeError):
            return None
        if not isinstance(data, dict):
            return None
        got = _parse_updated_from_jql_search_response(data)
        if got:
            return got
        issues = _issues_from_jql_search_payload(data)
        if issues and isinstance(issues[0], dict):
            ref = issues[0].get("key") or issues[0].get("id")
            if ref:
                return _issue_updated_via_get(session, base, str(ref))
        return None

    for jql in jql_variants:
        for use_field_list in (True, False):
            body: dict = {"jql": jql, "maxResults": 1}
            if use_field_list:
                body["fields"] = ["updated"]
            got = _try_payload(jql, body)
            if got:
                return got

    # GET /search/jql for short JQL (same resource; avoids rare POST body quirks).
    get_url = f"{base}/rest/api/3/search/jql"
    for jql in jql_variants[:2]:
        try:
            r = session.get(
                get_url,
                params={"jql": jql, "maxResults": 1, "fields": "updated"},
                timeout=45,
            )
        except (requests.RequestException, ValueError, TypeError):
            continue
        if not r.ok:
            continue
        try:
            data = r.json()
        except (ValueError, TypeError):
            continue
        if isinstance(data, dict):
            got = _parse_updated_from_jql_search_response(data)
            if got:
                return got
            issues = _issues_from_jql_search_payload(data)
            if issues and isinstance(issues[0], dict):
                ref = issues[0].get("key") or issues[0].get("id")
                if ref:
                    via = _issue_updated_via_get(session, base, str(ref))
                    if via:
                        return via
    return None


def enrich_projects_last_issue_updated(
    session: requests.Session, api_root: str, projects: list[dict]
) -> None:
    """Mutates each project dict with lastIssueUpdatedAt (one search request per project)."""
    throttle = float(os.environ.get("JIRA_ENRICH_THROTTLE_SEC", "0.04") or "0.04")
    for i, p in enumerate(projects):
        key = str(p.get("key") or "").strip()
        if not key:
            p["lastIssueUpdatedAt"] = None
            continue
        peid = p.get("projectEntityId")
        p["lastIssueUpdatedAt"] = fetch_last_issue_updated_at(
            session, api_root, key, peid
        )
        if throttle > 0 and i + 1 < len(projects):
            time.sleep(throttle)


def fetch_all_projects(
    session: requests.Session, api_root: str, name_query: str
) -> tuple[list[dict], int | None, str | None]:
    """
    Paginate GET /rest/api/3/project/search (expand=lead). Returns (rows, total_from_api, error_message).
    """
    base = api_root.rstrip("/")
    url = f"{base}/rest/api/3/project/search"
    rows: list[dict] = []
    total: int | None = None
    start = 0

    max_project_rows = env_int("JIRA_PROJECT_LIST_MAX")
    while len(rows) < max_project_rows:
        params: dict[str, str | int] = {
            "startAt": start,
            "maxResults": PAGE_SIZE,
            "orderBy": "name",
            "expand": "lead",
        }
        q = name_query.strip()
        if q:
            params["query"] = q

        try:
            r = session.get(url, params=params, timeout=60)
            if not r.ok:
                return rows, total, f"HTTP {r.status_code}: {r.text[:400]}"
            data = r.json()
        except (requests.RequestException, ValueError, TypeError) as ex:
            return rows, total, str(ex)[:400]

        if not isinstance(data, dict):
            return rows, total, "Unexpected response (not JSON object)"

        try:
            total = int(data.get("total")) if data.get("total") is not None else total
        except (TypeError, ValueError):
            pass

        values = data.get("values")
        if not isinstance(values, list):
            return rows, total, "Invalid project search payload"

        for p in values:
            if not isinstance(p, dict):
                continue
            key = str(p.get("key") or "").strip()
            if not key:
                continue
            name = str(p.get("name") or "").strip() or "—"
            ptk = str(p.get("projectTypeKey") or "").strip()
            lead_name, lead_account_id = _lead_from_project(p)
            peid = p.get("id")
            project_entity_id = (
                str(peid).strip()
                if peid is not None and str(peid).strip()
                else None
            )
            row: dict = {
                "key": key,
                "name": name,
                "projectTypeKey": ptk,
                "projectType": project_type_label(ptk),
                "url": project_browse_url(base, key),
                "projectLead": lead_name or None,
                "projectLeadAccountId": lead_account_id,
                "projectEntityId": project_entity_id,
            }
            rows.append(row)
            if len(rows) >= max_project_rows:
                break

        if len(values) < PAGE_SIZE:
            break
        start += len(values)
        if total is not None and start >= total:
            break

    return rows, total, None


def _jql_escape_project_key(key: str) -> str:
    return str(key or "").strip().replace("\\", "\\\\").replace('"', '\\"')


def _jql_for_assignee_issue_scan(jql: str) -> str:
    """Append ORDER BY key when missing so search/jql pagination is stable."""
    j = str(jql or "").strip().rstrip(";")
    if not j:
        return j
    if " order by " in j.lower():
        return j
    return f"{j} ORDER BY key ASC"


def _issue_assignee_fields(issue: dict) -> tuple[str, str | None]:
    """Human label and Atlassian accountId (None = unassigned)."""
    fields = issue.get("fields") if isinstance(issue, dict) else None
    if not isinstance(fields, dict):
        return "Unknown", None
    a = fields.get("assignee")
    if a is None:
        return "Unassigned", None
    if isinstance(a, dict):
        if not a:
            return "Unassigned", None
        aid = a.get("accountId")
        aid_s = str(aid).strip() if aid else None
        disp = (
            a.get("displayName")
            or a.get("emailAddress")
            or a.get("name")
            or aid_s
        )
        label = str(disp).strip() if disp else (aid_s or "Unknown")
        return label, aid_s
    return "Unknown", None


def fetch_it_ops_assignee_workload(
    session: requests.Session,
    api_root: str,
    jql_open: str,
    max_issues_scan: int,
    page_size: int,
    inter_page_sleep: float,
) -> tuple[list[dict], int, bool, str | None]:
    """
    Paginate POST /rest/api/3/search/jql over workload JQL; tally open issues per assignee.

    Returns (rows sorted by count desc, issues_scanned, truncated, error_message).
    """
    base = api_root.rstrip("/")
    url = f"{base}/rest/api/3/search/jql"
    scan_jql = _jql_for_assignee_issue_scan(jql_open)
    bucket: dict[str, tuple[str, int]] = {}
    scanned = 0
    token: str | None = None
    pages = 0
    err: str | None = None
    max_pages = max(3, (max_issues_scan // max(1, page_size)) + 5)
    last_batch_len = 0
    last_is_last: bool | None = None

    while scanned < max_issues_scan and pages < max_pages:
        pages += 1
        take = min(max(1, page_size), max_issues_scan - scanned)
        body: dict = {"jql": scan_jql, "maxResults": take, "fields": ["assignee"]}
        if token:
            body["nextPageToken"] = token
        try:
            r = session.post(url, json=body, timeout=60)
        except requests.RequestException as exc:
            err = f"assignee workload: request failed ({exc})"
            break
        if not r.ok:
            err = f"assignee workload: HTTP {r.status_code}"
            break
        try:
            data = r.json()
        except (ValueError, TypeError):
            err = "assignee workload: invalid JSON"
            break
        if not isinstance(data, dict):
            err = "assignee workload: unexpected response"
            break
        issues = _issues_from_jql_search_payload(data) or []
        last_batch_len = len(issues)
        for issue in issues:
            if scanned >= max_issues_scan:
                break
            if not isinstance(issue, dict):
                continue
            label, aid = _issue_assignee_fields(issue)
            key = aid if aid else "__unassigned__"
            if key in bucket:
                prev_name, prev_c = bucket[key]
                bucket[key] = (prev_name or label, prev_c + 1)
            else:
                bucket[key] = (label, 1)
            scanned += 1
        if scanned >= max_issues_scan:
            break
        is_last = data.get("isLast")
        if isinstance(is_last, bool):
            last_is_last = is_last
        next_tok = data.get("nextPageToken")
        if isinstance(is_last, bool) and is_last:
            break
        if isinstance(next_tok, str) and next_tok.strip():
            nxt = next_tok.strip()
            if nxt == token:
                break
            token = nxt
        else:
            break
        if last_batch_len == 0:
            break
        if inter_page_sleep > 0:
            time.sleep(inter_page_sleep)

    rows: list[dict] = []
    for bkey, (name, cnt) in bucket.items():
        rows.append(
            {
                "assignee": name,
                "accountId": None if bkey == "__unassigned__" else bkey,
                "count": cnt,
            }
        )
    rows.sort(key=lambda x: (-int(x.get("count") or 0), str(x.get("assignee") or "")))

    # Truncated if we hit the scan cap without Jira reporting the last page.
    truncated = (
        err is None
        and scanned >= max_issues_scan
        and last_is_last is not True
    )
    return rows, scanned, truncated, err


def fetch_jql_approximate_count(session: requests.Session, api_root: str, jql: str) -> int | None:
    """
    POST /rest/api/3/search/approximate-count — JQL must be bounded (project + status filters qualify).
    """
    url = f"{api_root.rstrip('/')}/rest/api/3/search/approximate-count"
    try:
        r = session.post(url, json={"jql": jql}, timeout=45)
        if not r.ok:
            return None
        data = r.json()
    except (requests.RequestException, ValueError, TypeError):
        return None
    if not isinstance(data, dict):
        return None
    c = data.get("count")
    try:
        return int(c) if c is not None else None
    except (TypeError, ValueError):
        return None


def fetch_it_ops_snapshot(session: requests.Session, site: str) -> dict | None:
    """
    Optional IT Ops / service desk slice (e.g. OPS): open queue depth + unassigned open count.

    JIRA_IT_OPS_PROJECT_KEY — required to enable this block (browse key for title / queue URL default).

    Workload JQL (first match wins):
      JIRA_IT_OPS_WORKLOAD_JQL — explicit bounded JQL for the “open workload” count.
      JIRA_IT_OPS_FILTER_ID — numeric saved filter id → JQL ``filter = <id>``.
      else — ``project = "<KEY>" AND statusCategory != Done``.

    Unassigned count:
      JIRA_IT_OPS_UNASSIGNED_JQL if set; else ``(<workload JQL>) AND assignee is EMPTY``.

    JIRA_IT_OPS_QUEUE_URL — service desk queue link; JIRA_IT_OPS_FILTER_LABEL — optional dashboard label.

    Per-assignee open counts: paginated search/jql over the same workload JQL (default on).
      JIRA_IT_OPS_ASSIGNEE_WORKLOAD=0 to disable.
      JIRA_IT_OPS_ASSIGNEE_SCAN_MAX (default 5000), JIRA_IT_OPS_ASSIGNEE_PAGE_SIZE (25–100),
      JIRA_IT_OPS_ASSIGNEE_THROTTLE_SEC (default 0.03) between pages.
    """
    raw_key = (os.environ.get("JIRA_IT_OPS_PROJECT_KEY") or "").strip().upper()
    if not raw_key:
        return None
    key_esc = _jql_escape_project_key(raw_key)
    base = site.rstrip("/")
    try:
        queue_url = jira_it_ops_queue_url(base, raw_key)
    except ConfigError as ex:
        print(f"[warn] IT Ops queue URL: {ex}", file=sys.stderr)
        return None

    custom_open = (os.environ.get("JIRA_IT_OPS_WORKLOAD_JQL") or "").strip()
    filter_id_raw = (os.environ.get("JIRA_IT_OPS_FILTER_ID") or "").strip().replace(" ", "")
    # env.example historically used 12345 only to show URL shape — not a real filter id.
    # Treat it as unset so workload falls back to project JQL (same as omitting the variable).
    if filter_id_raw == "12345":
        filter_id_raw = ""
    filter_label = (os.environ.get("JIRA_IT_OPS_FILTER_LABEL") or "").strip()
    unassigned_custom = (os.environ.get("JIRA_IT_OPS_UNASSIGNED_JQL") or "").strip()

    if custom_open:
        jql_open = custom_open
        workload_source = "custom_jql"
        saved_filter_id: str | None = None
    elif filter_id_raw.isdigit():
        jql_open = f"filter = {filter_id_raw}"
        workload_source = "saved_filter"
        saved_filter_id = filter_id_raw
    else:
        jql_open = f'project = "{key_esc}" AND statusCategory != Done'
        workload_source = "project_default"
        saved_filter_id = None

    if unassigned_custom:
        jql_unassigned = unassigned_custom
    else:
        jql_unassigned = f"({jql_open}) AND assignee is EMPTY"

    open_n = fetch_jql_approximate_count(session, site, jql_open)
    unassigned_n = fetch_jql_approximate_count(session, site, jql_unassigned)
    err_parts: list[str] = []
    if open_n is None:
        err_parts.append("open count unavailable (JQL or approximate-count)")
    if unassigned_n is None:
        err_parts.append("unassigned count unavailable")
    out: dict = {
        "projectKey": raw_key,
        "queueUrl": queue_url,
        "workloadJqlSource": workload_source,
        "jqlOpenWorkload": jql_open,
        "jqlUnassignedOpen": jql_unassigned,
        "openWorkload": open_n,
        "unassignedOpen": unassigned_n,
    }
    if saved_filter_id:
        out["savedFilterId"] = saved_filter_id
    if filter_label:
        out["filterLabel"] = filter_label

    skip_aw = (os.environ.get("JIRA_IT_OPS_ASSIGNEE_WORKLOAD", "1") or "").strip().lower() in (
        "0",
        "false",
        "no",
        "off",
    )
    if not skip_aw:
        try:
            max_scan = int(os.environ.get("JIRA_IT_OPS_ASSIGNEE_SCAN_MAX", "5000") or "5000")
        except (TypeError, ValueError):
            max_scan = 5000
        max_scan = max(50, min(max_scan, 50_000))
        try:
            pg = int(os.environ.get("JIRA_IT_OPS_ASSIGNEE_PAGE_SIZE", "100") or "100")
        except (TypeError, ValueError):
            pg = 100
        pg = max(25, min(pg, 100))
        try:
            th = float(os.environ.get("JIRA_IT_OPS_ASSIGNEE_THROTTLE_SEC", "0.03") or "0.03")
        except (TypeError, ValueError):
            th = 0.03
        rows, nscan, trunc, aw_err = fetch_it_ops_assignee_workload(
            session, site, jql_open, max_scan, pg, th
        )
        out["assigneeWorkload"] = rows
        out["assigneeWorkloadScanned"] = nscan
        out["assigneeWorkloadTruncated"] = trunc
        out["assigneeWorkloadScanMax"] = max_scan
        if aw_err:
            out["assigneeWorkloadError"] = aw_err
        elif trunc:
            if open_n is not None and nscan < open_n:
                out["assigneeWorkloadNote"] = (
                    f"Scanned {nscan} of ~{open_n} open issues (cap {max_scan}); "
                    "per-assignee counts may be incomplete."
                )
            else:
                out["assigneeWorkloadNote"] = (
                    f"Stopped after {max_scan} issues scanned; queue may have more."
                )

    if err_parts:
        out["itOpsError"] = "; ".join(err_parts)
    return out


def main() -> int:
    load_env_file()
    try:
        site = jira_base_url()
    except ConfigError as ex:
        print(str(ex), file=sys.stderr)
        return 1
    email = os.environ.get("JIRA_EMAIL", "").strip()
    token = os.environ.get("JIRA_API_TOKEN", "").strip()
    name_query = (os.environ.get("JIRA_PROJECT_NAME_QUERY") or "").strip()

    it_ops_key = (os.environ.get("JIRA_IT_OPS_PROJECT_KEY") or "").strip()
    if not it_ops_key:
        print(
            "[info] IT Ops block skipped: JIRA_IT_OPS_PROJECT_KEY not set. "
            "(That is separate from JIRA_EMAIL / JIRA_API_TOKEN, which only authenticate the main project list.) "
            "Ensure the line is in the saved .env next to fetch_jira.py’s project root, then run this script again.",
            file=sys.stderr,
        )
    if not email or not token:
        print(
            "Missing JIRA_EMAIL or JIRA_API_TOKEN in environment (.env). See env.example.",
            file=sys.stderr,
        )
        return 1

    session = requests.Session()
    session.auth = (email, token)
    session.headers.update({"Accept": "application/json"})

    projects, projects_total, err = fetch_all_projects(session, site, name_query)

    hide_keys = parse_hide_project_keys()
    before_hide = len(projects)
    if hide_keys:
        projects = [p for p in projects if str(p.get("key") or "").strip().upper() not in hide_keys]
    excluded = before_hide - len(projects)

    skip_last = (os.environ.get("JIRA_SKIP_LAST_ISSUE", "") or "").strip().lower() in (
        "1",
        "true",
        "yes",
    )
    if projects and not skip_last:
        enrich_projects_last_issue_updated(session, site, projects)
    elif projects:
        for p in projects:
            p["lastIssueUpdatedAt"] = None

    admin_apps = admin_apps_url_from_env()
    if admin_apps and (
        "<" in admin_apps
        or "your-org-id" in admin_apps.lower()
        or "YOUR_ORG_ID" in admin_apps
    ):
        print(
            "[warn] ATLASSIAN_ADMIN_APPS_URL looks like a placeholder. "
            "Copy the real …/o/<org-id>/atlassian-apps URL from your browser while logged into admin.atlassian.com.",
            file=sys.stderr,
        )

    payload: dict = {
        "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "siteUrl": site,
        "atlassianAdminAppsUrl": admin_apps or None,
        "projectsDirectoryUrl": projects_directory_url(site),
        "projectNameQuery": name_query or None,
        "projectsTotal": projects_total,
        "projectsListed": len(projects),
        "projectsExcluded": excluded,
        "jiraError": err,
        "projects": projects,
    }
    it_ops = fetch_it_ops_snapshot(session, site)
    if it_ops:
        payload["itOps"] = it_ops

    out_path = write_json_snapshot(OUT, payload)
    print(f"Wrote {out_path}")
    print(
        f"Wrote {OUT} ({len(projects)} project row(s) shown, "
        f"remote total={projects_total}, hidden={excluded})"
    )
    return 1 if err else 0


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