#!/usr/bin/env python3
"""Accelerated-session scope triage — flag over-scope and surface cut candidates.

This tool answers one question: given a topic count and a session type, does the
course fit? If not, it flags the over-scope and surfaces conservative cut candidates
limited to topics explicitly marked optional in the course config.

HARD BOUNDARY (mandatory):
    This tool flags and proposes; it NEVER auto-selects or ranks substantive topics
    to cut. Every over-scope result carries a note stating the cut decision belongs
    to the instructor. The tool identifies optional topics as candidates only —
    it does not rank, score, or recommend which topics to drop among substantive ones.

CLI:
    python scripts/scope_triage.py --course-id N --session A
    python scripts/scope_triage.py --course-id N --session A --topic-count 10
    python scripts/scope_triage.py --course-id N --session A --start 2026-08-20
    python scripts/scope_triage.py --course-id N --session A --json
    python scripts/scope_triage.py --course-id N --session A --emit-backlog
"""

from __future__ import annotations

import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = Path(__file__).resolve().parent
if str(SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPTS_DIR))

from config_loader import load_course_config as _load_course_config_shared  # noqa: E402
from session_calendar import (  # noqa: E402
    build_deadlines,
    lookup_term,
    runway_status,
    session_length_weeks,
)

try:
    import backlog_manager  # noqa: F401 — imported for patchability in tests
except Exception:  # noqa: BLE001 — optional dependency; gracefully absent in some envs
    backlog_manager = None  # type: ignore[assignment]

# Human-decides note (mandatory on every over-scope result).
_HUMAN_DECIDES_NOTE = (
    "SCOPE DECISION BELONGS TO THE INSTRUCTOR: This tool identifies optional topics "
    "as cut candidates only. The instructor (and relevant program faculty) must decide "
    "which topics to cut, demote, or defer. No substantive topics have been ranked or "
    "selected for removal by this tool."
)

_CLEAN_SCOPE_NOTE = (
    "Course fits within session capacity. No scope reduction needed based on current topic count."
)


# ---------------------------------------------------------------------------
# Config loader
# ---------------------------------------------------------------------------

def _load_course_config(course_id: str | None = None, config_path: str | None = None) -> dict:
    """Load course config.  Thin alias — delegates to :func:`config_loader.load_course_config`."""
    return _load_course_config_shared(course_id=course_id, config_path=config_path)


# ---------------------------------------------------------------------------
# Core assessment function
# ---------------------------------------------------------------------------

def assess_scope(
    topic_count: int,
    session_type: str,
    topics_per_week: float = 1.0,
    optional_topics: list[str] | None = None,
) -> dict[str, Any]:
    """Assess whether *topic_count* topics fit within *session_type*.

    Args:
        topic_count:     Number of topics/modules in the course.
        session_type:    ASU session type: ``A``, ``B``, ``C``, or ``full``.
        topics_per_week: Coverage rate (topics per teaching week). Must be > 0.
                         Defaults to 1.0.
        optional_topics: Topics explicitly marked optional in course config.
                         Only these appear as cut candidates — substantive topics
                         are NEVER auto-selected.

    Returns a dict with keys:
        session_type    — normalized session type string
        weeks           — session length in weeks
        topics_per_week — coverage rate used
        capacity        — max topics that fit: floor((weeks - 0.5) * topics_per_week)
                          (the 0.5-week reservation is for finals/wrap-up)
        topic_count     — input topic count
        over_by         — how many topics exceed capacity (0 if in scope)
        over_scope      — bool
        candidates      — list of optional topic names as cut candidates (may be empty)
        note            — human-decides boundary statement (over-scope) or clean confirmation

    Raises:
        ValueError: if topics_per_week <= 0
        ValueError: if session_type is unknown (delegated to session_length_weeks)
    """
    if topics_per_week <= 0:
        raise ValueError(
            f"topics_per_week must be > 0, got {topics_per_week!r}"
        )

    weeks = session_length_weeks(session_type)
    # Reserve ~half a week for finals/wrap-up; then floor to whole topics.
    capacity = math.floor((weeks - 0.5) * topics_per_week)
    over_by = max(0, topic_count - capacity)
    over_scope = over_by > 0

    # Candidates are ONLY topics explicitly marked optional — never substantive topics.
    candidates: list[str] = list(optional_topics) if optional_topics else []

    note = _HUMAN_DECIDES_NOTE if over_scope else _CLEAN_SCOPE_NOTE

    return {
        "session_type": session_type.strip().upper(),
        "weeks": weeks,
        "topics_per_week": topics_per_week,
        "capacity": capacity,
        "topic_count": topic_count,
        "over_by": over_by,
        "over_scope": over_scope,
        "candidates": candidates,
        "note": note,
    }


# ---------------------------------------------------------------------------
# Topic-count extraction from course config
# ---------------------------------------------------------------------------

def _extract_topic_count(config: dict) -> int | None:
    """Return the number of modules/topics from a course config, or None.

    Returns 0 for an explicitly-empty modules list (distinct from missing).
    Returns None only when no modules key is present.
    """
    # Check top-level modules list
    top_modules = config.get("modules")
    if isinstance(top_modules, list):
        return len(top_modules)
    if isinstance(top_modules, int):
        return top_modules

    # Check nested course.modules
    course = config.get("course") or {}
    nested_modules = course.get("modules")
    if isinstance(nested_modules, list):
        return len(nested_modules)
    if isinstance(nested_modules, int):
        return nested_modules

    return None


def _extract_optional_topics(config: dict) -> list[str]:
    """Return topic names explicitly marked optional: true in the config."""
    optional: list[str] = []
    modules = (
        config.get("modules")
        or (config.get("course") or {}).get("modules")
        or []
    )
    if not isinstance(modules, list):
        return optional
    for mod in modules:
        if not isinstance(mod, dict):
            continue
        if mod.get("optional") is True:
            name = mod.get("name") or mod.get("title") or mod.get("topic") or ""
            if name:
                optional.append(str(name))
    return optional


# ---------------------------------------------------------------------------
# Backlog emission
# ---------------------------------------------------------------------------

def emit_backlog(course_id: str, result: dict[str, Any]) -> None:
    """Emit an over-scope finding to backlog_manager."""
    # Use the module-level reference so tests can monkeypatch scope_triage.backlog_manager
    import sys as _sys  # noqa: PLC0415
    _mod = _sys.modules[__name__]
    bm = getattr(_mod, "backlog_manager", None)
    if bm is None:
        raise RuntimeError(
            "backlog_manager is not available. "
            "Ensure scripts/ is on sys.path and backlog_manager.py exists."
        )

    finding_id = f"scope-{result['session_type'].lower()}-overscope"
    item = {
        "finding_id": finding_id,
        "finding_text": (
            f"Over-scope: {result['topic_count']} topics in a "
            f"{result['session_type']} session (capacity {result['capacity']}; "
            f"over by {result['over_by']})"
        ),
        "severity": "HIGH",
        "priority": "high",
        "source": "scope_triage.py",
        "detector": "scope_triage",
        "detail": result["note"],
        "evidence": json.dumps({
            "session_type": result["session_type"],
            "weeks": result["weeks"],
            "capacity": result["capacity"],
            "topic_count": result["topic_count"],
            "over_by": result["over_by"],
            "candidates": result["candidates"],
        }),
        "content_type": "course",
        "status": "can_do_now",
    }
    bm.add(course_id, item)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description=(
            "Accelerated-session scope triage — flag over-scope and surface "
            "cut candidates. The cut decision always belongs to the instructor."
        )
    )
    parser.add_argument("--course-id", help="Canvas course ID (numeric)")
    parser.add_argument(
        "--session",
        required=True,
        metavar="TYPE",
        help="Session type: A, B, C, or full",
    )
    parser.add_argument(
        "--start",
        metavar="YYYY-MM-DD",
        help="Session start date (ISO 8601). Shows build deadlines and runway.",
    )
    parser.add_argument(
        "--term",
        metavar="TERM",
        help='Term label for auto-lookup of start date, e.g. "Fall 2026"',
    )
    parser.add_argument(
        "--topic-count",
        type=int,
        metavar="N",
        help="Number of topics/modules. Defaults to module count from course-config.json.",
    )
    parser.add_argument(
        "--topics-per-week",
        type=float,
        default=1.0,
        metavar="N",
        help="Topics covered per week (default: 1.0)",
    )
    parser.add_argument(
        "--config",
        metavar="PATH",
        help="Path to course-config.json (overrides default lookup)",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Machine-readable JSON output",
    )
    parser.add_argument(
        "--emit-backlog",
        action="store_true",
        help="Emit over-scope finding to backlog (requires --course-id)",
    )
    args = parser.parse_args()

    if args.emit_backlog and not args.course_id:
        parser.error("--emit-backlog requires --course-id")

    if args.course_id and not re.match(r"^\d+$", args.course_id.strip()):
        parser.error(f"--course-id must be numeric, got: {args.course_id!r}")

    # Resolve topic count
    topic_count = args.topic_count
    config: dict[str, Any] = {}
    optional_topics: list[str] = []

    if topic_count is None or args.config or args.course_id:
        config = _load_course_config(
            course_id=args.course_id,
            config_path=args.config,
        )
        if topic_count is None:
            topic_count = _extract_topic_count(config)
        optional_topics = _extract_optional_topics(config)

    if topic_count is None:
        parser.error(
            "Could not determine topic count. "
            "Provide --topic-count N or ensure course-config.json has a modules list."
        )

    # Resolve session start from --term if not given directly
    start_date_str = args.start
    if not start_date_str and args.term:
        term_info = lookup_term(args.term, session_type=args.session)
        if term_info and isinstance(term_info, dict):
            start_date_str = term_info.get("start")

    # Validate topics_per_week early for better error message
    if args.topics_per_week <= 0:
        parser.error(f"--topics-per-week must be > 0, got {args.topics_per_week}")

    # Run assessment
    try:
        result = assess_scope(
            topic_count=topic_count,
            session_type=args.session,
            topics_per_week=args.topics_per_week,
            optional_topics=optional_topics if optional_topics else None,
        )
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(1)

    # Add build deadlines / runway if start date available
    deadline_info: dict[str, Any] = {}
    if start_date_str:
        try:
            from datetime import date as _date
            from session_calendar import _parse_date
            start_date = _parse_date(start_date_str)
            deadlines = build_deadlines(start_date)
            runway = runway_status(start_date)
            deadline_info = {
                "session_start": str(start_date),
                "build_deadlines": {k: str(v) for k, v in deadlines.items()},
                "runway_status": runway,
            }
        except ValueError as exc:
            print(f"WARNING: could not compute build deadlines: {exc}", file=sys.stderr)

    if args.json:
        output = dict(result)
        output.update(deadline_info)
        print(json.dumps(output, indent=2, default=str))
    else:
        _print_human(result, deadline_info, course_id=args.course_id)

    # Emit to backlog if requested
    if args.emit_backlog and result["over_scope"]:
        emit_backlog(args.course_id, result)
        print(f"\nOver-scope finding emitted to backlog for course {args.course_id}.")
    elif args.emit_backlog and not result["over_scope"]:
        print("\n(No over-scope finding to emit — course fits within session capacity.)")


def _print_human(
    result: dict[str, Any],
    deadline_info: dict[str, Any],
    course_id: str | None = None,
) -> None:
    label = f"Course {course_id}" if course_id else "Scope Triage"
    print(f"\n=== Accelerated-Session Scope Triage — {label} ===")
    print(
        f"Session: {result['session_type']}  "
        f"({result['weeks']} weeks, "
        f"{result['topics_per_week']} topics/week)"
    )
    print(f"Capacity:    {result['capacity']} topics")
    print(f"Topic count: {result['topic_count']} topics")

    if result["over_scope"]:
        print(f"\n[OVER-SCOPE] Over by {result['over_by']} topic(s)")
        if result["candidates"]:
            print("\nCut candidates (optional topics only):")
            for c in result["candidates"]:
                print(f"  - {c}")
        else:
            print("\nNo optional topics flagged in config — candidates list is empty.")
        print(f"\n{result['note']}")
    else:
        print(f"\n{result['note']}")

    if deadline_info:
        bd = deadline_info.get("build_deadlines", {})
        rv = deadline_info.get("runway_status", {})
        print(f"\nSession start: {deadline_info.get('session_start')}")
        print(f"Build target:  {bd.get('target_date')} ({rv.get('days_to_target', '?'):+} days)")
        print(f"Build floor:   {bd.get('floor_date')} ({rv.get('days_to_floor', '?'):+} days)")
        print(f"Runway status: {rv.get('status')}")


if __name__ == "__main__":
    main()
