#!/usr/bin/env python3
"""
Team Staging & Develop Status — Jira-first, markdown output.

1. GitHub (gh CLI): detect release branches per repo
2. Jira REST API:   fetch open-sprint tickets for the team
3. Jira dev-status:  linked PRs + destination branch per ticket
4. Classify PRs as staging / develop-only / open
5. Build "last production release" section
6. Output markdown report to stdout

Usage:
    TEAM_NAME="Coyote" TEAM_TICKET_PREFIX="SQCY" python3 team-staging-status.py

Required env:
    TEAM_NAME, TEAM_TICKET_PREFIX
    JIRA_EMAIL (or ATLASSIAN_EMAIL), JIRA_API_TOKEN (or ATLASSIAN_API_TOKEN)

Optional env:
    JIRA_BASE_URL          (default: https://humand.atlassian.net)
    HUMAND_GH_ORG          (default: HumandDev)
    RELEASE_REPOS          (space-separated, default: humand-web humand-backoffice humand-main-api)
    VERSION_REPOS          (space-separated, default: humand-mobile)
    TEAM_GITHUB_MEMBERS    (comma-separated github logins to filter team PRs in prod)
    TEAM_STATUS_DEBUG=1    (debug logs to stderr)
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import urllib.parse
from base64 import b64encode
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path

# ── Config ────────────────────────────────────────────────────────────────

ORG = os.environ.get("HUMAND_GH_ORG", "HumandDev")
DEBUG = os.environ.get("TEAM_STATUS_DEBUG") == "1"

# plugins/humand-product-workflow/skills/hu-team-staging-status/team-staging-status.py
# -> plugin root at parent.parent.parent
REPOS_JSON = Path(__file__).resolve().parent.parent.parent / "config" / "repos.json"
TEAMS_JSON = REPOS_JSON.with_name("teams.json")

def _load_repo_config() -> dict:
    if not REPOS_JSON.exists():
        return {}
    with open(REPOS_JSON) as f:
        return json.load(f)

_REPO_CFG = _load_repo_config()
_REPOS = _REPO_CFG.get("repos", {})
_PLATFORM_HEURISTICS = _REPO_CFG.get("platform_heuristics", [])


def _load_teams_config() -> dict:
    if not TEAMS_JSON.exists():
        return {}
    with open(TEAMS_JSON) as f:
        return json.load(f)


_TEAMS_CFG = _load_teams_config()

SCAN_REPOS: dict[str, dict] = {name: cfg for name, cfg in _REPOS.items() if cfg.get("scan")}
RELEASE_REPOS = [n for n, c in SCAN_REPOS.items() if c.get("branch_model") == "release-date"]
VERSION_REPOS = [n for n, c in SCAN_REPOS.items() if c.get("branch_model") == "semver"]
EXPLICIT_BRANCH_REPOS: dict[str, dict[str, str]] = {
    n: c["branches"] for n, c in SCAN_REPOS.items()
    if c.get("branch_model") == "explicit" and "branches" in c
}

if os.environ.get("RELEASE_REPOS"):
    RELEASE_REPOS = os.environ["RELEASE_REPOS"].split()
if os.environ.get("VERSION_REPOS"):
    VERSION_REPOS = os.environ["VERSION_REPOS"].split()


def repo_platform(repo_name: str) -> str:
    cfg = _REPOS.get(repo_name)
    if cfg and "platform" in cfg:
        return cfg["platform"]
    name_lower = repo_name.lower()
    for rule in _PLATFORM_HEURISTICS:
        if rule["pattern"] in name_lower:
            return rule["platform"]
    return "other"


def repo_sort_key(repo_name: str) -> tuple[int, str]:
    """Core scan repos first (release-date, semver, explicit), then known, then unknown."""
    order = {"release-date": 0, "semver": 1, "explicit": 2}
    cfg = _REPOS.get(repo_name, {})
    if cfg.get("scan"):
        return (order.get(cfg.get("branch_model", ""), 3), repo_name)
    if repo_name in _REPOS:
        return (4, repo_name)
    return (5, repo_name)

JIRA_BASE = os.environ.get("JIRA_BASE_URL") or os.environ.get("ATLASSIAN_BASE_URL") or "https://humand.atlassian.net"
JIRA_BASE = JIRA_BASE.rstrip("/")
if JIRA_BASE.endswith("/browse"):
    JIRA_BASE = JIRA_BASE[: -len("/browse")]

JIRA_EMAIL = os.environ.get("JIRA_EMAIL") or os.environ.get("ATLASSIAN_EMAIL") or os.environ.get("JIRA_USERNAME") or os.environ.get("JIRA_USER") or ""
JIRA_TOKEN = os.environ.get("JIRA_API_TOKEN") or os.environ.get("ATLASSIAN_API_TOKEN") or os.environ.get("JIRA_TOKEN") or ""

TEAM_NAME = os.environ.get("TEAM_NAME", "")
TEAM_PREFIX = os.environ.get("TEAM_TICKET_PREFIX") or os.environ.get("TEAM_KEY") or ""

ACTIVE_STATUSES = {"developing", "in progress", "in review", "staging", "developed", "code review"}


class ExternalDependencyError(RuntimeError):
    """Raised when GitHub/Jira dependencies fail and output would be unreliable."""


def debug(msg: str) -> None:
    if DEBUG:
        print(f"[debug] {msg}", file=sys.stderr)


def die(msg: str, code: int = 1) -> None:
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(code)


def _short(text: str, limit: int = 280) -> str:
    text = (text or "").strip().replace("\n", " ")
    if len(text) <= limit:
        return text
    return text[: limit - 3] + "..."


def _run_command(cmd: list[str], timeout: int, source: str) -> str:
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired as e:
        raise ExternalDependencyError(f"{source} timeout after {timeout}s: {' '.join(cmd[:4])}") from e
    if r.returncode != 0:
        detail = _short(r.stderr or r.stdout or "")
        raise ExternalDependencyError(f"{source} failed (exit {r.returncode}): {detail}")
    return r.stdout


# ── Data classes ──────────────────────────────────────────────────────────

@dataclass
class Ticket:
    key: str
    summary: str
    status: str
    assignee: str
    is_subtask: bool = False
    parent_key: str = ""
    prs: list[dict] = field(default_factory=list)


@dataclass
class RepoBranches:
    staging: str = ""
    prod: str = ""
    prev_prod: str = ""
    dev: str = "develop"
    dev_only_prs: set[int] = field(default_factory=set)
    not_in_prod_prs: set[int] = field(default_factory=set)
    last_prod_release_prs: set[int] = field(default_factory=set)
    last_prod_release_team_keys_by_pr: dict[int, set[str]] = field(default_factory=dict)


@dataclass
class GroupedEntry:
    ticket: Ticket
    pr_numbers: list[int] = field(default_factory=list)


@dataclass
class ReleasePrMeta:
    number: int
    title: str
    url: str
    author: str
    labels: list[str] = field(default_factory=list)


# ── GitHub helpers ────────────────────────────────────────────────────────

def gh_api(path: str, jq: str | None = None) -> str:
    cmd = ["gh", "api", path]
    if jq:
        cmd += ["--jq", jq]
    cmd += ["--paginate"]
    return _run_command(cmd, timeout=30, source=f"GitHub API {path}").strip()


def _release_tags(repo: str, limit: int = 2) -> list[str]:
    """Get latest GitHub release tags ordered newest first."""
    raw = _run_command(
        ["gh", "api", f"repos/{ORG}/{repo}/releases", "--jq", ".[].tag_name"],
        timeout=15,
        source=f"GitHub releases {repo}",
    )
    tags = [t.strip() for t in raw.splitlines() if t.strip()]
    return tags[:limit]


def _team_aliases(team_prefix: str) -> set[str]:
    team_cfg = _TEAMS_CFG.get(team_prefix, {})
    aliases = set()
    for alias in team_cfg.get("aliases", []):
        if isinstance(alias, str):
            aliases.add(alias.lower())
    name = team_cfg.get("name")
    if isinstance(name, str) and name:
        aliases.add(name.lower())
    return aliases


def _load_team_members(team_prefix: str, team_name: str) -> set[str]:
    """
    Resolve github logins for the team from:
    1) TEAM_GITHUB_MEMBERS env var
    2) teams.json (members[].github; legacy fallback: members_github)
    3) dailyseek.json profiles (by prefix/name/alias)
    """
    members: set[str] = set()

    env_members = os.environ.get("TEAM_GITHUB_MEMBERS", "")
    if env_members:
        for raw in env_members.split(","):
            login = raw.strip().lower()
            if login:
                members.add(login)
        if members:
            debug(f"Team members from TEAM_GITHUB_MEMBERS: {sorted(members)}")
            return members

    team_cfg = _TEAMS_CFG.get(team_prefix, {})
    raw_members = team_cfg.get("members", [])
    if isinstance(raw_members, list):
        for item in raw_members:
            if isinstance(item, str) and item.strip():
                members.add(item.strip().lower())
            elif isinstance(item, dict):
                login = (item.get("github") or "").strip().lower()
                if login:
                    members.add(login)
    if not members:
        # Legacy schema fallback.
        raw_members_github = team_cfg.get("members_github", [])
        if isinstance(raw_members_github, list):
            for item in raw_members_github:
                if isinstance(item, str) and item.strip():
                    members.add(item.strip().lower())
    if members:
        debug(f"Team members from teams.json: {sorted(members)}")
        return members

    aliases = _team_aliases(team_prefix)
    profile_hints = {team_prefix.lower(), team_name.lower()} | aliases
    dailyseek_candidates = [
        REPOS_JSON.with_name("dailyseek.json"),
        Path.cwd() / ".cursor" / "dailyseek.json",
        Path.cwd() / "config" / "dailyseek.json",
    ]
    seen_paths: set[str] = set()
    for path in dailyseek_candidates:
        path_str = str(path.resolve())
        if path_str in seen_paths or not path.exists():
            continue
        seen_paths.add(path_str)
        try:
            with open(path) as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError):
            continue
        profiles = data.get("profiles", {})
        if not isinstance(profiles, dict):
            continue
        for profile_name, profile in profiles.items():
            if not isinstance(profile, dict):
                continue
            profile_name_l = str(profile_name).lower()
            if profile_name_l not in profile_hints:
                continue
            for member in profile.get("members", []):
                if isinstance(member, dict):
                    login = (member.get("github") or "").strip().lower()
                    if login:
                        members.add(login)
                elif isinstance(member, str):
                    login = member.strip().lower()
                    if login:
                        members.add(login)
        if members:
            debug(f"Team members from {path}: {sorted(members)}")
            return members
    return members


def _extract_release_prs_and_team_keys(repo: str, base_ref: str, head_ref: str) -> tuple[set[int], dict[int, set[str]]]:
    """
    Return:
    - all PR numbers detected in compare commits
    - mapping PR number -> team ticket keys (TEAM_PREFIX-123) found in merge messages
    """
    all_prs: set[int] = set()
    keys_by_pr: dict[int, set[str]] = {}
    raw = gh_api(
        f"repos/{ORG}/{repo}/compare/{base_ref}...{head_ref}",
        "[.commits[].commit.message]",
    )
    if not raw:
        return all_prs, keys_by_pr

    try:
        commit_messages = json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        return all_prs, keys_by_pr

    team_key_re = re.compile(rf"\b{re.escape(TEAM_PREFIX)}-\d+\b", re.IGNORECASE)
    pr_re = re.compile(r"\(#(?P<n>\d+)\)")
    if not isinstance(commit_messages, list):
        return all_prs, keys_by_pr

    for message in commit_messages:
        if not isinstance(message, str):
            continue
        pr_nums = [int(m.group("n")) for m in pr_re.finditer(message)]
        if not pr_nums:
            continue
        team_keys = {m.group(0).upper() for m in team_key_re.finditer(message)}
        for pr_num in pr_nums:
            all_prs.add(pr_num)
            if team_keys:
                keys_by_pr.setdefault(pr_num, set()).update(team_keys)
    return all_prs, keys_by_pr


def _fetch_release_pr_meta(repo: str, pr_numbers: set[int]) -> dict[int, ReleasePrMeta]:
    if not pr_numbers:
        return {}

    def _fetch_one(pr_num: int) -> tuple[int, ReleasePrMeta]:
        raw = _run_command(
            [
                "gh",
                "api",
                f"repos/{ORG}/{repo}/pulls/{pr_num}",
                "--jq",
                '{number:.number,title:.title,url:.html_url,author:(.user.login // ""),labels:[.labels[].name]}',
            ],
            timeout=20,
            source=f"GitHub PR metadata {repo}#{pr_num}",
        )
        try:
            body = json.loads(raw)
        except (json.JSONDecodeError, ValueError) as e:
            raise ExternalDependencyError(
                f"GitHub PR metadata parse failed for {repo}#{pr_num}: {_short(raw)}"
            ) from e
        labels = []
        for label in body.get("labels", []):
            if isinstance(label, str) and label.strip():
                labels.append(label.strip())
        meta = ReleasePrMeta(
            number=int(body.get("number") or pr_num),
            title=str(body.get("title") or f"PR#{pr_num}"),
            url=str(body.get("url") or f"https://github.com/{ORG}/{repo}/pull/{pr_num}"),
            author=str(body.get("author") or "").lower(),
            labels=labels,
        )
        return pr_num, meta

    result: dict[int, ReleasePrMeta] = {}
    with ThreadPoolExecutor(max_workers=12) as pool:
        futures = {pool.submit(_fetch_one, n): n for n in pr_numbers}
        for f in as_completed(futures):
            try:
                num, meta = f.result()
                result[num] = meta
            except ExternalDependencyError as e:
                print(f"[warn] PR meta fetch failed, skipping: {e}", file=sys.stderr)
    return result


def detect_branches(repo: str) -> RepoBranches | None:
    all_names = gh_api(f"repos/{ORG}/{repo}/branches", ".[].name").splitlines()
    if not all_names:
        return None

    staging = prod = prev_prod = dev = ""
    if repo in EXPLICIT_BRANCH_REPOS:
        cfg = EXPLICIT_BRANCH_REPOS[repo]
        staging = cfg["staging"] if cfg["staging"] in all_names else ""
        prod = cfg["prod"] if cfg["prod"] in all_names else ""
        dev = cfg["dev"] if cfg["dev"] in all_names else ""
    elif repo in VERSION_REPOS:
        versions = sorted(
            [n for n in all_names if re.match(r"^v\d+\.\d+\.\d+$", n)],
            key=lambda v: list(map(int, v[1:].split("."))),
        )
        if versions:
            staging = versions[-1]
        tags = _release_tags(repo, limit=2)
        if tags:
            prod = tags[0]
        if len(tags) >= 2:
            prev_prod = tags[1]
    else:
        releases = sorted(
            [n for n in all_names if re.match(r"^release-\d{4}\.\d{2}\.\d{2}$", n)],
            reverse=True,
        )
        if releases:
            staging = releases[0]
            prod = releases[1] if len(releases) >= 2 else ""
            prev_prod = releases[2] if len(releases) >= 3 else ""

    if not staging:
        return None

    if not dev:
        dev = "develop" if "develop" in all_names else gh_api(f"repos/{ORG}/{repo}", ".default_branch") or "main"

    pr_extract_jq = r'[.commits[].commit.message | capture("\\(#(?P<n>[0-9]+)\\)") | .n | tonumber] | unique | .[]'

    dev_only_prs: set[int] = set()
    if dev:
        raw = gh_api(f"repos/{ORG}/{repo}/compare/{staging}...{dev}", pr_extract_jq)
        for line in raw.splitlines():
            try:
                dev_only_prs.add(int(line))
            except ValueError:
                pass

    not_in_prod_prs: set[int] = set()
    if prod:
        for ref in {staging, dev}:
            if not ref:
                continue
            raw = gh_api(f"repos/{ORG}/{repo}/compare/{prod}...{ref}", pr_extract_jq)
            for line in raw.splitlines():
                try:
                    not_in_prod_prs.add(int(line))
                except ValueError:
                    pass

    last_prod_release_prs: set[int] = set()
    last_prod_release_team_keys_by_pr: dict[int, set[str]] = {}
    if prod and prev_prod:
        last_prod_release_prs, last_prod_release_team_keys_by_pr = _extract_release_prs_and_team_keys(
            repo=repo,
            base_ref=prev_prod,
            head_ref=prod,
        )

    return RepoBranches(
        staging=staging,
        prod=prod,
        prev_prod=prev_prod,
        dev=dev,
        dev_only_prs=dev_only_prs,
        not_in_prod_prs=not_in_prod_prs,
        last_prod_release_prs=last_prod_release_prs,
        last_prod_release_team_keys_by_pr=last_prod_release_team_keys_by_pr,
    )


# ── Jira helpers ──────────────────────────────────────────────────────────

def jira_auth_header() -> str:
    return "Basic " + b64encode(f"{JIRA_EMAIL}:{JIRA_TOKEN}".encode()).decode()


def _jira_error_detail(payload: dict) -> str:
    if not isinstance(payload, dict):
        return ""
    errors = payload.get("errorMessages")
    if isinstance(errors, list) and errors:
        return "; ".join(str(e) for e in errors if e)
    field_errors = payload.get("errors")
    if isinstance(field_errors, dict) and field_errors:
        return "; ".join(f"{k}: {v}" for k, v in field_errors.items())
    msg = payload.get("message")
    if msg:
        return str(msg)
    return ""


def jira_get(path: str, params: dict | None = None) -> dict:
    url = f"{JIRA_BASE}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    raw = _run_command(
        [
            "curl",
            "-sS",
            "-L",
            "-H",
            f"Authorization: {jira_auth_header()}",
            "-H",
            "Accept: application/json",
            "-w",
            "\n%{http_code}",
            url,
        ],
        timeout=20,
        source=f"Jira API {path}",
    )
    body_raw, sep, status_raw = raw.rpartition("\n")
    if not sep or not status_raw.isdigit():
        raise ExternalDependencyError(f"Jira API {path} returned malformed response metadata.")
    status_code = int(status_raw)
    if status_code >= 400:
        detail = ""
        try:
            detail = _jira_error_detail(json.loads(body_raw))
        except (json.JSONDecodeError, ValueError):
            detail = ""
        detail = detail or _short(body_raw)
        raise ExternalDependencyError(f"Jira API {path} HTTP {status_code}: {detail}")
    try:
        body = json.loads(body_raw)
    except (json.JSONDecodeError, ValueError) as e:
        raise ExternalDependencyError(f"Jira API {path} returned invalid JSON: {_short(body_raw)}") from e
    payload_error = _jira_error_detail(body)
    if payload_error:
        raise ExternalDependencyError(f"Jira API {path} returned error payload: {payload_error}")
    return body


def fetch_sprint_tickets(project: str) -> list[Ticket]:
    jql = f'sprint in openSprints() AND project = "{project}" ORDER BY status ASC, priority DESC'
    data = jira_get("/rest/api/3/search/jql", {"jql": jql, "maxResults": "100", "fields": "summary,status,assignee,issuetype,parent"})
    if "issues" not in data:
        raise ExternalDependencyError("Jira sprint search response missing 'issues'.")

    tickets: list[Ticket] = []
    for issue in data.get("issues", []):
        f = issue.get("fields", {})
        assignee_obj = f.get("assignee")
        assignee = ""
        if assignee_obj:
            assignee = assignee_obj.get("displayName") or assignee_obj.get("emailAddress") or ""
        issuetype = f.get("issuetype") or {}
        parent_obj = f.get("parent")
        tickets.append(Ticket(
            key=issue["key"],
            summary=f.get("summary", ""),
            status=f.get("status", {}).get("name", ""),
            assignee=assignee,
            is_subtask=bool(issuetype.get("subtask")),
            parent_key=parent_obj["key"] if parent_obj else "",
        ))
    return tickets


def fetch_dev_info(keys: list[str]) -> dict[str, list[dict]]:
    if not keys:
        return {}

    jql = f"key in ({','.join(keys)})"
    data = jira_get("/rest/api/3/search/jql", {"jql": jql, "fields": "key", "maxResults": str(len(keys))})
    if "issues" not in data:
        raise ExternalDependencyError("Jira issue-id lookup response missing 'issues'.")

    key_to_id: dict[str, str] = {}
    for issue in data.get("issues", []):
        key_to_id[issue["key"]] = issue["id"]

    def _fetch_one(key: str, issue_id: str) -> tuple[str, list[dict]]:
        body = jira_get(
            "/rest/dev-status/latest/issue/detail",
            {"issueId": issue_id, "applicationType": "GitHub", "dataType": "pullrequest"},
        )
        items: list[dict] = []

        for detail in body.get("detail", []):
            for pr in detail.get("pullRequests", []):
                pr_url = pr.get("url", "")
                num_m = re.search(r"/pull/(\d+)", pr_url)
                repo_m = re.search(r"github\.com/[^/]+/([^/]+)/pull/", pr_url)
                items.append({
                    "repo": repo_m.group(1) if repo_m else "",
                    "pr_number": int(num_m.group(1)) if num_m else None,
                    "name": pr.get("name", ""),
                    "url": pr_url,
                    "state": pr.get("status", ""),
                    "dest_branch": (pr.get("destination") or {}).get("branch", ""),
                })
        return key, items

    result: dict[str, list[dict]] = {}
    with ThreadPoolExecutor(max_workers=10) as pool:
        futures = {pool.submit(_fetch_one, k, v): k for k, v in key_to_id.items()}
        for f in as_completed(futures):
            key, items = f.result()
            result[key] = items
    return result


def fetch_tickets_by_keys(keys: list[str]) -> dict[str, Ticket]:
    if not keys:
        return {}
    jql = f"key in ({','.join(sorted(keys))})"
    data = jira_get(
        "/rest/api/3/search/jql",
        {"jql": jql, "maxResults": str(len(keys)), "fields": "summary,status,assignee,issuetype,parent"},
    )
    if "issues" not in data:
        raise ExternalDependencyError("Jira key lookup response missing 'issues'.")

    result: dict[str, Ticket] = {}
    for issue in data.get("issues", []):
        f = issue.get("fields", {})
        assignee_obj = f.get("assignee")
        assignee = ""
        if assignee_obj:
            assignee = assignee_obj.get("displayName") or assignee_obj.get("emailAddress") or ""
        issuetype = f.get("issuetype") or {}
        parent_obj = f.get("parent")
        t = Ticket(
            key=issue["key"],
            summary=f.get("summary", issue["key"]),
            status=f.get("status", {}).get("name", ""),
            assignee=assignee,
            is_subtask=bool(issuetype.get("subtask")),
            parent_key=parent_obj["key"] if parent_obj else "",
        )
        result[t.key] = t
    return result


# ── Classification ────────────────────────────────────────────────────────

def classify_pr(pr: dict, branches: RepoBranches) -> str | None:
    """Classify a PR into staging / develop / open / None (already in prod or declined)."""
    state = pr.get("state", "")
    if state == "DECLINED":
        return None
    if state == "OPEN":
        return "open"
    if state != "MERGED":
        return None

    dest = pr.get("dest_branch", "").lower().strip()
    stg = branches.staging.lower()
    prod = branches.prod.lower()
    dev = branches.dev.lower()
    pr_num = pr.get("pr_number")

    def _is_not_in_prod() -> bool:
        if not branches.not_in_prod_prs:
            return True  # no prod branch → assume not yet in prod
        return bool(pr_num and pr_num in branches.not_in_prod_prs)

    if dest and stg and dest == stg:
        return "staging" if _is_not_in_prod() else None
    if dest and prod and dest == prod:
        return None
    if re.match(r"^release-\d{4}\.\d{2}\.\d{2}$", dest):
        return "staging" if _is_not_in_prod() else None
    if re.match(r"^v\d+\.\d+\.\d+$", dest):
        return "staging" if _is_not_in_prod() else None
    if dest and dev and dest == dev:
        if pr_num and pr_num in branches.dev_only_prs:
            return "develop"
        return "staging" if _is_not_in_prod() else None
    return "staging" if _is_not_in_prod() else None


def build_groups(
    tickets: list[Ticket],
    branch_map: dict[str, RepoBranches],
) -> tuple[dict[str, dict[str, dict[str, GroupedEntry]]], list[Ticket]]:
    """Returns (repo_groups, no_pr_tickets)."""
    repo_groups: dict[str, dict[str, dict[str, GroupedEntry]]] = {}
    no_pr: list[Ticket] = []

    for ticket in tickets:
        prs = [p for p in ticket.prs if p.get("repo")]
        if not prs:
            no_pr.append(ticket)
            continue

        for pr in prs:
            repo = pr["repo"]
            branches = branch_map.get(repo, RepoBranches())
            bucket = classify_pr(pr, branches)
            if bucket is None:
                continue

            repo_groups.setdefault(repo, {"staging": {}, "develop": {}, "open": {}})
            group = repo_groups[repo][bucket]
            if ticket.key not in group:
                group[ticket.key] = GroupedEntry(ticket=ticket)
            pr_num = pr.get("pr_number")
            if pr_num and pr_num not in group[ticket.key].pr_numbers:
                group[ticket.key].pr_numbers.append(pr_num)

    return repo_groups, no_pr


def build_last_prod_release_data(
    branch_map: dict[str, RepoBranches],
    team_members: set[str],
) -> tuple[
    dict[str, dict[str, GroupedEntry]],
    dict[str, list[ReleasePrMeta]],
    dict[str, list[ReleasePrMeta]],
    dict[str, int],
]:
    """
    Returns:
    - team ticket groups per repo included in latest production release
    - team PRs (author in team roster) without a ticket key for this team
    - hotfix/stgfix team PRs (by labels)
    - team PR count per repo
    """
    release_groups: dict[str, dict[str, GroupedEntry]] = {}
    no_ticket_team_prs: dict[str, list[ReleasePrMeta]] = {}
    hotfix_stgfix_team_prs: dict[str, list[ReleasePrMeta]] = {}
    team_pr_count_by_repo: dict[str, int] = {}

    team_pr_numbers_by_repo: dict[str, set[int]] = {}
    team_keys_by_repo_by_pr: dict[str, dict[int, set[str]]] = {}
    pr_meta_by_repo: dict[str, dict[int, ReleasePrMeta]] = {}
    all_team_keys: set[str] = set()

    for repo, branches in branch_map.items():
        if not branches.prod or not branches.prev_prod:
            continue
        pr_meta = _fetch_release_pr_meta(repo, branches.last_prod_release_prs)
        pr_meta_by_repo[repo] = pr_meta

        if team_members:
            team_pr_numbers = {n for n, meta in pr_meta.items() if meta.author and meta.author in team_members}
        else:
            # Conservative fallback when roster is missing: only PRs with team ticket key
            team_pr_numbers = set(branches.last_prod_release_team_keys_by_pr.keys())
        team_pr_numbers_by_repo[repo] = team_pr_numbers
        team_pr_count_by_repo[repo] = len(team_pr_numbers)

        keys_by_pr = {
            pr_num: keys
            for pr_num, keys in branches.last_prod_release_team_keys_by_pr.items()
            if pr_num in team_pr_numbers
        }
        team_keys_by_repo_by_pr[repo] = keys_by_pr
        for keys in keys_by_pr.values():
            all_team_keys.update(keys)

    ticket_map = fetch_tickets_by_keys(sorted(all_team_keys))

    for repo, branches in branch_map.items():
        if not branches.prod or not branches.prev_prod:
            continue

        team_pr_numbers = team_pr_numbers_by_repo.get(repo, set())
        keys_by_pr = team_keys_by_repo_by_pr.get(repo, {})
        pr_meta = pr_meta_by_repo.get(repo, {})

        no_ticket_pr_nums = sorted(n for n in team_pr_numbers if n not in keys_by_pr)
        if no_ticket_pr_nums:
            no_ticket_team_prs[repo] = [pr_meta[n] for n in no_ticket_pr_nums if n in pr_meta]

        hotfix_stgfix = []
        for pr_num in sorted(team_pr_numbers):
            meta = pr_meta.get(pr_num)
            if not meta:
                continue
            labels_l = {re.sub(r"[\s_-]+", "", l.lower()) for l in meta.labels}
            if any(("hotfix" in label or "stgfix" in label) for label in labels_l):
                hotfix_stgfix.append(meta)
        if hotfix_stgfix:
            hotfix_stgfix_team_prs[repo] = hotfix_stgfix

        for pr_num, keys in keys_by_pr.items():
            for key in keys:
                ticket = ticket_map.get(key) or Ticket(key=key, summary=f"Ticket {key}", status="", assignee="")
                release_groups.setdefault(repo, {})
                if key not in release_groups[repo]:
                    release_groups[repo][key] = GroupedEntry(ticket=ticket)
                if pr_num not in release_groups[repo][key].pr_numbers:
                    release_groups[repo][key].pr_numbers.append(pr_num)

    return release_groups, no_ticket_team_prs, hotfix_stgfix_team_prs, team_pr_count_by_repo


# ── Markdown rendering ───────────────────────────────────────────────────

def render_markdown(
    team_name: str,
    repo_groups: dict[str, dict[str, dict[str, GroupedEntry]]],
    branch_map: dict[str, RepoBranches],
    no_pr_tickets: list[Ticket],
    release_groups: dict[str, dict[str, GroupedEntry]],
    release_no_ticket_team_prs: dict[str, list[ReleasePrMeta]],
    release_hotfix_stgfix_team_prs: dict[str, list[ReleasePrMeta]],
    release_team_pr_count_by_repo: dict[str, int],
    team_members_loaded: bool,
) -> str:
    lines: list[str] = []
    today = date.today().strftime("%Y-%m-%d")
    lines.append(f"# Estado de staging y develop — {team_name}")
    lines.append(f"> Generado: {today}")
    lines.append("")

    sorted_repos = sorted(repo_groups.keys(), key=repo_sort_key)

    # Summary table
    lines.append("## Resumen")
    lines.append("")
    lines.append("| Repo | Staging | Develop only | PRs abiertos |")
    lines.append("|------|---------|--------------|--------------|")
    for repo in sorted_repos:
        buckets = repo_groups[repo]
        stg = len(buckets["staging"])
        dev = len(buckets["develop"])
        opn = len(buckets["open"])
        if not stg and not dev and not opn:
            continue
        lines.append(f"| {repo} | {stg} | {dev} | {opn} |")
    lines.append("")

    # Last production release section
    prod_repos = sorted([r for r, b in branch_map.items() if b.prod], key=repo_sort_key)
    if prod_repos:
        lines.append("## Ultimo release a produccion")
        lines.append("")
        if not team_members_loaded:
            lines.append("> Aviso: no se encontro roster de GitHub del equipo; los datos de prod se filtran solo por tickets con prefijo.")
            lines.append("")
        lines.append("| Repo | Produccion actual | Base comparada | PRs en el release | PRs del equipo | Tickets del equipo | Hotfix/Stgfix del equipo |")
        lines.append("|------|-------------------|----------------|-------------------|----------------|--------------------|--------------------------|")
        for repo in prod_repos:
            branches = branch_map[repo]
            team_tickets_count = len(release_groups.get(repo, {}))
            team_pr_count = release_team_pr_count_by_repo.get(repo, 0)
            hotfix_count = len(release_hotfix_stgfix_team_prs.get(repo, []))
            prs_count = len(branches.last_prod_release_prs) if branches.prev_prod else "n/d"
            base_ref = f"`{branches.prev_prod}`" if branches.prev_prod else "_sin referencia previa_"
            lines.append(
                f"| {repo} | `{branches.prod}` | {base_ref} | {prs_count} | {team_pr_count} | {team_tickets_count} | {hotfix_count} |"
            )
        lines.append("")
        for repo in prod_repos:
            entries = list(release_groups.get(repo, {}).values())
            if not entries:
                lines.append(f"### {repo} — tickets del equipo en ultimo release (0)")
                lines.append("")
            else:
                lines.append(f"### {repo} — tickets del equipo en ultimo release ({len(entries)})")
                lines.append("")
                for e in entries:
                    lines.append(_format_entry(e, repo=repo))
                lines.append("")

            no_ticket_prs = release_no_ticket_team_prs.get(repo, [])
            if no_ticket_prs:
                lines.append(f"### {repo} — PRs del equipo sin ticket {TEAM_PREFIX} ({len(no_ticket_prs)})")
                lines.append("")
                for pr in no_ticket_prs:
                    lines.append(_format_release_pr_line(pr))
                lines.append("")

            hotfix_prs = release_hotfix_stgfix_team_prs.get(repo, [])
            if hotfix_prs:
                lines.append(f"### {repo} — hotfix/stgfix del equipo ({len(hotfix_prs)})")
                lines.append("")
                for pr in hotfix_prs:
                    lines.append(_format_release_pr_line(pr))
                lines.append("")

    # Staging by repo
    lines.append("## En staging (por repositorio)")
    lines.append("")
    for repo in sorted_repos:
        buckets = repo_groups[repo]
        stg = list(buckets["staging"].values())
        if not stg:
            continue

        branches = branch_map.get(repo, RepoBranches())
        lines.append(f"### {repo} ({len(stg)})")
        lines.append(f"staging: `{branches.staging or '?'}` — develop: `{branches.dev}`")
        lines.append("")
        for e in stg:
            lines.append(_format_entry(e))
        lines.append("")

    # Develop by repo
    lines.append("## Solo en develop (por repositorio)")
    lines.append("")
    for repo in sorted_repos:
        buckets = repo_groups[repo]
        dev = list(buckets["develop"].values())
        if not dev:
            continue
        branches = branch_map.get(repo, RepoBranches())
        lines.append(f"### {repo} ({len(dev)})")
        lines.append(f"staging: `{branches.staging or '?'}` — develop: `{branches.dev}`")
        lines.append("")
        for e in dev:
            lines.append(_format_entry(e))
        lines.append("")

    # Open PRs by repo
    lines.append("## PRs abiertos (por repositorio)")
    lines.append("")
    for repo in sorted_repos:
        buckets = repo_groups[repo]
        opn = list(buckets["open"].values())
        if not opn:
            continue

        branches = branch_map.get(repo, RepoBranches())
        lines.append(f"### {repo} ({len(opn)})")
        lines.append(f"staging: `{branches.staging or '?'}` — develop: `{branches.dev}`")
        lines.append("")
        for e in opn:
            lines.append(_format_entry(e))
        lines.append("")

    active_no_pr = [t for t in no_pr_tickets if t.status.lower() in ACTIVE_STATUSES]
    if active_no_pr:
        lines.append(f"## Sin PRs vinculados ({len(active_no_pr)} tickets en progreso)")
        lines.append("")
        for t in active_no_pr:
            assignee = t.assignee.split(" ")[0] if t.assignee else "sin asignar"
            jira_url = f"{JIRA_BASE}/browse/{t.key}"
            lines.append(f"- {t.summary} ({assignee}) — [{t.key}]({jira_url}) `{t.status}`")
        lines.append("")

    return "\n".join(lines)


def _format_entry(entry: GroupedEntry, repo: str | None = None) -> str:
    t = entry.ticket
    assignee = t.assignee.split(" ")[0] if t.assignee else ""
    jira_url = f"{JIRA_BASE}/browse/{t.key}"
    if entry.pr_numbers:
        if repo:
            pr_part = ", ".join(
                f"[PR#{n}](https://github.com/{ORG}/{repo}/pull/{n})" for n in sorted(entry.pr_numbers)
            )
        else:
            pr_part = ", ".join(f"PR#{n}" for n in sorted(entry.pr_numbers))
    else:
        pr_part = ""
    parts = [f"- {t.summary}"]
    if assignee:
        parts.append(f"({assignee})")
    parts.append(f"— [{t.key}]({jira_url})")
    if pr_part:
        parts.append(f"— {pr_part}")
    return " ".join(parts)


def _format_release_pr_line(pr: ReleasePrMeta) -> str:
    labels = ", ".join(pr.labels) if pr.labels else ""
    author = pr.author if pr.author else "sin autor"
    line = f"- [PR#{pr.number}]({pr.url}) {pr.title} ({author})"
    if labels:
        line += f" — labels: `{labels}`"
    return line


# ── Main ──────────────────────────────────────────────────────────────────

def main() -> None:
    if not TEAM_NAME or not TEAM_PREFIX:
        die("TEAM_NAME and TEAM_TICKET_PREFIX are required.")
    if not JIRA_EMAIL or not JIRA_TOKEN:
        die("Jira credentials required. Set JIRA_EMAIL + JIRA_API_TOKEN.", 2)

    try:
        # 0. Jira auth probe (fail loud before expensive calls)
        debug("Verifying Jira authentication...")
        jira_get("/rest/api/3/myself")

        # 1. GitHub: detect release branches
        debug("Fetching release branches...")
        branch_map: dict[str, RepoBranches] = {}
        for repo in RELEASE_REPOS + VERSION_REPOS + list(EXPLICIT_BRANCH_REPOS):
            branches = detect_branches(repo)
            if branches:
                branch_map[repo] = branches
                debug(f"{repo}: stg={branches.staging} prod={branches.prod} dev={branches.dev} dev_only={len(branches.dev_only_prs)} not_in_prod={len(branches.not_in_prod_prs)}")

        # 2. Jira: sprint tickets
        debug(f"Fetching Jira sprint tickets for {TEAM_PREFIX}...")
        all_tickets = fetch_sprint_tickets(TEAM_PREFIX)
        if not all_tickets:
            print(f"No open sprint tickets found for {TEAM_NAME}.")
            return

        debug(f"Found {len(all_tickets)} tickets (parents + subtasks)")

        # Filter: keep subtasks + standalone tickets; drop parents that have subtasks in sprint
        parent_keys_with_subtasks = {t.parent_key for t in all_tickets if t.parent_key}
        tickets = [t for t in all_tickets if t.is_subtask or t.key not in parent_keys_with_subtasks]
        debug(f"After filtering parents: {len(tickets)} work tickets ({len(all_tickets) - len(tickets)} parents skipped)")

        # 3. Jira: dev info
        keys = [t.key for t in tickets]
        debug("Fetching dev-info from Jira...")
        dev_info = fetch_dev_info(keys)
        for t in tickets:
            t.prs = dev_info.get(t.key, [])

        # 4. Classify and group
        repo_groups, no_pr = build_groups(tickets, branch_map)
        team_members = _load_team_members(TEAM_PREFIX, TEAM_NAME)
        if team_members:
            debug(f"Using {len(team_members)} github members for prod filtering")
        else:
            debug("No github members found for team; prod filtering falls back to ticket prefix only")
        release_groups, release_no_ticket_team_prs, release_hotfix_stgfix_team_prs, release_team_pr_count_by_repo = build_last_prod_release_data(
            branch_map,
            team_members=team_members,
        )

        # 5. Render
        md = render_markdown(
            TEAM_NAME,
            repo_groups,
            branch_map,
            no_pr,
            release_groups,
            release_no_ticket_team_prs,
            release_hotfix_stgfix_team_prs,
            release_team_pr_count_by_repo,
            team_members_loaded=bool(team_members),
        )
        print(md)
    except ExternalDependencyError as e:
        die(str(e), 3)


if __name__ == "__main__":
    main()
