#!/usr/bin/env python3
"""multi_course_status.py — Cross-course standup + pending aggregator.

Reads courses/*/course-memory.md + build-progress.json + registry.json
and produces a unified status view for daily check-in across course builds.

Usage:
    python3 scripts/multi_course_status.py                     # standup (default)
    python3 scripts/multi_course_status.py --pending           # full pending sections
    python3 scripts/multi_course_status.py --course-id 256162  # filter
    python3 scripts/multi_course_status.py --blocked           # include blocked items
    python3 scripts/multi_course_status.py --json              # machine-readable
"""

import argparse
import json
import os
import re
import sys
from datetime import date, timedelta
from pathlib import Path

sys.path.insert(0, os.path.dirname(__file__))
try:
    from canvas_api import get_plugin_root, validate_course_id
except Exception:
    def get_plugin_root():
        return Path(__file__).parent.parent


# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------

def _parse_frontmatter(text):
    """Return (meta_dict, body_text). Skips malformed YAML gracefully."""
    meta = {}
    body = text
    if text.startswith("---"):
        end = text.find("\n---", 3)
        if end != -1:
            yaml_block = text[3:end].strip()
            body = text[end + 4:].lstrip("\n")
            for line in yaml_block.splitlines():
                if ":" in line:
                    _pf_key = line.split(":", 1)
                    key = _pf_key[0].strip() if _pf_key else ""
                    val = _pf_key[1].strip() if len(_pf_key) > 1 else ""
                    meta[key.strip()] = val.strip().strip('"')
    return meta, body


def _extract_section(body, heading_prefix):
    """Return text of the first heading matching prefix (## Pending...), else ''."""
    lines = body.splitlines()
    in_section = False
    section_lines = []
    for line in lines:
        if in_section:
            if line.startswith("## ") and not line.lower().startswith(heading_prefix.lower()):
                break
            section_lines.append(line)
        elif re.match(r"^## " + re.escape(heading_prefix.lstrip("## ")), line, re.IGNORECASE):
            in_section = True
            section_lines.append(line)
    return "\n".join(section_lines)


def _extract_current_state(body):
    """Return compact prose summary of ## Current State section."""
    section = _extract_section(body, "## Current State")
    lines = [l for l in section.splitlines()[1:] if l.strip()]
    cleaned = []
    for line in lines[:3]:
        text = re.sub(r"^[-*•]\s+", "", line.strip())
        text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
        text = re.sub(r"`[^`]+`", "...", text)
        text = re.split(r"\(", text)[0].strip().rstrip(".")
        if text:
            cleaned.append(text)
    return "; ".join(cleaned[:2]) if cleaned else ""


def _extract_pending_section(body):
    """Return all ## Pending* sections verbatim, or ''."""
    lines = body.splitlines()
    in_section = False
    section_lines = []
    for line in lines:
        if re.match(r"^## ", line):
            in_section = bool(re.match(r"^## Pending", line, re.IGNORECASE))
        if in_section:
            section_lines.append(line)
    return "\n".join(section_lines)


def _parse_pending_items(pending_text):
    """Return (actionable_list, blocked_list) of bullet texts."""
    lines = pending_text.splitlines()
    in_blocked = False
    actionable = []
    blocked = []
    for line in lines:
        # Detect blocked subsection header
        if re.match(r"^#{2,4}\s+Blocked", line, re.IGNORECASE) or \
           re.match(r"^\*\*Blocked", line, re.IGNORECASE):
            in_blocked = True
            continue
        # Any other markdown heading starts a non-blocked pending group.
        if re.match(r"^#{2,4}\s+", line):
            in_blocked = False
            continue
        # Detect can-do subsection resets in-blocked
        if re.match(r"^#{2,4}\s+Can do", line, re.IGNORECASE) or \
           re.match(r"^#{2,4}\s+Now", line, re.IGNORECASE):
            in_blocked = False
            continue
        # Bullet lines
        m = re.match(r"^\s*[-*•]\s+(.+)", line)
        if m:
            text = m.group(1).strip()
            is_blocked = in_blocked or bool(re.search(r"\bblocked\b", text, re.IGNORECASE))
            if is_blocked:
                blocked.append(text)
            else:
                actionable.append(text)
    return actionable, blocked


def _build_progress_pct(progress_path):
    """Return progress % string or None."""
    if not os.path.exists(progress_path):
        return None
    try:
        with open(progress_path) as f:
            data = json.load(f)
        modules = data.get("modules", {})
        if not modules:
            return "0%"
        total = 0
        done = 0
        for mod in modules.values():
            pages = mod.get("pages", {})
            for page in pages.values():
                total += 1
                if page.get("status") == "complete":
                    done += 1
        if total == 0:
            return "0%"
        return f"{int(done / total * 100)}%"
    except Exception:
        return None


def _open_questions_summary(root, course_id):
    """Return summary string for open faculty questions, or ''."""
    path = os.path.join(root, "courses", str(course_id), "open_questions.jsonl")
    if not os.path.exists(path):
        return ""
    open_records = []
    try:
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    record = json.loads(line)
                except json.JSONDecodeError:
                    continue
                if record.get("status") == "open":
                    open_records.append(record)
    except OSError:
        return ""
    if not open_records:
        return ""
    open_records.sort(key=lambda r: r.get("created_at", ""))
    oldest = open_records[0]
    date = (oldest.get("created_at") or "unknown")[:10]
    owner = oldest.get("owner") or "unknown"
    return f"Open Qs: {len(open_records)} (oldest {date} to @{owner})"


def _has_activity_without_memory(root, course_id):
    """Return True when a local course dir has work artifacts but no memory."""
    course_dir = os.path.join(root, "courses", str(course_id))
    if not os.path.isdir(course_dir):
        return False
    activity_files = (
        "course-config.json",
        "timeline.jsonl",
        "build-progress.json",
        "open_questions.jsonl",
    )
    if any(os.path.exists(os.path.join(course_dir, f)) for f in activity_files):
        return True
    activity_dirs = ("staging", "backups", "mockup", "reports")
    for dirname in activity_dirs:
        path = os.path.join(course_dir, dirname)
        if os.path.isdir(path) and any(os.scandir(path)):
            return True
    return False


def _memory_warning(root, course_id, has_memory):
    """Return a course-memory warning string, or ''. Does not create files."""
    if has_memory or not _has_activity_without_memory(root, course_id):
        return ""
    return (
        "Memory missing: local course activity exists but course-memory.md "
        "is absent. Run: python3 scripts/init_course_memory.py "
        f"--course-id {course_id}"
    )


def _clean_bullet(text):
    """Strip markdown bold and checkbox markers for clean display."""
    text = re.sub(r"\[.\]\s*", "", text)          # [ ] or [x]
    text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)  # **bold**
    text = re.sub(r"`(.*?)`", r"\1", text)        # `code`
    return text.strip()


# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------

def load_registry(root):
    reg_path = os.path.join(root, "courses", "registry.json")
    if not os.path.exists(reg_path):
        return {}
    with open(reg_path) as f:
        data = json.load(f)
    return {str(c["id"]): c for c in data.get("courses", [])}


def load_course_data(root, course_id, registry):
    """Return dict with all parsed data for a single course."""
    cid = str(course_id)
    mem_path = os.path.join(root, "courses", cid, "course-memory.md")
    prog_path = os.path.join(root, "courses", cid, "build-progress.json")

    reg_entry = registry.get(cid, {})
    fallback_name = reg_entry.get("name", f"Course {cid}")

    has_memory = os.path.exists(mem_path)
    meta = {}
    body = ""
    if has_memory:
        with open(mem_path) as f:
            text = f.read()
        meta, body = _parse_frontmatter(text)

    name = meta.get("course") or fallback_name
    last_active = meta.get("last_active", "")
    session = meta.get("session", "")
    status = (meta.get("status") or "active").lower()
    current_state = _extract_current_state(body) if body else ""
    pending_text = _extract_pending_section(body) if body else ""
    actionable, blocked = _parse_pending_items(pending_text) if pending_text else ([], [])
    build_pct = _build_progress_pct(prog_path)
    open_questions = _open_questions_summary(root, cid)
    memory_warning = _memory_warning(root, cid, has_memory)

    # Mockup status
    mockup_manifest_path = os.path.join(root, "courses", cid, "mockup", "manifest.json")
    mockup_status = None
    mockup_last_change = None
    if os.path.exists(mockup_manifest_path):
        try:
            with open(mockup_manifest_path) as _mf:
                _manifest = json.load(_mf)
            mockup_status = _manifest.get("status")
            _lsc = _manifest.get("last_status_change", "")
            if _lsc:
                mockup_last_change = str(_lsc)[:10]  # date portion only
        except Exception:
            pass

    return {
        "id": cid,
        "name": name,
        "last_active": last_active,
        "session": session,
        "status": status,
        "current_state": current_state,
        "actionable": actionable,
        "blocked": blocked,
        "pending_text": pending_text,
        "build_pct": build_pct,
        "open_questions": open_questions,
        "has_memory": has_memory,
        "memory_warning": memory_warning,
        "mockup_status": mockup_status,
        "mockup_last_change": mockup_last_change,
    }


def discover_course_ids(root, registry):
    """Return sorted list of course IDs that have a courses/ subdirectory."""
    courses_dir = os.path.join(root, "courses")
    ids = set()
    if os.path.isdir(courses_dir):
        for entry in os.scandir(courses_dir):
            if entry.is_dir() and re.match(r"^\d+$", entry.name):
                ids.add(entry.name)
    # Also include all registry entries (may not have local dir)
    ids.update(registry.keys())
    return sorted(ids, key=lambda x: int(x))


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Deadline helpers (--deadlines flag)
# ---------------------------------------------------------------------------

def _check_calendar_staleness(root):
    """Print a warning if asu_calendar.json generated_at is older than 180 days."""
    cal_path = os.path.join(root, "data", "asu_calendar.json")
    if not os.path.exists(cal_path):
        return
    try:
        with open(cal_path) as f:
            data = json.load(f)
        generated_at = data.get("generated_at")
        if not generated_at:
            return
        cal_date = date.fromisoformat(str(generated_at)[:10])
        if (date.today() - cal_date).days > 180:
            print(f"\u26a0 ASU calendar last refreshed {cal_date.isoformat()} (>6 months old) — run scripts/refresh_asu_calendar.py")
    except Exception:
        pass


def _load_calendar(root):
    """Return terms list from data/asu_calendar.json, or None if missing."""
    cal_path = os.path.join(root, "data", "asu_calendar.json")
    if not os.path.exists(cal_path):
        return None
    try:
        with open(cal_path) as f:
            return json.load(f).get("terms", [])
    except Exception:
        return None


def _match_term(terms, session_label):
    """Return term dict whose session label fuzzy-matches session_label, or None.

    Token-set match — order-insensitive. e.g. "Fall B 2026" matches "Fall 2026 B".
    """
    if not session_label or not terms:
        return None
    want = set(session_label.strip().lower().split())
    if not want:
        return None
    best = None
    for term in terms:
        have = set(term.get("session", "").strip().lower().split())
        if want == have:
            return term
        if want.issubset(have) and best is None:
            best = term
    return best



def _status_from_days(floor_in_days, target_in_days):
    """Return color string from floor/target day counts (extracted for testability)."""
    if floor_in_days < 0:
        return "red"
    elif floor_in_days < 7:
        return "yellow"
    elif target_in_days < 0:
        return "yellow"
    else:
        return "green"

def _deadline_line(course, root, terms):
    """Return formatted deadline line for a course, or unknown message."""
    if terms is None:
        return "Target completion: unknown — run scripts/refresh_asu_calendar.py"

    # Read session from course-config.json first, then memory frontmatter
    session_label = None
    cfg_path = os.path.join(root, "courses", course["id"], "course-config.json")
    if os.path.exists(cfg_path):
        try:
            with open(cfg_path) as f:
                cfg = json.load(f)
            session_label = (cfg.get("course") or {}).get("session") or cfg.get("session")
        except Exception:
            pass

    if not session_label:
        # Try memory frontmatter (already parsed into course dict would need re-read;
        # easier to re-read the file for the session key)
        mem_path = os.path.join(root, "courses", course["id"], "course-memory.md")
        if os.path.exists(mem_path):
            try:
                with open(mem_path) as f:
                    raw = f.read()
                meta, _ = _parse_frontmatter(raw)
                session_label = meta.get("session")
            except Exception:
                pass

    if not session_label:
        return "Target completion: unknown (no session in config)"

    term = _match_term(terms, session_label)
    if not term or not term.get("start"):
        return f"Target completion: unknown (session '{session_label}' not in calendar)"

    try:
        session_start = date.fromisoformat(term["start"])
    except ValueError:
        return "Target completion: unknown (bad date in calendar)"

    today = date.today()
    complete_by_target = session_start - timedelta(days=28)  # 4 weeks before — ideal target
    complete_by_floor = session_start - timedelta(days=14)   # 2 weeks before — hard floor
    target_in_days = (complete_by_target - today).days
    floor_in_days = (complete_by_floor - today).days
    start_in_days = (session_start - today).days

    # Status reflects urgency vs the 2-week floor (last acceptable date)
    if floor_in_days < 0:
        status = "red"      # past hard floor — course launches too late
    elif floor_in_days < 7:
        status = "yellow"   # less than a week to floor
    elif target_in_days < 0:
        status = "yellow"   # past 4-week target but still inside 2-week window
    else:
        status = "green"

    return (
        f"Target completion: {complete_by_target.isoformat()} (4w) "
        f"· Floor: {complete_by_floor.isoformat()} (2w) "
        f"· {status} · target in {target_in_days}d · floor in {floor_in_days}d · session start in {start_in_days}d"
    )



def _compact_color(course, root, terms):
    """Return (color_tag, target_str, days_str) for compact output."""
    if not course.get("name") and not course.get("last_active"):
        return "GRAY", "", ""
    
    # Try to get deadline info
    if terms and terms != "skip":
        dl_raw = _deadline_line(course, root, terms)
        # Parse status from deadline line
        if "· red ·" in dl_raw or "· red" in dl_raw:
            color = "RED"
        elif "· yellow ·" in dl_raw or "· yellow" in dl_raw:
            color = "YELLOW"
        elif "· green ·" in dl_raw or "· green" in dl_raw:
            color = "GREEN"
        else:
            color = "GRAY"
        # Extract target date and days
        import re as _re
        m_target = _re.search(r"Target completion: (\d{4}-\d{2}-\d{2})", dl_raw)
        m_days = _re.search(r"target in (-?\d+)d", dl_raw)
        target_str = m_target.group(1) if m_target else ""
        days_val = int(m_days.group(1)) if m_days else None
        if days_val is not None:
            if days_val < 0:
                days_str = "OVERDUE"
            else:
                days_str = f"({days_val}d)"
        else:
            days_str = ""
    else:
        # No calendar — infer color from pending urgency alone or mark gray
        color = "GRAY"
        target_str = ""
        days_str = ""
    
    return color, target_str, days_str


# Sort order for compact mode
_COMPACT_SORT = {"RED": 0, "YELLOW": 1, "GREEN": 2, "GRAY": 3}


def fmt_compact(course, root, terms):
    """Return a single-line compact status string for standup."""
    name = course.get("name") or f"Course {course['id']}"
    cid = course["id"]

    if not course.get("has_memory"):
        warning = course.get("memory_warning") or "no local context"
        return f"[GRAY] {name} ({cid}) · {warning}"

    actionable = course.get("actionable") or []
    blocked = course.get("blocked") or []
    n_actionable = len(actionable)
    n_blocked = len(blocked)

    color, target_str, days_str = _compact_color(course, root, terms)

    counts = f"{n_actionable} actionable / {n_blocked} blocked"

    mockup_part = ""
    if course.get("mockup_status"):
        mockup_part = f" · mockup {course['mockup_status']}"

    if color == "GRAY":
        # Has memory but no deadline signal — show as neutral
        return f"[GRAY] {name} ({cid}) · {counts}{mockup_part}"

    if target_str:
        if days_str == "OVERDUE":
            target_part = f"target {target_str} OVERDUE"
        else:
            target_part = f"target {target_str} {days_str}"
        return f"[{color}] {name} ({cid}) · {counts} · {target_part}{mockup_part}"
    else:
        return f"[{color}] {name} ({cid}) · {counts}{mockup_part}"

def fmt_standup(course, show_blocked=False, deadline_line=None):
    parts = []
    bar = "━" * 3
    parts.append(f"\n{bar} {course['name']} {bar}")
    meta = f"ID {course['id']}"
    if course["last_active"]:
        meta += f"  ·  last active {course['last_active']}"
    if course["build_pct"] is not None:
        meta += f"  ·  build {course['build_pct']}"
    parts.append(meta)

    if not course["has_memory"]:
        parts.append(f"({course.get('memory_warning') or 'no local context'})")
        return "\n".join(parts)

    if course["current_state"]:
        parts.append(f"State: {course['current_state']}")
    if course["open_questions"]:
        parts.append(course["open_questions"])

    actionable = course["actionable"]
    blocked = course["blocked"]
    top3 = actionable[:3]
    if top3:
        parts.append(f"Now ({len(actionable)}):")
        for item in top3:
            parts.append(f"  • {_clean_bullet(item)}")
    else:
        parts.append("Now: (none)")

    if blocked:
        if show_blocked:
            parts.append(f"Blocked ({len(blocked)}):")
            for item in blocked:
                parts.append(f"  □ {_clean_bullet(item)}")
        else:
            parts.append(f"Blocked: {len(blocked)}")

    if course.get("mockup_status"):
        lsc = course.get("mockup_last_change", "")
        lsc_part = f" (last change {lsc})" if lsc else ""
        parts.append(f"Mockup: {course['mockup_status']}{lsc_part}")

    if deadline_line is not None:
        parts.append(deadline_line)
    return "\n".join(parts)


def fmt_pending(course):
    header = f"\n### {course['name']} (ID {course['id']})"
    if not course["has_memory"] or not course["pending_text"]:
        return header + "\n(no pending data)"
    return header + "\n" + course["pending_text"]


# ---------------------------------------------------------------------------
# Filters
# ---------------------------------------------------------------------------

def _course_status(course, root, terms):
    """Return (color, days_to_floor, days_to_start) tuple. None,None,None if no calendar match."""
    if not terms:
        return None, None, None
    session = course.get("session") or ""
    if not session:
        # Try config session
        cid = course["id"]
        cfg_path = os.path.join(root, "courses", str(cid), "course-config.json")
        if os.path.exists(cfg_path):
            try:
                with open(cfg_path) as f:
                    cfg = json.load(f)
                session = (cfg.get("course") or {}).get("session") or cfg.get("session") or ""
            except Exception:
                pass
    if not session:
        return None, None, None
    term = _match_term(terms, session)
    if not term or not term.get("start"):
        return None, None, None
    try:
        start = date.fromisoformat(term["start"])
    except ValueError:
        return None, None, None
    today = date.today()
    floor = start - timedelta(days=14)
    target = start - timedelta(days=28)
    floor_in = (floor - today).days
    target_in = (target - today).days
    start_in = (start - today).days
    if floor_in < 0:
        color = "RED"
    elif floor_in < 7 or target_in < 0:
        color = "YELLOW"
    else:
        color = "GREEN"
    return color, floor_in, start_in


def _next_session_label(terms):
    """Return session label of upcoming term (closest start date >= today)."""
    if not terms:
        return None
    today = date.today()
    upcoming = []
    for t in terms:
        try:
            s = date.fromisoformat(t["start"])
        except (ValueError, KeyError, TypeError):
            continue
        if s >= today:
            upcoming.append((s, t["session"]))
    upcoming.sort()
    return upcoming[0][1] if upcoming else None


def _filter_courses(courses, args, root, terms):
    """Apply --at-risk, --launching-in, --this-session, --blocked-only, --include-archived filters."""
    filtered = []
    next_session = _next_session_label(terms) if args.this_session else None
    for c in courses:
        # Archive filter (default exclude archived)
        if not args.include_archived and (c.get("status") == "archived"):
            continue
        # Blocked-only
        if args.blocked_only:
            has_blocked = bool(c.get("blocked")) or bool(c.get("open_questions"))
            if not has_blocked:
                continue
        # At-risk / launching-in / this-session need calendar
        color, floor_in, start_in = _course_status(c, root, terms)
        if args.at_risk:
            if color not in ("RED", "YELLOW"):
                continue
        if args.launching_in is not None:
            if start_in is None or start_in > args.launching_in or start_in < 0:
                continue
        if args.this_session:
            if not next_session:
                continue
            sess = (c.get("session") or "").lower()
            if not sess:
                continue
            want = set(next_session.lower().split())
            have = set(sess.split())
            if want != have and not want.issubset(have):
                continue
        filtered.append(c)
    return filtered


def _render_inbox(courses):
    """Cross-course faculty inbox grouped by owner, sorted by oldest age desc."""
    from collections import defaultdict
    by_owner = defaultdict(list)
    for c in courses:
        path = os.path.join(get_plugin_root() if not isinstance(get_plugin_root(), str) else Path(get_plugin_root()), "courses", str(c["id"]), "open_questions.jsonl")
        if not os.path.exists(path):
            continue
        try:
            with open(path) as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        rec = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    if rec.get("status") != "open":
                        continue
                    owner = rec.get("owner") or "unknown"
                    by_owner[owner].append({
                        "course_id": c["id"],
                        "course_name": c["name"],
                        "question": rec.get("question", ""),
                        "created_at": rec.get("created_at", ""),
                    })
        except OSError:
            continue
    if not by_owner:
        return "(no open faculty questions)"
    today = date.today()
    def age_days(rec):
        try:
            return (today - date.fromisoformat(rec["created_at"][:10])).days
        except (ValueError, KeyError):
            return 0
    total_open = sum(len(v) for v in by_owner.values())
    course_count = len({r["course_id"] for v in by_owner.values() for r in v})
    lines = [f"━━━ Faculty Inbox ({total_open} open across {course_count} courses) ━━━", ""]
    owner_order = sorted(
        by_owner.keys(),
        key=lambda o: max((age_days(r) for r in by_owner[o]), default=0),
        reverse=True,
    )
    for owner in owner_order:
        recs = sorted(by_owner[owner], key=age_days, reverse=True)
        oldest = age_days(recs[0]) if recs else 0
        lines.append(f"@{owner} ({len(recs)} open, oldest {oldest}d)")
        # Group by course
        by_course = defaultdict(list)
        for r in recs:
            by_course[(r["course_id"], r["course_name"])].append(r)
        for (cid, cname), items in by_course.items():
            lines.append(f"  {cname} ({cid}):")
            for r in items:
                lines.append(f"    • [{age_days(r)}d] {r['question']}")
        lines.append("")
    return "\n".join(lines).rstrip()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Multi-course standup + pending aggregator for ASUCB builds."
    )
    mode_group = parser.add_mutually_exclusive_group()
    mode_group.add_argument("--standup", action="store_true", default=False,
                            help="Per-course one-block summary (default)")
    mode_group.add_argument("--pending", action="store_true", default=False,
                            help="Full pending sections across all courses")
    parser.add_argument("--course-id", metavar="ID[,ID2]",
                        help="Filter to specific course IDs (comma-separated)")
    parser.add_argument("--blocked", action="store_true",
                        help="Also show blocked items in standup mode")
    parser.add_argument("--deadlines", action="store_true",
                        help="Show 21-day target completion deadline per course")
    parser.add_argument("--compact", action="store_true",
                        help="One-line-per-course output sorted by urgency")
    parser.add_argument("--json", action="store_true", dest="as_json",
                        help="Machine-readable JSON output")
    parser.add_argument("--at-risk", action="store_true", dest="at_risk",
                        help="Only show courses past 4w target or near 2w floor")
    parser.add_argument("--launching-in", type=int, dest="launching_in", metavar="DAYS",
                        help="Only show courses with session start within N days")
    parser.add_argument("--this-session", action="store_true", dest="this_session",
                        help="Only show courses in the next-upcoming ASU session")
    parser.add_argument("--blocked-only", action="store_true", dest="blocked_only",
                        help="Only show courses with at least 1 blocked item or open question")
    parser.add_argument("--include-archived", action="store_true", dest="include_archived",
                        help="Include courses with status: archived in memory frontmatter")
    parser.add_argument("--inbox", action="store_true",
                        help="Cross-course faculty open-question inbox view")
    args = parser.parse_args()
    if getattr(args, 'course_id', None) is not None:
        validated = []
        for cid in [c.strip() for c in args.course_id.split(',')]:
            try:
                validated.append(validate_course_id(cid))
            except ValueError as exc:
                parser.error(str(exc))
        args.course_id = ','.join(validated)

    # Default to standup if neither flag given
    mode = "pending" if args.pending else "standup"

    root = str(get_plugin_root())
    _check_calendar_staleness(root)
    registry = load_registry(root)

    # Resolve course IDs
    if args.course_id:
        course_ids = [c.strip() for c in args.course_id.split(",")]
    else:
        course_ids = discover_course_ids(root, registry)
        # Only keep courses with a memory file OR a local data dir
        courses_dir = os.path.join(root, "courses")
        local_dirs = set()
        if os.path.isdir(courses_dir):
            for e in os.scandir(courses_dir):
                if e.is_dir() and re.match(r"^\d+$", e.name):
                    local_dirs.add(e.name)
        course_ids = [cid for cid in course_ids if cid in local_dirs]

    courses = [load_course_data(root, cid, registry) for cid in course_ids]

    # Load calendar terms once for --deadlines / filter operations
    needs_terms = args.deadlines or args.compact or args.at_risk or args.launching_in is not None or args.this_session
    terms = _load_calendar(root) if needs_terms else "skip"
    terms_for_filter = terms if (terms != "skip") else None

    # Inbox mode short-circuits everything else
    if args.inbox:
        print(_render_inbox(courses))
        return

    # Apply filters (archive, at-risk, launching-in, this-session, blocked-only)
    courses = _filter_courses(courses, args, root, terms_for_filter)

    # --this-session: provide guidance when result is empty
    if args.this_session and not courses:
        if not terms_for_filter:
            print("No calendar data — run scripts/refresh_asu_calendar.py")
        else:
            label = _next_session_label(terms_for_filter) or "the next session"
            print(f"No courses in upcoming session ({label}).")
        return

    # At-risk header
    if args.at_risk:
        if not courses:
            print("✓ No courses at risk.")
            return
        print(f"⚠ {len(courses)} courses at risk:")

    if args.compact:
        # Load terms for color computation
        compact_terms = terms if (terms != "skip") else None
        rows = []
        for course in courses:
            color, _, _ = _compact_color(course, root, compact_terms)
            rows.append((_COMPACT_SORT.get(color, 9), fmt_compact(course, root, compact_terms)))
        rows.sort(key=lambda x: x[0])
        if args.as_json:
            # Compact JSON: list of line strings
            print(json.dumps([r[1] for r in rows], indent=2))
        else:
            for _, line in rows:
                print(line)
        return

    if args.as_json:
        print(json.dumps(courses, indent=2))
        return

    if mode == "standup":
        for course in courses:
            if args.deadlines:
                dl = _deadline_line(course, root, terms)
            else:
                dl = None
            print(fmt_standup(course, show_blocked=args.blocked, deadline_line=dl))
    else:
        for course in courses:
            print(fmt_pending(course))


if __name__ == "__main__":
    main()
