#!/usr/bin/env python3
"""Report outdated FreeBSD ports for a maintainer using Repology and GitHub."""

# report-outdated-for-maintainer.py - a script that find outdated ports for
# a given maintainer based on Repology API and GitHub releases, and prints
# a categorized report.
#
# MAINTAINER= yuri@FreeBSD.org
#


import sys
import os
import re
import json
import time
import subprocess
import concurrent.futures
import asyncio
import urllib.request
import urllib.parse
import urllib.error
import shutil
import ssl

# --- Dependency check ---

# missing python dependencies
missing_deps = []
try:
    from tabulate2 import tabulate
except ImportError:
    missing_deps.append("tabulate2")

if missing_deps:
    py_ver = f"py{sys.version_info.major}{sys.version_info.minor}"
    pkg_names = " ".join(f"{py_ver}-{dep}" for dep in missing_deps)
    print(
        f"Missing dependencies: {', '.join(missing_deps)}. "
        f"Please install them with 'pkg install {pkg_names}' and try again."
    )
    sys.exit(1)


# --- Configuration ---

PORTSDIR = os.environ.get("PORTSDIR", "/usr/ports")
REPOLOGY_API_BASE = "https://repology.org/api/v1"
GITHUB_BASE = "https://github.com"
GITHUB_API_BASE = "https://api.github.com"
USER_AGENT = "report-outdated-for-maintainer/1.0 (FreeBSD ports maintainer tool)"
GITHUB_TOKEN = None
GIT_PARALLELISM = int(os.environ.get("GIT_PARALLELISM", 32))
GIT_SINGLE_THRESHOLD = int(os.environ.get("GIT_SINGLE_THRESHOLD", 500))

USAGE = f"""Usage: {os.path.basename(sys.argv[0])} [--disable-last-updated] <maintainer-email>
       {os.path.basename(sys.argv[0])} --help

Report outdated FreeBSD ports for a maintainer using Repology and GitHub.

Options:
  --disable-last-updated  Skip git history queries and omit the 'Last Updated' column.
  --help, -h              Show this help message and exit.

Example:
  {os.path.basename(sys.argv[0])} yuri@FreeBSD.org
"""

STALENESS_THRESHOLDS = [
    ("1 week", 7 * 24 * 3600),
    ("2 weeks", 14 * 24 * 3600),
    ("3 weeks", 21 * 24 * 3600),
    ("1 month", 30 * 24 * 3600),
    ("2 months", 60 * 24 * 3600),
    ("3 months", 90 * 24 * 3600),
    ("4 months", 120 * 24 * 3600),
    ("5 months", 150 * 24 * 3600),
    ("6 months", 180 * 24 * 3600),
    ("12 months", 365 * 24 * 3600),
    ("24 months", 730 * 24 * 3600),
]

# USES keyword → Port Type label (in priority order)
USES_TYPES = [
    ("gmake",    "gmake"),
    ("cmake",    "cmake"),
    ("meson",    "meson"),
    ("waf",      "waf"),
    ("python",   "Python"),
    ("cargo",    "Rust"),
    ("cabal",    "Haskel"),
    ("go",       "GoLang"),
    ("nodejs",   "NodeJS"),
    ("fortran",  "Fortran"),
    ("drupal",   "Drupal"),
    ("cran",     "R"),
    ("ocaml",    "Ocaml"),
    ("php",      "php"),
    ("vala",     "Vala"),
    ("ruby",     "Ruby"),
    ("perl5",    "Perl"),
    ("mono",     "Mono"),
    ("ada",      "Ada"),
    ("lazarus",  "Lazarus"),
    ("erlang",   "Erlang"),
    ("electron", "Electron"),
    ("linux",    "Linux"),
    ("kmod",     "kmod"),
    ("java",     "Java"),
]


# --- Validation ---

def check_portsdir():
    required = [
        PORTSDIR,
        os.path.join(PORTSDIR, "Makefile"),
        os.path.join(PORTSDIR, "Mk"),
        os.path.join(PORTSDIR, "MOVED"),
        os.path.join(PORTSDIR, "devel"),
        os.path.join(PORTSDIR, "devel", "Makefile"),
    ]
    for path in required:
        if not os.path.exists(path):
            print(f"Error: Required path not found: {path}", file=sys.stderr)
            sys.exit(1)


def check_required_commands(need_git=True):
    """Check that required external commands are available."""
    missing = []
    if not shutil.which("gh"):
        missing.append("gh")
    if need_git and not shutil.which("git"):
        missing.append("git")
    if missing:
        print(
            f"Missing required commands: {', '.join(missing)}. "
            "Please install them and try again."
        )
        sys.exit(1)


def parse_args():
    """Parse command-line arguments and return (maintainer_email, disable_last_updated)."""
    args = sys.argv[1:]
    if "--help" in args or "-h" in args:
        print(USAGE)
        sys.exit(0)

    disable_last_updated = False
    maintainer_email = None
    for arg in args:
        if arg == "--disable-last-updated":
            disable_last_updated = True
        elif arg.startswith("-"):
            print(f"Unknown option: {arg}\n{USAGE}", file=sys.stderr)
            sys.exit(1)
        elif maintainer_email is None:
            maintainer_email = arg
        else:
            print(USAGE, file=sys.stderr)
            sys.exit(1)

    if not maintainer_email:
        print(USAGE, file=sys.stderr)
        sys.exit(1)

    return maintainer_email, disable_last_updated


def get_github_token():
    """Return a GitHub token from env or gh auth, if available."""
    global GITHUB_TOKEN
    if GITHUB_TOKEN is not None:
        return GITHUB_TOKEN

    GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
    if GITHUB_TOKEN:
        return GITHUB_TOKEN

    try:
        result = subprocess.run(
            ["gh", "auth", "token"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        token = result.stdout.strip()
        if result.returncode == 0 and token:
            GITHUB_TOKEN = token
            return GITHUB_TOKEN
    except Exception:
        pass

    GITHUB_TOKEN = ""
    return None


# --- Ports tree parsing ---

def get_subdir_entries(makefile_path):
    """Return list of SUBDIR += entries from a Makefile."""
    entries = []
    try:
        with open(makefile_path, "r", errors="replace") as f:
            content = f.read()
        for m in re.finditer(r"^\s*SUBDIR\s*\+=\s*(\S+)", content, re.MULTILINE):
            entries.append(m.group(1))
    except OSError:
        pass
    return entries


def read_makefile_logical_lines(makefile_path):
    """Read a Makefile and return a list of logical lines (continuations joined)."""
    try:
        with open(makefile_path, "r", errors="replace") as f:
            content = f.read()
    except OSError:
        return []
    # Join continuation lines
    content = re.sub(r"\\\n", " ", content)
    return content.splitlines()


def get_makefile_var(makefile_path, var):
    """Extract a simple variable value from a Makefile (first assignment found)."""
    for line in read_makefile_logical_lines(makefile_path):
        m = re.match(rf"^\s*{re.escape(var)}\s*(?::|\?)?=\s*(.+)$", line)
        if m:
            return re.sub(r"\s+#.*$", "", m.group(1)).strip()
    return None


def get_uses_types(makefile_path):
    """Return a comma-separated string of port types based on USES= in the Makefile."""
    uses_tokens = set()
    for line in read_makefile_logical_lines(makefile_path):
        # Match both USES= and USES+=
        m = re.match(r"^\s*USES\s*\+?=\s*(.+)$", line)
        if m:
            for token in m.group(1).split():
                # Strip :options suffix for matching
                uses_tokens.add(token.split(":")[0])

    types = []
    for keyword, label in USES_TYPES:
        if keyword in uses_tokens:
            types.append(label)
    return ", ".join(types)


def resolve_make_value(value, portname):
    """Resolve simple ${PORTNAME} substitutions in Makefile values."""
    if value is None:
        return None

    def replacer(match):
        modifier = match.group(1)
        if modifier == ":tl":
            return portname.lower()
        if modifier == ":tu":
            return portname.upper()
        return portname

    return re.sub(r"\$\{PORTNAME(?:(:tl|:tu))?\}", replacer, value)


def get_github_repo(makefile_path):
    """Return (account, project) for USE_GITHUB=yes ports or from WWW field, else None."""
    use_github = get_makefile_var(makefile_path, "USE_GITHUB")
    if use_github and use_github.lower() == "yes":
        portname = get_makefile_var(makefile_path, "PORTNAME")
        if not portname:
            return None

        gh_account = get_makefile_var(makefile_path, "GH_ACCOUNT") or portname
        gh_project = get_makefile_var(makefile_path, "GH_PROJECT") or portname
        gh_account = resolve_make_value(gh_account.split()[0], portname)
        gh_project = resolve_make_value(gh_project.split()[0], portname)
        return gh_account, gh_project
    
    # If USE_GITHUB is not set, try to extract from WWW field, but only for ports
    # that appear to use GitHub releases and are explicitly marked with a comment
    # or have DISTVERSION matching a GitHub-style tag pattern (YYYY.M.DD or similar)
    version = get_makefile_var(makefile_path, "DISTVERSION") or get_makefile_var(makefile_path, "PORTVERSION")
    if version and looks_like_version(version):
        # Only query from WWW if version matches GitHub-style patterns:
        # - YYYY.M.DD (date-based like 2026.4.26, 2026.4.29)
        # - X.Y.Z.W (4+ numeric components)
        # This filters out most non-GitHub ports while catching real GitHub releases
        parts = re.findall(r"\d+", version)
        if len(parts) >= 3:
            # Check if it looks like YYYY.M.DD (year >= 2000)
            if int(parts[0]) >= 2000 and int(parts[0]) <= 2099:
                if int(parts[1]) >= 1 and int(parts[1]) <= 12:
                    if int(parts[2]) >= 1 and int(parts[2]) <= 31:
                        # Looks like a date-based version from GitHub
                        www = get_makefile_var(makefile_path, "WWW")
                        if www:
                            m = re.match(r"https?://(?:www\.)?github\.com/([^/]+)/([^/\s]+)", www)
                            if m:
                                return m.group(1), m.group(2)
            # Also check for simple 4+ component versions (e.g., 1.2.3.4)
            elif len(parts) >= 4:
                www = get_makefile_var(makefile_path, "WWW")
                if www:
                    m = re.match(r"https?://(?:www\.)?github\.com/([^/]+)/([^/\s]+)", www)
                    if m:
                        return m.group(1), m.group(2)
    
    return None


# --- Version handling ---

VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
G_VERSION_RE = re.compile(r"^g[a-zA-Z0-9]+$")
YYYYMMDD_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")


def looks_like_version(s):
    return bool(s and VERSION_RE.match(s) and any(ch.isdigit() for ch in s))


def compute_aux_version(real_version, latest_effective=None):
    """
    Return the auxiliary version string if it differs from real_version, else None.

    - gNNNNN  → 0.0.0... (matching component count of latest_effective)
    - YYYYMMDD → YYYY.MM.DD
    """
    if G_VERSION_RE.match(real_version) and latest_effective is not None:
        parts = parse_version(latest_effective)
        n = max(len(parts), 1)
        return ".".join(["0"] * n)

    m = YYYYMMDD_RE.match(real_version)
    if m:
        year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if 1900 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31:
            return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"

    return None


def display_version(real, aux):
    """Return 'real (aux)' if aux differs from real, else just 'real'."""
    if aux and aux != real:
        return f"{real} ({aux})"
    return real


def parse_version(version_str):
    """Parse version string into a tuple of ints."""
    parts = [int(part) for part in re.findall(r"\d+", version_str)]
    return tuple(parts) if parts else (0,)


def compare_versions(left_version, right_version):
    """Return 1 if left>right, -1 if left<right, 0 if equal by numeric components."""
    left = parse_version(left_version)
    right = parse_version(right_version)
    max_len = max(len(left), len(right), 3)
    left = left + (0,) * (max_len - len(left))
    right = right + (0,) * (max_len - len(right))
    if left > right:
        return 1
    if left < right:
        return -1
    return 0


def categorize_outdatedness(current_effective, latest_effective):
    """
    Compare effective (auxiliary) versions.
    Returns (category, level) or None if not outdated.
    """
    if compare_versions(latest_effective, current_effective) <= 0:
        return None

    cur = parse_version(current_effective)
    lat = parse_version(latest_effective)
    max_len = max(len(cur), len(lat), 3)
    cur = cur + (0,) * (max_len - len(cur))
    lat = lat + (0,) * (max_len - len(lat))

    if lat[0] > cur[0]:
        return ("major", lat[0] - cur[0])

    if lat[1] > cur[1]:
        return ("minor", lat[1] - cur[1])

    for i in range(2, max_len):
        if lat[i] > cur[i]:
            return ("patch", lat[i] - cur[i])
        if lat[i] < cur[i]:
            break

    return ("patch", 1)


# --- Ports scanning ---

def get_port_version_from_makefile(makefile_path):
    """Try PORTVERSION then DISTVERSION; return value if it looks like a version."""
    for var in ("PORTVERSION", "DISTVERSION"):
        val = get_makefile_var(makefile_path, var)
        if val and looks_like_version(val):
            return val
    return None


def get_port_version_via_make(port_dir):
    """Run 'make -V PORTVERSION' in port_dir and return the result."""
    try:
        result = subprocess.run(
            ["make", "BATCH=yes", "-V", "PORTVERSION"],
            cwd=port_dir,
            capture_output=True,
            text=True,
            timeout=30,
        )
        v = result.stdout.strip()
        if looks_like_version(v):
            return v
    except Exception:
        pass
    return None


def find_maintained_ports(maintainer_email):
    """
    Return (versions, uses_types, github_repos, github_version_prefixes) keyed by port origin.
    versions[origin] = real version string
    uses_types[origin] = comma-separated port type string
    github_repos[origin] = (account, project) for USE_GITHUB ports
    github_version_prefixes[origin] = (prefix, suffix) to strip from GitHub release tags
    """
    versions = {}
    uses_types = {}
    github_repos = {}
    github_version_prefixes = {}
    top_makefile = os.path.join(PORTSDIR, "Makefile")
    categories = get_subdir_entries(top_makefile)

    for category in categories:
        cat_makefile = os.path.join(PORTSDIR, category, "Makefile")
        if not os.path.isfile(cat_makefile):
            continue
        port_dirs = get_subdir_entries(cat_makefile)
        for port_dir in port_dirs:
            port_path = os.path.join(PORTSDIR, category, port_dir)
            port_makefile = os.path.join(port_path, "Makefile")
            if not os.path.isfile(port_makefile):
                continue
            maintainer = get_makefile_var(port_makefile, "MAINTAINER")
            if not maintainer or maintainer.lower() != maintainer_email.lower():
                continue
            origin = f"{category}/{port_dir}"
            version = get_port_version_from_makefile(port_makefile)
            if version is None:
                version = get_port_version_via_make(port_path)
            if version is None:
                print(
                    f"Warning: Could not determine version for {origin}, skipping.",
                    file=sys.stderr,
                )
                continue
            versions[origin] = version
            uses_types[origin] = get_uses_types(port_makefile)
            github_repo = get_github_repo(port_makefile)
            if github_repo:
                github_repos[origin] = github_repo
                portname = get_makefile_var(port_makefile, "PORTNAME") or ""
                prefix_raw = get_makefile_var(port_makefile, "DISTVERSIONPREFIX") or ""
                suffix_raw = get_makefile_var(port_makefile, "DISTVERSIONSUFFIX") or ""
                prefix = resolve_make_value(prefix_raw, portname)
                suffix = resolve_make_value(suffix_raw, portname)
                if prefix or suffix:
                    github_version_prefixes[origin] = (prefix, suffix)

    return versions, uses_types, github_repos, github_version_prefixes


# --- Git last-update queries ---

async def _find_last_updates_per_origin(origins):
    """Per-origin streaming git log, killing each process once a bump is found."""
    if not origins:
        return {}

    bump_re = re.compile(r"^(\+PORTVERSION=|\+DISTVERSION=)")
    semaphore = asyncio.Semaphore(GIT_PARALLELISM)

    async def _run_for_origin(origin):
        async with semaphore:
            makefile_rel = f"{origin}/Makefile"
            args = [
                "-C",
                PORTSDIR,
                "--no-optional-locks",
                "log",
                "-p",
                "-m",
                "--pretty=format:COMMIT:%H %ct",
                "--",
                makefile_rel,
            ]
            try:
                proc = await asyncio.create_subprocess_exec(
                    "git",
                    *args,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
            except Exception:
                return origin, None

            current_time = None
            timestamp = None

            while True:
                try:
                    line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=60)
                except asyncio.TimeoutError:
                    break
                if not line_b:
                    break
                line = line_b.decode(errors="replace").rstrip("\n")

                if line.startswith("COMMIT:"):
                    parts = line[len("COMMIT:"):].split()
                    current_time = parts[1] if len(parts) >= 2 else None
                elif current_time and bump_re.match(line):
                    try:
                        timestamp = int(current_time)
                    except ValueError:
                        pass
                    break

            try:
                if proc.returncode is None:
                    proc.kill()
                    await proc.wait()
            except Exception:
                pass

            return origin, timestamp

    tasks = [_run_for_origin(origin) for origin in origins]
    results = await asyncio.gather(*tasks)

    last_updates = {}
    for origin, timestamp in results:
        if timestamp is not None:
            last_updates[origin] = timestamp
    return last_updates


async def _find_last_updates_single_log(origins):
    """One repository-wide git log -G parse, with per-origin fallback."""
    if not origins:
        return {}

    target_files = {f"{origin}/Makefile" for origin in origins}
    bump_re = re.compile(r"^(\+PORTVERSION=|\+DISTVERSION=)")

    args = [
        "-C",
        PORTSDIR,
        "--no-optional-locks",
        "log",
        "-p",
        "-m",
        "-G",
        "^PORTVERSION=|^DISTVERSION=",
        "--pretty=format:COMMIT:%H %ct",
        "--",
        "*/Makefile",
    ]

    last_updates = {}
    current_commit = None
    current_time = None
    current_file = None
    current_has_bump = False

    try:
        proc = await asyncio.create_subprocess_exec(
            "git",
            *args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
    except Exception:
        return {}

    def _flush():
        nonlocal current_has_bump
        if (
            current_file
            and current_commit
            and current_time
            and current_has_bump
            and current_file in target_files
            and current_file not in last_updates
        ):
            try:
                last_updates[current_file] = int(current_time)
            except ValueError:
                pass
        current_has_bump = False

    while True:
        try:
            line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=600)
        except asyncio.TimeoutError:
            break
        if not line_b:
            break
        line = line_b.decode(errors="replace").rstrip("\n")

        if line.startswith("COMMIT:"):
            _flush()
            current_file = None
            parts = line[len("COMMIT:"):].split()
            current_commit, current_time = (
                (parts[0], parts[1]) if len(parts) >= 2 else (None, None)
            )
        elif line.startswith("diff --git"):
            _flush()
            match = re.match(r"^diff --git a/(.+) b/(.+)$", line)
            current_file = match.group(2) if match else None
        elif current_commit and current_file and bump_re.match(line):
            current_has_bump = True

    _flush()

    try:
        if proc.returncode is None:
            proc.kill()
            await proc.wait()
    except Exception:
        pass

    # Fallback for renamed/moved Makefiles that the single -G log does not catch.
    missing = [origin for origin in origins if f"{origin}/Makefile" not in last_updates]
    if missing:
        fallback = await _find_last_updates_per_origin(missing)
        last_updates.update({f"{origin}/Makefile": ts for origin, ts in fallback.items()})

    return {path[:-len("/Makefile")]: ts for path, ts in last_updates.items()}


async def _find_last_updates(origins):
    """Return {origin: timestamp} for the last version-bumping commit of each origin.

    For large maintainer lists, use a single repository-wide ``git log -G``
    query; for small lists, use per-port streaming git logs.  A per-origin
    fallback handles renamed/moved Makefiles that the single log may miss.
    """
    if len(origins) > GIT_SINGLE_THRESHOLD:
        return await _find_last_updates_single_log(origins)
    return await _find_last_updates_per_origin(origins)


def format_last_updated(timestamp):
    """Format a Unix timestamp as minutes, hours, or days since that time."""
    if timestamp is None:
        return ""
    now = time.time()
    elapsed_seconds = now - timestamp
    if elapsed_seconds < 3600:
        minutes = int(elapsed_seconds / 60)
        return f"{minutes} minute{'s' if minutes != 1 else ''}"
    elapsed_hours = elapsed_seconds / 3600
    if elapsed_hours < 24:
        hours = int(elapsed_hours)
        return f"{hours} hour{'s' if hours != 1 else ''}"
    days = int(elapsed_hours / 24)
    return f"{days} day{'s' if days != 1 else ''}"


# --- Repology API ---

def repology_request(url):
    """Make a single GET request to the Repology API; return parsed JSON."""
    ctx = ssl.create_default_context()
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        print(f"Error: Repology API returned HTTP {e.code}: {e.reason}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"Error: Could not reach Repology API: {e.reason}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error querying Repology: {e}", file=sys.stderr)
        sys.exit(1)


def query_repology_outdated(maintainer_email):
    """
    Return dict {origin: latest_version} for FreeBSD ports that Repology
    considers outdated for this maintainer.
    """
    encoded_email = urllib.parse.quote(maintainer_email.lower(), safe="@")
    base_params = f"maintainer={encoded_email}&inrepo=freebsd&outdated=true"
    url = f"{REPOLOGY_API_BASE}/projects/?{base_params}"
    outdated = {}

    while url:
        data = repology_request(url)
        if not data:
            break

        for project_name, packages in data.items():
            freebsd_outdated = None
            newest_version = None

            for pkg in packages:
                if pkg.get("repo") == "freebsd" and pkg.get("status") == "outdated":
                    freebsd_outdated = pkg
                if pkg.get("status") == "newest" and newest_version is None:
                    newest_version = pkg.get("version")

            if freebsd_outdated and newest_version:
                srcname = freebsd_outdated.get("srcname") or freebsd_outdated.get("visiblename")
                if srcname:
                    outdated[srcname] = newest_version

        if len(data) == 200:
            last_project = list(data.keys())[-1]
            encoded_last = urllib.parse.quote(last_project, safe="")
            url = f"{REPOLOGY_API_BASE}/projects/{encoded_last}/?{base_params}"
            time.sleep(0.5)  # be polite to the API
        else:
            url = None

    return outdated


def github_request(url, method="GET"):
    """Make a single GitHub request and return the response object."""
    headers = {
        "User-Agent": USER_AGENT,
        "Accept": "application/vnd.github+json",
    }
    token = get_github_token()
    if token:
        headers["Authorization"] = f"Bearer {token}"
    req = urllib.request.Request(url, headers=headers, method=method)
    return urllib.request.urlopen(req, timeout=30)


def query_github_latest_release(account, project, cache):
    """Return the latest stable GitHub release tag for account/project, or None."""
    key = (account, project)
    if key in cache:
        return cache[key]

    # Use 'gh' CLI for better rate limit handling and authentication
    try:
        result = subprocess.run(
            ["gh", "release", "view", "--repo", f"{account}/{project}"],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode == 0:
            for line in result.stdout.split("\n"):
                if line.startswith("tag:"):
                    tag = line.split(":", 1)[1].strip()
                    cache[key] = tag.strip() or None
                    return cache[key]
        cache[key] = None
        return None
    except Exception:
        cache[key] = None
        return None


def query_github_releases(github_repos, version_prefixes=None):
    """Return dict {origin: latest_release_tag} for GitHub-based ports with releases."""
    cache = {}
    latest_releases = {}

    unique_repos = sorted(set(github_repos.values()))
    # gh CLI handles rate limiting better than direct API calls, so we can use more workers
    max_workers = min(8, len(unique_repos)) if unique_repos else 1
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_map = {
            executor.submit(query_github_latest_release, account, project, cache): (account, project)
            for account, project in unique_repos
        }
        for future in concurrent.futures.as_completed(future_map):
            future.result()

    for origin, repo in github_repos.items():
        latest = cache.get(repo)
        if latest:
            if version_prefixes and origin in version_prefixes:
                prefix, suffix = version_prefixes[origin]
                if prefix and latest.startswith(prefix):
                    latest = latest[len(prefix):]
                if suffix and latest.endswith(suffix):
                    latest = latest[:-len(suffix)]
            latest_releases[origin] = latest
    return latest_releases


# --- Report formatting ---

SEP = "============="


def format_table_with_blank_header_line(rows, headers):
    """Render tabulate 'simple' table with a blank line inserted after the header separator."""
    raw = tabulate(rows, headers=headers, tablefmt="simple")
    lines = raw.splitlines()
    # simple format: line 0 = header names, line 1 = dashes, line 2+ = data
    if len(lines) >= 2:
        lines.insert(2, "")
    return "\n".join(lines)


def choose_latest_version(repology_real, github_real):
    """Choose the highest latest version from Repology/GitHub and return display info."""
    candidates = []
    if repology_real:
        repology_aux = compute_aux_version(repology_real)
        repology_effective = repology_aux if repology_aux else repology_real
        candidates.append(("Repology", repology_real, repology_aux, repology_effective))
    if github_real:
        github_aux = compute_aux_version(github_real)
        github_effective = github_aux if github_aux else github_real
        candidates.append(("GitHub", github_real, github_aux, github_effective))

    if not candidates:
        return None

    best = candidates[0]
    for candidate in candidates[1:]:
        cmp = compare_versions(candidate[3], best[3])
        if cmp > 0 or (cmp == 0 and candidate[0] == "Repology" and best[0] != "Repology"):
            best = candidate

    notes = [candidate[0] for candidate in candidates if compare_versions(candidate[3], best[3]) == 0]
    return {
        "real": best[1],
        "aux": best[2],
        "effective": best[3],
        "notes": ", ".join(notes),
    }


def print_report(
    maintainer_email,
    local_versions,
    local_uses,
    outdated_repology,
    github_latest,
    last_updates,
    disable_last_updated=False,
):
    total_ports = len(local_versions)

    headers = [
        "Port origin",
        "Latest released version",
        "Current port version",
        "Outdatedness level",
        "Port Type",
        "Notes",
    ]
    if not disable_last_updated:
        headers.insert(5, "Last Updated")

    major_ports = []
    minor_ports = []
    patch_ports = []
    outdated_origins = []
    stale_port_data = []  # (origin, outdatedness_level, last_update_timestamp)

    for origin, local_real in local_versions.items():
        latest = choose_latest_version(outdated_repology.get(origin), github_latest.get(origin))
        if latest is None:
            continue

        # Compute auxiliary version for local (gNNNNN needs latest_effective for depth)
        local_aux = compute_aux_version(local_real, latest["effective"])
        local_effective = local_aux if local_aux else local_real

        result = categorize_outdatedness(local_effective, latest["effective"])
        if result is None:
            continue
        category, level = result

        port_type = local_uses.get(origin, "")
        row = [
            origin,
            display_version(latest["real"], latest["aux"]),
            display_version(local_real, local_aux),
            level,
            port_type,
            latest["notes"],
        ]
        if not disable_last_updated:
            row.insert(5, format_last_updated(last_updates.get(origin)))
        row = tuple(row)

        if origin in last_updates:
            stale_port_data.append((origin, level, last_updates[origin]))

        outdated_origins.append(origin)
        if category == "major":
            major_ports.append(row)
        elif category == "minor":
            minor_ports.append(row)
        else:
            patch_ports.append(row)

    def sort_key(row):
        return (-row[3], row[0])

    major_ports.sort(key=sort_key)
    minor_ports.sort(key=sort_key)
    patch_ports.sort(key=sort_key)

    n_major = len(major_ports)
    n_minor = len(minor_ports)
    n_patch = len(patch_ports)
    n_outdated = n_major + n_minor + n_patch

    # Title
    print(f"Outdated ports for maintainer {maintainer_email}")
    print()
    print()

    # Summary line
    pct = (lambda n: f"{n / total_ports * 100:.2f}%") if total_ports > 0 else (lambda n: "0.00%")

    print(
        f"The maintainer has {n_outdated} ({pct(n_outdated)}) outdated ports, categorized as follows: "
        f"{n_major} ({pct(n_major)}) majorly outdated, "
        f"{n_minor} ({pct(n_minor)}) moderately outdated, "
        f"and {n_patch} ({pct(n_patch)}) slightly outdated."
    )
    print()

    for title, rows in [
        ("Majorly outdated ports", major_ports),
        ("Moderately outdated ports", minor_ports),
        ("Slightly outdated ports", patch_ports),
    ]:
        print(f"{SEP}{title}{SEP}")
        print()
        if rows:
            print(format_table_with_blank_header_line(rows, headers))
        else:
            print("No ports in this category")
        print()

    if not disable_last_updated and outdated_origins:
        now = time.time()
        stale_counts = []
        raw_counts = []
        stale_pct = (lambda n: f"{n / total_ports * 100:.1f}%") if total_ports > 0 else (lambda n: "0.0%")
        for label, seconds in STALENESS_THRESHOLDS:
            count = sum(
                1
                for origin in outdated_origins
                if origin in last_updates and (now - last_updates[origin]) >= seconds
            )
            stale_counts.append((label, f"{count} ({stale_pct(count)})"))
            raw_counts.append(count)

        print(f"{SEP}Staleness Analysis{SEP}")
        print()
        if any(raw_counts):
            print(format_table_with_blank_header_line(stale_counts, ["Stale for Longer Than", "Count"]))
        else:
            print("No stale ports with last update information available.")
        print()

    if not disable_last_updated and stale_port_data:
        now = time.time()

        # Non-overlapping staleness buckets in chronological order.
        bucket_intervals = [
            (f"< {STALENESS_THRESHOLDS[0][0]}", 0, STALENESS_THRESHOLDS[0][1]),
        ]
        for i in range(len(STALENESS_THRESHOLDS) - 1):
            label = f"{STALENESS_THRESHOLDS[i][0]} .. {STALENESS_THRESHOLDS[i + 1][0]}"
            bucket_intervals.append(
                (label, STALENESS_THRESHOLDS[i][1], STALENESS_THRESHOLDS[i + 1][1])
            )
        bucket_intervals.append((f"> {STALENESS_THRESHOLDS[-1][0]}", STALENESS_THRESHOLDS[-1][1], sys.maxsize))

        buckets_rows = []
        for period, lower, upper in bucket_intervals:
            bucket_ports = [
                (origin, level, timestamp)
                for origin, level, timestamp in stale_port_data
                if (age := now - timestamp) >= lower and age < upper
            ]
            if not bucket_ports:
                continue
            bucket_ports.sort(key=lambda x: (-x[2], x[0]))
            count = len(bucket_ports)
            for idx, (origin, level, timestamp) in enumerate(bucket_ports):
                last_updated_str = format_last_updated(timestamp)
                if idx == 0:
                    buckets_rows.append([period, count, origin, last_updated_str])
                else:
                    buckets_rows.append(["", "", origin, last_updated_str])

        print(f"{SEP}Ports by Time of Staleness{SEP}")
        print()
        if buckets_rows:
            print(format_table_with_blank_header_line(
                buckets_rows,
                ["Time Period", "Count", "Port origin", "Last Updated"],
            ))
        else:
            print("No stale ports with last update information available.")
        print()



# --- Main ---

def main():
    maintainer_email, disable_last_updated = parse_args()
    check_required_commands(need_git=not disable_last_updated)
    check_portsdir()

    print(f"Scanning {PORTSDIR} for ports maintained by {maintainer_email}...", file=sys.stderr)
    local_versions, local_uses, github_repos, github_version_prefixes = find_maintained_ports(maintainer_email)

    if not local_versions:
        print(f"No ports maintained by {maintainer_email}")
        sys.exit(0)

    print(
        f"Found {len(local_versions)} local ports ({len(github_repos)} GitHub-based). Querying Repology and GitHub...",
        file=sys.stderr,
    )
    outdated_repology = query_repology_outdated(maintainer_email)
    github_latest = query_github_releases(github_repos, github_version_prefixes)
    print(f"Repology reports {len(outdated_repology)} outdated FreeBSD ports.", file=sys.stderr)
    print(f"GitHub provides latest releases for {len(github_latest)} GitHub-based ports.", file=sys.stderr)

    last_updates = {}
    if not disable_last_updated:
        print("Querying git history for last port updates...", file=sys.stderr)
        if local_versions:
            last_updates = asyncio.run(_find_last_updates(local_versions.keys()))
        print(f"Found last update information for {len(last_updates)} ports.", file=sys.stderr)

    print_report(
        maintainer_email,
        local_versions,
        local_uses,
        outdated_repology,
        github_latest,
        last_updates,
        disable_last_updated=disable_last_updated,
    )


if __name__ == "__main__":
    main()
