#!/usr/bin/env python3
"""Gated rubric-creation CLI -- Write Safety Guard enforcing entry point.

Implements the full stage-preview-confirm-push gate for Canvas rubric writes:
  1. Accept a rubric spec (JSON file or stdin).
  2. Build ratings with cell text in description + descending points for order.
  3. Render an HTML preview staged to courses/{id}/staging/rubrics/{slug}.html.
  4. Run ensure_staging_server; print the canonical 127.0.0.1:8111 preview URL.
  5. Require --confirm-verb {push,approved,deploy} for any live write.
  6. On confirm: create or update_rubric (--replace <id>), associate to
     --assignment-id (repeatable), clear staged preview on success.
  7. --dry-run renders preview + prints plan without any Canvas write.

Rubric spec JSON format (file or stdin):
  {
    "title": "Rubric Title",
    "criteria": [
      {
        "description": "Criterion Name",
        "long_description": "Optional criterion detail (for criterion row only)",
        "levels": [
          {"name": "Exemplary", "text": "Full cell descriptor text."},
          {"name": "Proficient", "text": "..."},
          {"name": "Developing", "text": "..."},
          {"name": "Beginning",  "text": "..."}
        ]
      }
    ],
    "points_per_criterion": 25
  }

Points are assigned in descending order (Exemplary=highest, Beginning=0) so
Canvas renders columns left-to-right in Exemplary->Beginning order.

VERIFIED Canvas-API facts encoded here (do not re-litigate):
  1. Rating long_description is DROPPED by Canvas on write -- cell text goes
     into rating description only.
  2. Criteria edits on an already-associated rubric do NOT persist -- update
     routes through delete+recreate+re-associate (update_rubric in canvas_api).
  3. Equal-point ratings render alphabetically -- use descending points.
"""

import argparse
import html
import json
import os
import re
import subprocess
import sys
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent
PLUGIN_ROOT = SCRIPTS_DIR.parent

sys.path.insert(0, str(SCRIPTS_DIR))

from canvas_api import get_config, require_course_id, create_rubric, associate_rubric
from canvas_api import update_rubric as _update_rubric
import staging_manager

VALID_CONFIRM_VERBS = {"push", "approved", "deploy"}
AMBIGUOUS_VERBS = {"ok", "yes", "sure", "confirm", "y"}

STAGING_TYPE = "rubrics"


# ---------------------------------------------------------------------------
# Spec loading and rating construction
# ---------------------------------------------------------------------------

def load_spec(path_or_stdin):
    """Load and return a rubric spec dict from a file path or '-' for stdin."""
    if path_or_stdin == "-":
        raw = sys.stdin.read()
    else:
        with open(path_or_stdin, "r", encoding="utf-8") as fh:
            raw = fh.read()
    return json.loads(raw)


def _slug(title):
    """Convert a title to a filesystem/URL-safe slug."""
    s = title.lower()
    s = re.sub(r"[^a-z0-9]+", "-", s)
    s = s.strip("-")
    return s or "rubric"


def build_criteria(spec):
    """Return (criteria_for_api, levels_order) from a rubric spec.

    Assigns descending integer points so Canvas renders columns in the
    same order the levels appear in the spec (highest first).

    Cell text is placed in rating description -- NOT long_description
    (Canvas drops long_description on write; verified 2026-06-15).
    """
    points_per = int(spec.get("points_per_criterion", 25))
    raw_criteria = spec.get("criteria", [])
    out = []
    for crit in raw_criteria:
        levels = crit.get("levels", [])
        n = len(levels)
        if n == 0:
            raise ValueError(
                f"Criterion '{crit.get('description', '?')}' has no levels."
            )
        # Assign descending points: top level = points_per, bottom = 0.
        # Evenly spaced so the sum equals points_per only for the top level;
        # the criterion's grading points = points_per (top level).
        step = points_per / max(n - 1, 1) if n > 1 else 0
        ratings = []
        for idx, level in enumerate(levels):
            pts = round(points_per - idx * step)
            if idx == n - 1:
                pts = 0  # Bottom level always 0
            ratings.append({
                "description": f"{level['name']}: {level['text']}",
                "points": pts,
            })

        c = {
            "description": crit["description"],
            "points": points_per,
            "ratings": ratings,
        }
        if "long_description" in crit:
            c["long_description"] = crit["long_description"]
        out.append(c)

    level_names = [lv["name"] for lv in raw_criteria[0]["levels"]] if raw_criteria else []
    return out, level_names


# ---------------------------------------------------------------------------
# HTML preview rendering
# ---------------------------------------------------------------------------

PREVIEW_CSS = (
    "font-family:Arial,sans-serif;"
    "border-collapse:collapse;"
    "width:100%;"
    "margin:0 auto;"
)
TH_CSS = (
    "background-color:#8C1D40;"
    "color:#ffffff;"
    "padding:8px 12px;"
    "text-align:left;"
    "border:1px solid #ccc;"
    "font-size:13px;"
)
TD_CSS = (
    "padding:8px 10px;"
    "border:1px solid #ccc;"
    "vertical-align:top;"
    "font-size:12px;"
)
CRIT_TD_CSS = (
    "padding:8px 10px;"
    "border:1px solid #ccc;"
    "vertical-align:top;"
    "font-size:12px;"
    "font-weight:bold;"
    "background-color:#f5f5f5;"
    "min-width:120px;"
)
POINTS_CSS = (
    "display:inline-block;"
    "margin-top:4px;"
    "font-size:11px;"
    "color:#555;"
)


def render_preview_html(spec, criteria, level_names):
    """Return a self-contained HTML string previewing the rubric table."""
    title = html.escape(spec.get("title", "Rubric Preview"))
    points_per = int(spec.get("points_per_criterion", 25))
    total = points_per * len(criteria)

    col_headers = "".join(
        f'<th style="{TH_CSS}">{html.escape(name)}</th>'
        for name in level_names
    )
    header_row = (
        f'<tr>'
        f'<th style="{TH_CSS}">Criterion</th>'
        f'{col_headers}'
        f'</tr>'
    )

    rows_html = ""
    for c in criteria:
        crit_name = html.escape(c["description"])
        crit_long = html.escape(c.get("long_description", ""))
        crit_cell = f'<td style="{CRIT_TD_CSS}">{crit_name}'
        if crit_long:
            crit_cell += f'<br><span style="font-weight:normal;font-size:11px;">{crit_long}</span>'
        crit_cell += f'<br><span style="{POINTS_CSS}">{c["points"]} pts</span></td>'

        rating_cells = ""
        for r in c["ratings"]:
            desc = html.escape(r["description"])
            pts = r["points"]
            rating_cells += (
                f'<td style="{TD_CSS}">'
                f'{desc}'
                f'<br><span style="{POINTS_CSS}">{pts} pts</span>'
                f'</td>'
            )
        rows_html += f"<tr>{crit_cell}{rating_cells}</tr>\n"

    body = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title} - Rubric Preview</title>
</head>
<body style="background:#fff;padding:16px;">
<h2 style="color:#8C1D40;font-family:Arial,sans-serif;">{title}</h2>
<p style="font-family:Arial,sans-serif;font-size:13px;color:#333;">
  <strong>Total points:</strong> {total} &nbsp;|&nbsp;
  <strong>Criteria:</strong> {len(criteria)} &nbsp;|&nbsp;
  <strong>Levels:</strong> {len(level_names)}
</p>
<table style="{PREVIEW_CSS}">
  {header_row}
  {rows_html}
</table>
<p style="font-family:Arial,sans-serif;font-size:11px;color:#888;margin-top:12px;">
  Preview only. Points are descending left-to-right for correct Canvas column order.
  Cell text is stored in the rating description field (Canvas drops long_description).
</p>
</body>
</html>"""
    return body


# ---------------------------------------------------------------------------
# Staging helpers
# ---------------------------------------------------------------------------

def _stage_preview(course_id, slug, html_string, title):
    """Stage the rubric preview HTML via staging_manager and return the staged path."""
    result = staging_manager.stage(
        course_id, STAGING_TYPE, slug, html_string,
        validate=False, title=title,
    )
    if not result.get("ok"):
        raise RuntimeError(f"Failed to stage rubric preview: {result.get('error')}")
    return Path(result["path"])


def _clear_staged(course_id, slug):
    """Remove the staged rubric preview via staging_manager."""
    result = staging_manager.clear_staged(course_id, STAGING_TYPE, slug)
    if result.get("cleared", 0):
        print(f"  Cleared staged preview for slug: {slug}")


# ---------------------------------------------------------------------------
# Staging server
# ---------------------------------------------------------------------------

def _ensure_staging_server():
    """Run ensure_staging_server.py and return the base URL string."""
    server_script = SCRIPTS_DIR / "ensure_staging_server.py"
    result = subprocess.run(
        [sys.executable, str(server_script)],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"Staging server failed to start:\n{result.stderr.strip()}"
        )
    return result.stdout.strip()


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

def _parse_args(argv=None):
    parser = argparse.ArgumentParser(
        description=(
            "Gated rubric-creation CLI. Stages an HTML preview, requires an "
            "explicit push verb, then creates or replaces a Canvas rubric."
        )
    )
    parser.add_argument(
        "spec",
        nargs="?",
        default="-",
        help="Path to rubric spec JSON file, or '-' to read from stdin (default).",
    )
    parser.add_argument(
        "--course-id",
        help="Canvas course ID. Overrides CANVAS_COURSE_ID env var.",
    )
    parser.add_argument(
        "--replace",
        metavar="RUBRIC_ID",
        help="ID of an existing rubric to replace (delete+recreate+re-associate).",
    )
    parser.add_argument(
        "--assignment-id",
        action="append",
        dest="assignment_ids",
        metavar="ID",
        help="Associate rubric with this assignment ID. Repeatable.",
    )
    parser.add_argument(
        "--use-for-grading",
        action="store_true",
        default=True,
        help="Set use_for_grading=true on assignment associations (default: true).",
    )
    parser.add_argument(
        "--no-use-for-grading",
        action="store_false",
        dest="use_for_grading",
        help="Set use_for_grading=false (rubric for feedback only).",
    )
    parser.add_argument(
        "--confirm-verb",
        choices=list(VALID_CONFIRM_VERBS),
        metavar="VERB",
        help=(
            "Explicit push verb required for a live Canvas write. "
            "Accepts only: push, approved, deploy. "
            "Ambiguous words (ok/yes/confirm) are rejected per Write Safety Guard."
        ),
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Render the staged preview and print what would be created. No Canvas write.",
    )
    return parser.parse_args(argv)


def main(argv=None):
    args = _parse_args(argv)

    # Load spec
    try:
        spec = load_spec(args.spec)
    except (FileNotFoundError, json.JSONDecodeError) as exc:
        print(f"ERROR loading spec: {exc}", file=sys.stderr)
        return 1

    # Build criteria
    try:
        criteria, level_names = build_criteria(spec)
    except ValueError as exc:
        print(f"ERROR building criteria: {exc}", file=sys.stderr)
        return 1

    title = spec.get("title", "Untitled Rubric")
    slug = _slug(title)

    # Validate confirm verb early (before any side effects)
    if not args.dry_run:
        if not args.confirm_verb:
            print(
                "ERROR: --confirm-verb {push,approved,deploy} is required for "
                "a live Canvas write.\n"
                "Use --dry-run to render the preview without writing.",
                file=sys.stderr,
            )
            return 1
        if args.confirm_verb in AMBIGUOUS_VERBS:
            print(
                f"ERROR: '{args.confirm_verb}' is ambiguous. "
                "Use one of: push, approved, deploy.",
                file=sys.stderr,
            )
            return 1

    # Load config (needed for course_id even in dry-run for staging path)
    config = get_config(course_id=args.course_id)
    require_course_id(config)
    course_id = config["course_id"]

    # Stage HTML preview
    preview_html = render_preview_html(spec, criteria, level_names)
    try:
        staged_path = _stage_preview(course_id, slug, preview_html, title)
    except RuntimeError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(f"  Staged preview: {staged_path}")

    # Ensure staging server is up
    try:
        base_url = _ensure_staging_server()
    except RuntimeError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1

    preview_url = f"{base_url}/courses/{course_id}/preview/{STAGING_TYPE}/{slug}"
    print(f"\nPreview at: {preview_url}")
    print("Review the rubric preview, then say 'push' when ready.\n")

    # Print plan
    total_points = int(spec.get("points_per_criterion", 25)) * len(criteria)
    action = f"REPLACE rubric {args.replace}" if args.replace else "CREATE new rubric"
    print(f"Plan: {action}")
    print(f"  Title: {title}")
    print(f"  Criteria: {len(criteria)}")
    print(f"  Levels: {len(level_names)} ({', '.join(level_names)})")
    print(f"  Total points: {total_points}")
    if args.assignment_ids:
        print(f"  Associate with assignments: {', '.join(args.assignment_ids)}")

    if args.dry_run:
        print("\n[dry-run] No Canvas write performed.")
        return 0

    # Live write
    print(f"\nPushing rubric to Canvas (confirm verb: {args.confirm_verb})...")

    if args.replace:
        result = _update_rubric(
            config, args.replace, title, criteria,
            free_form_comments=False,
        )
        if not result:
            print("ERROR: update_rubric failed.", file=sys.stderr)
            return 1
        rubric_data = result["rubric"]
        rubric_id = rubric_data.get("id")
        print(f"  Replaced rubric -> new id={rubric_id}")
    else:
        rubric_resp = create_rubric(
            config, title, criteria,
            bookmark=(not args.assignment_ids),
        )
        if not rubric_resp:
            print("ERROR: create_rubric failed.", file=sys.stderr)
            return 1
        rubric_data = rubric_resp.get("rubric", rubric_resp)
        rubric_id = rubric_data.get("id")

    # Associate with assignments
    if args.assignment_ids and rubric_id:
        for aid in args.assignment_ids:
            associate_rubric(
                config, aid, rubric_id,
                use_for_grading=args.use_for_grading,
            )

    # Clear staged preview on success
    _clear_staged(course_id, slug)
    print(f"\nDone. Rubric id={rubric_id} created in course {course_id}.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
