#!/usr/bin/env python3
"""ASUCB Push to Canvas — atomic push wrapper with backup and verification.

Orchestrates the full push workflow:
    1. Read staged content
    2. Fetch current Canvas content for backup
    3. Save backup
    4. Push new content to Canvas
    5. Verify the write
    6. Clear staged artifact ONLY after successful verification

Supports pages, assignments, discussions, and quizzes (including questions).
"""

import argparse
import json
import logging
import os
import re
import sys

sys.path.insert(0, os.path.dirname(__file__))
import canvas_api
import staging_manager
import backup_manager
import post_write_verify
import progress_tracker
import staging_schema
from preflight_checks import check_identical_feedback
from mockup_manager import AUDIENCE_LEVELS, lint_content
try:
    import timeline_logger
except Exception:
    timeline_logger = None
try:
    import backlog_manager
except Exception:
    backlog_manager = None


def _read_staged_meta(course_id, object_type, object_id):
    """Read the staged artifact's .meta.json sidecar, if present."""
    try:
        path = staging_manager._staged_path(course_id, object_type, object_id)
    except Exception:
        return {}
    meta_path = path + ".meta.json"
    if not os.path.exists(meta_path):
        return {}
    try:
        with open(meta_path, "r", encoding="utf-8") as f:
            meta = json.load(f)
        return meta if isinstance(meta, dict) else {}
    except (json.JSONDecodeError, OSError):
        return {}


def _resolve_staged_audience(course_id, object_type, object_id):
    meta = _read_staged_meta(course_id, object_type, object_id)
    audience = meta.get("audience", "student")
    if isinstance(audience, str) and audience in AUDIENCE_LEVELS:
        return audience
    return "student"


def _warn_audience_violations(label, audience, violations):
    print(
        f"WARNING: audience-lint found {len(violations)} forbidden token(s) "
        f"in {label} for audience '{audience}'.",
        file=sys.stderr,
    )
    for violation in violations:
        print(
            f"WARNING:   {violation['token']}: {violation['line_hint']}",
            file=sys.stderr,
        )


def _check_audience_gate(violations, audience, allow_violations, label):
    if not violations:
        return None, []
    if allow_violations:
        _warn_audience_violations(label, audience, violations)
        return None, violations
    tokens = ", ".join(v["token"] for v in violations)
    return {
        "ok": False,
        "error": (
            f"Audience lint blocked {label} for audience '{audience}'. "
            f"Forbidden token(s): {tokens}"
        ),
        "audience": audience,
        "audience_violations": violations,
    }, violations


def _attach_audience_violations(result, violations):
    if violations:
        result["audience_violations"] = violations
    return result


def _attach_schema_warnings(result, warnings):
    if warnings:
        result["schema_warnings"] = warnings
    return result


def _check_schema_gate(course_id, object_type, object_id, payload=None):
    try:
        path = staging_manager._staged_path(course_id, object_type, object_id)
    except Exception:
        path = ""
    if path and os.path.exists(path):
        schema_result = staging_schema.validate_file(
            path,
            course_id=course_id,
            object_type=object_type,
            object_id=object_id,
        )
    elif payload is not None:
        schema_result = staging_schema.validate_payload(
            payload,
            course_id=course_id,
            object_type=object_type,
            object_id=object_id,
            meta=_read_staged_meta(course_id, object_type, object_id),
            path=path,
        ).to_dict()
    else:
        schema_result = staging_schema.validate(course_id, object_type, object_id)
    if schema_result.get("ok"):
        return None, schema_result.get("warnings", [])
    return {
        "ok": False,
        "id": object_id,
        "error": "Staged content failed schema validation.",
        "schema": schema_result,
        "staging_preserved": True,
    }, schema_result.get("warnings", [])


def _warn_identical_feedback(label, issues):
    print(
        f"WARNING: identical-feedback preflight found {len(issues)} issue(s) in {label}.",
        file=sys.stderr,
    )
    for issue in issues:
        print(
            f"WARNING:   {issue.location}: {issue.message}",
            file=sys.stderr,
        )


def _check_identical_feedback_gate(content, allow_identical_feedback, label):
    issues = check_identical_feedback(content)
    if not issues:
        return None, []
    serialized = [issue.to_dict() for issue in issues]
    if allow_identical_feedback:
        _warn_identical_feedback(label, issues)
        return None, serialized
    return {
        "ok": False,
        "error": (
            f"Identical feedback blocked {label}. "
            "Pass --allow-identical-feedback only after intentional review."
        ),
        "identical_feedback": serialized,
        "staging_preserved": True,
    }, serialized


def _append_quiz_text(chunks, value):
    if isinstance(value, str) and value:
        chunks.append(value)


def _extract_quiz_visible_text(content):
    """Collect student-visible quiz text from staged quiz JSON."""
    chunks = []

    for key in ("description", "quiz_description"):
        _append_quiz_text(chunks, content.get(key))

    quiz = content.get("quiz")
    if isinstance(quiz, dict):
        for key in ("description", "quiz_description"):
            _append_quiz_text(chunks, quiz.get(key))

    for question in content.get("questions", []) or []:
        if not isinstance(question, dict):
            continue
        for key in (
            "question_text",
            "correct_comments",
            "correct_comments_html",
            "incorrect_comments",
            "incorrect_comments_html",
            "neutral_comments",
            "neutral_comments_html",
            "general_comments",
            "feedback",
        ):
            _append_quiz_text(chunks, question.get(key))
        for answer in question.get("answers", []) or []:
            if not isinstance(answer, dict):
                continue
            for key in ("answer_text", "answer_comment", "comments", "feedback"):
                _append_quiz_text(chunks, answer.get(key))

    return "\n".join(chunks)


def _emit_timeline_event(course_id, event_type, payload):
    """Best-effort timeline emission that never interrupts a push."""
    if timeline_logger is None:
        logging.warning("timeline_logger unavailable; skipping %s event for course %s",
                        event_type, course_id)
        return
    timeline_logger.emit_event(course_id, event_type, payload)


def _emit_dry_run_push_events(course_id, object_type, object_id):
    """Emit synthetic push lifecycle events for dry-run pushes."""
    payload = {
        "object_type": str(object_type),
        "content_type": str(object_type),
        "object_id": str(object_id),
        "dry_run": True,
    }
    _emit_timeline_event(course_id, "push_started", payload)
    _emit_timeline_event(course_id, "push_verified", payload)


def _cleanup_resolved_backlog(course_id, content_type, object_id, verify_result):
    """Best-effort Pending cleanup after verified pushes."""
    if backlog_manager is None or not verify_result.get("ok"):
        return
    try:
        backlog_manager.cleanup_resolved(
            course_id,
            verify_result=verify_result,
            content_type=content_type,
            object_id=object_id,
        )
    except Exception as exc:
        logging.warning(
            "Failed to clean resolved backlog for course %s %s/%s: %s",
            course_id, content_type, object_id, exc,
        )


# Regex matching asucb-staged-file://{key} tokens (key must be slug-safe).
_STAGED_FILE_TOKEN_RE = re.compile(
    r"asucb-staged-file://([A-Za-z0-9._-]+)"
)


def _resolve_staged_file_tokens(config, course_id, body):
    """Upload staged image files referenced by asucb-staged-file://{key} tokens.

    Scans body for all unique tokens, uploads each staged file to Canvas Files
    via canvas_api.upload_image(), builds a key->canvas_url map, and returns
    the rewritten body.

    Atomic: if ANY upload fails the function raises _StagedFileUploadError with
    the failing key. The caller must NOT push the body in that case and must
    preserve staging intact.

    Known limitation: uploads happen sequentially, so if an earlier key uploads
    and a later key fails, the earlier file is already in Canvas Files (an
    orphan) while the page is not pushed. Staging is preserved, so a retry
    re-uploads it (a duplicate orphan). The page itself is never left in a
    partial state. Orphan cleanup is out of scope here; deleting Canvas files
    is a separate, gated operation.

    Args:
        config: Canvas API config dict.
        course_id: Canvas course ID (string).
        body: Page HTML string.

    Returns:
        (rewritten_body, keys_uploaded) where keys_uploaded is the list of
        unique keys that were uploaded and cleared.

    Raises:
        _StagedFileUploadError: if any upload fails (includes failed_key attr).
    """
    keys = list(dict.fromkeys(_STAGED_FILE_TOKEN_RE.findall(body)))
    if not keys:
        return body, []

    key_to_url = {}
    for key in keys:
        file_info = staging_manager.get_staged_file(course_id, key)
        if file_info is None:
            raise _StagedFileUploadError(
                key,
                f"Staged file not found for token key '{key}'. "
                "Aborting page push to preserve staging integrity.",
            )
        meta = file_info.get("meta", {})
        content_type = meta.get("mime") or None
        module_num_raw = meta.get("module")
        module_number = None
        if module_num_raw is not None:
            try:
                module_number = int(module_num_raw)
            except (TypeError, ValueError):
                module_number = None

        result = canvas_api.upload_image(
            config,
            str(file_info["path"]),
            module_number=module_number,
            content_type=content_type,
        )
        if not result or not result.get("embed_url"):
            raise _StagedFileUploadError(
                key,
                f"Canvas upload failed for staged file key '{key}'. "
                "Aborting page push to preserve staging integrity.",
            )
        key_to_url[key] = result["embed_url"]

    def _replace_token(match):
        k = match.group(1)
        return key_to_url.get(k, match.group(0))

    rewritten = _STAGED_FILE_TOKEN_RE.sub(_replace_token, body)
    return rewritten, keys


class _StagedFileUploadError(Exception):
    """Raised when a staged-file upload fails during token resolution."""

    def __init__(self, failed_key, message):
        super().__init__(message)
        self.failed_key = failed_key


def push_page(config, slug, course_id, dry_run=False,
              module_num=None, page_type=None,
              allow_audience_violations=False):
    """Push a staged page to Canvas.

    Args:
        module_num: Optional module number for progress tracking.
        page_type: Optional page type for progress tracking.
            If both are None, falls back to slug parsing.

    Returns dict with ok, slug, canvas_url, content_length, warnings.
    """
    # 1. Read staged content
    content = staging_manager.get_staged(course_id, "pages", slug)
    if content is None:
        return {"ok": False, "slug": slug,
                "error": f"No staged content for page '{slug}'."}

    schema_result, schema_warnings = _check_schema_gate(
        course_id, "pages", slug, payload=content)
    if schema_result:
        schema_result["slug"] = slug
        return schema_result

    audience = _resolve_staged_audience(course_id, "pages", slug)
    audience_violations = lint_content(content, audience)
    gate_result, audience_violations = _check_audience_gate(
        audience_violations, audience, allow_audience_violations,
        f"page '{slug}'")
    if gate_result:
        gate_result["slug"] = slug
        return gate_result

    if dry_run:
        _emit_dry_run_push_events(course_id, "page", slug)
        return _attach_schema_warnings(_attach_audience_violations({
            "ok": True, "slug": slug, "dry_run": True,
            "content_length": len(content),
            "canvas_url": f"https://{config['domain']}/courses/"
                          f"{config['course_id']}/pages/{slug}",
        }, audience_violations), schema_warnings)

    # 2. Resolve asucb-staged-file://{key} tokens: upload each staged image
    #    to Canvas Files and rewrite the src before pushing. Atomic: if any
    #    upload fails, abort here and preserve staging intact.
    try:
        content, uploaded_keys = _resolve_staged_file_tokens(
            config, course_id, content
        )
    except _StagedFileUploadError as exc:
        return {
            "ok": False,
            "slug": slug,
            "error": str(exc),
            "failed_staged_file_key": exc.failed_key,
            "staging_preserved": True,
        }

    # 4. Backup current Canvas page
    current = canvas_api.get_page(config, slug)
    if current and current.get("body"):
        backup_manager.save_backup(
            course_id, "pages", slug, current["body"],
            diff_summary=f"Pre-push backup for page {slug}",
        )

    # 5. Push to Canvas
    # Shrink-guard: warn if staged content is >40% smaller than current (non-blocking)
    if current and current.get("body"):
        _cur_len = len(current["body"])
        _stg_len = len(content)
        if _cur_len > 0 and _stg_len < _cur_len * 0.60:
            _pct = round((1 - _stg_len / _cur_len) * 100)
            print(f"WARNING: Staged content for '{slug}' is {_pct}% smaller than current Canvas content. Proceeding, but verify intent.")
    _emit_timeline_event(course_id, "push_started", {
        "object_type": "page",
        "object_id": str(slug),
        "slug": str(slug),
    })
    success = canvas_api.update_page(config, slug, content, from_staging=True)
    if not success:
        return {"ok": False, "slug": slug,
                "error": f"Canvas API rejected the update for page '{slug}'."}

    # 6. Verify BEFORE clearing staging
    verify = post_write_verify.verify_page(config, slug)
    if not verify.get("ok"):
        return {"ok": False, "slug": slug,
                "error": verify.get("error", "Verification failed."),
                "staging_preserved": True}

    _emit_timeline_event(course_id, "push_verified", {
        "object_type": "page",
        "content_type": "page",
        "object_id": str(slug),
        "slug": str(slug),
        "canvas_url": verify.get("canvas_url", ""),
        "warnings": verify.get("warnings", []),
    })
    _cleanup_resolved_backlog(course_id, "page", slug, verify)

    # 7. Clear staging only after successful verification
    staging_manager.clear_staged(course_id, "pages", slug)
    # Clear any staged files that were uploaded for this page.
    for key in uploaded_keys:
        staging_manager.clear_staged_file(course_id, key)

    # 8. Update build progress
    _track_progress(course_id, slug, module_num, page_type)

    return _attach_schema_warnings(_attach_audience_violations({
        "ok": True,
        "slug": slug,
        "canvas_url": verify.get("canvas_url", ""),
        "content_length": verify.get("content_length", len(content)),
        "warnings": verify.get("warnings", []),
    }, audience_violations), schema_warnings)


def _track_progress(course_id, slug_or_id, module_num=None, page_type=None):
    """Update build progress after a successful push.

    If module_num/page_type are provided explicitly, uses them directly.
    Otherwise attempts best-effort slug parsing for page pushes.
    Passes the actual slug so drift detection can find the right Canvas page.
    Silently returns on any failure — progress tracking should never
    break a push.
    """
    try:
        m, pt = module_num, page_type
        actual_slug = str(slug_or_id)
        if m is None or pt is None:
            m, pt = progress_tracker.try_parse_slug(actual_slug)
        if m is not None and pt is not None:
            progress_tracker.update_progress(
                course_id, m, pt, "complete", slug=actual_slug)
    except Exception:
        pass  # Never let progress tracking break a push


def push_assignment(config, assignment_id, course_id, dry_run=False,
                    module_num=None, page_type=None,
                    allow_audience_violations=False):
    """Push a staged assignment description to Canvas."""
    content = staging_manager.get_staged(course_id, "assignments",
                                         str(assignment_id))
    if content is None:
        return {"ok": False, "id": assignment_id,
                "error": f"No staged content for assignment {assignment_id}."}

    schema_result, schema_warnings = _check_schema_gate(
        course_id, "assignments", str(assignment_id), payload=content)
    if schema_result:
        return schema_result

    audience = _resolve_staged_audience(
        course_id, "assignments", str(assignment_id))
    audience_violations = lint_content(content, audience)
    gate_result, audience_violations = _check_audience_gate(
        audience_violations, audience, allow_audience_violations,
        f"assignment '{assignment_id}'")
    if gate_result:
        gate_result["id"] = assignment_id
        return gate_result

    if dry_run:
        _emit_dry_run_push_events(course_id, "assignment", assignment_id)
        return _attach_schema_warnings(_attach_audience_violations({
            "ok": True, "id": assignment_id, "dry_run": True,
            "content_length": len(content),
        }, audience_violations), schema_warnings)

    # Backup current
    current = canvas_api.get_assignment(config, assignment_id)
    if current and current.get("description"):
        backup_manager.save_backup(
            course_id, "assignments", str(assignment_id),
            current["description"],
            diff_summary=f"Pre-push backup for assignment {assignment_id}",
        )

    # Push description update
    _emit_timeline_event(course_id, "push_started", {
        "object_type": "assignment",
        "object_id": str(assignment_id),
    })
    result = canvas_api.update_assignment(config, assignment_id,
                                          description=content)
    if not result:
        return {"ok": False, "id": assignment_id,
                "error": "Canvas API rejected the assignment update."}

    # Verify BEFORE clearing staging
    verify = post_write_verify.verify_assignment(config, assignment_id)
    if not verify.get("ok"):
        return {"ok": False, "id": assignment_id,
                "error": verify.get("error", "Verification failed."),
                "staging_preserved": True}

    _emit_timeline_event(course_id, "push_verified", {
        "object_type": "assignment",
        "content_type": "assignment",
        "object_id": str(assignment_id),
        "canvas_url": verify.get("canvas_url", ""),
        "warnings": verify.get("warnings", []),
    })
    _cleanup_resolved_backlog(course_id, "assignment", assignment_id, verify)

    # Clear staging only after successful verification
    staging_manager.clear_staged(course_id, "assignments", str(assignment_id))

    # Update build progress
    _track_progress(course_id, assignment_id, module_num, page_type)

    return _attach_schema_warnings(_attach_audience_violations({
        "ok": True,
        "id": assignment_id,
        "canvas_url": verify.get("canvas_url", ""),
        "content_length": verify.get("content_length", len(content)),
        "warnings": verify.get("warnings", []),
    }, audience_violations), schema_warnings)


def push_discussion(config, topic_id, course_id, dry_run=False,
                    module_num=None, page_type=None,
                    allow_audience_violations=False):
    """Push a staged discussion prompt to Canvas."""
    content = staging_manager.get_staged(course_id, "discussions",
                                          str(topic_id))
    if content is None:
        return {"ok": False, "id": topic_id,
                "error": f"No staged content for discussion {topic_id}."}

    schema_result, schema_warnings = _check_schema_gate(
        course_id, "discussions", str(topic_id), payload=content)
    if schema_result:
        return schema_result

    audience = _resolve_staged_audience(
        course_id, "discussions", str(topic_id))
    audience_violations = lint_content(content, audience)
    gate_result, audience_violations = _check_audience_gate(
        audience_violations, audience, allow_audience_violations,
        f"discussion '{topic_id}'")
    if gate_result:
        gate_result["id"] = topic_id
        return gate_result

    if dry_run:
        _emit_dry_run_push_events(course_id, "discussion", topic_id)
        return _attach_schema_warnings(_attach_audience_violations({
            "ok": True, "id": topic_id, "dry_run": True,
            "content_length": len(content),
        }, audience_violations), schema_warnings)

    # Backup current
    current = canvas_api.get_discussion(config, topic_id)
    if current and current.get("message"):
        backup_manager.save_backup(
            course_id, "discussions", str(topic_id),
            current["message"],
            diff_summary=f"Pre-push backup for discussion {topic_id}",
        )

    # Push: discussion prompt is the 'message' field
    _emit_timeline_event(course_id, "push_started", {
        "object_type": "discussion",
        "object_id": str(topic_id),
    })
    result = canvas_api.update_discussion(config, topic_id, message=content)
    if not result:
        return {"ok": False, "id": topic_id,
                "error": "Canvas API rejected the discussion update."}

    # Verify BEFORE clearing staging
    verify = post_write_verify.verify_discussion(config, topic_id)
    if not verify.get("ok"):
        return {"ok": False, "id": topic_id,
                "error": verify.get("error", "Verification failed."),
                "staging_preserved": True}

    _emit_timeline_event(course_id, "push_verified", {
        "object_type": "discussion",
        "content_type": "discussion",
        "object_id": str(topic_id),
        "canvas_url": verify.get("canvas_url", ""),
        "warnings": verify.get("warnings", []),
    })
    _cleanup_resolved_backlog(course_id, "discussion", topic_id, verify)

    # Clear staging only after successful verification
    staging_manager.clear_staged(course_id, "discussions", str(topic_id))

    # Update build progress
    _track_progress(course_id, topic_id, module_num, page_type)

    return _attach_schema_warnings(_attach_audience_violations({
        "ok": True,
        "id": topic_id,
        "canvas_url": verify.get("canvas_url", ""),
        "content_length": verify.get("content_length", len(content)),
        "warnings": verify.get("warnings", []),
    }, audience_violations), schema_warnings)


def push_quiz(config, quiz_id, course_id, dry_run=False,
              module_num=None, page_type=None,
              allow_audience_violations=False,
              allow_identical_feedback=False):
    """Push a staged quiz to Canvas — settings AND questions.

    Staged quiz JSON format:
    {
        "quiz": { "title": "...", "time_limit": 5, ... },
        "questions": [
            {
                "question_name": "Q1",
                "question_text": "<p>...</p>",
                "question_type": "multiple_choice_question",
                "points_possible": 3,
                "correct_comments": "...",
                "incorrect_comments": "...",
                "answers": [
                    {"answer_text": "...", "answer_weight": 100, "answer_comment": "..."},
                    {"answer_text": "...", "answer_weight": 0, "answer_comment": "..."}
                ]
            }
        ]
    }

    Returns dict with ok, id, canvas_url, question_count, warnings.
    """
    content = staging_manager.get_staged(course_id, "quizzes", str(quiz_id))
    if content is None:
        return {"ok": False, "id": quiz_id,
                "error": f"No staged content for quiz {quiz_id}."}

    if not isinstance(content, dict):
        return {"ok": False, "id": quiz_id,
                "error": "Staged quiz content is not valid JSON."}

    schema_result, schema_warnings = _check_schema_gate(
        course_id, "quizzes", str(quiz_id), payload=content)
    if schema_result:
        return schema_result

    identical_result, identical_issues = _check_identical_feedback_gate(
        content, allow_identical_feedback, f"quiz '{quiz_id}'")
    if identical_result:
        identical_result["id"] = quiz_id
        return identical_result

    quiz_text = _extract_quiz_visible_text(content)
    audience_violations = lint_content(quiz_text, "student")
    gate_result, audience_violations = _check_audience_gate(
        audience_violations, "student", allow_audience_violations,
        f"quiz '{quiz_id}'")
    if gate_result:
        gate_result["id"] = quiz_id
        return gate_result

    if not str(quiz_id).isdigit():
        return {
            "ok": False,
            "id": quiz_id,
            "error": (
                f"Quiz pushes require the numeric Canvas quiz ID, got "
                f"'{quiz_id}'. Rename the staged quiz to the Canvas quiz ID "
                "before pushing, for example: "
                f"scripts/staging_manager.py --course-id {course_id} "
                f"--rename {quiz_id} <canvas_quiz_id> --type quizzes"
            ),
            "staging_preserved": True,
        }

    if dry_run:
        _emit_dry_run_push_events(course_id, "quiz", quiz_id)
        staged_q_count = len(content.get("questions", []))
        result = _attach_audience_violations({
            "ok": True, "id": quiz_id, "dry_run": True,
            "staged_question_count": staged_q_count,
        }, audience_violations)
        if identical_issues:
            result["identical_feedback"] = identical_issues
        return _attach_schema_warnings(result, schema_warnings)

    # Backup current quiz (settings + questions)
    current_quiz = canvas_api.get_quiz(config, quiz_id)
    if current_quiz:
        current_questions = canvas_api.get_quiz_questions(config, quiz_id)
        backup_data = {"quiz": current_quiz}
        if current_questions is not None:
            backup_data["questions"] = current_questions
        else:
            # get_quiz_questions() failed to fetch (returned None rather than
            # a list). Omit "questions" rather than storing None — an absent
            # key already means "settings-only restore, leave questions
            # alone" to _restore_quiz, which is the safe fallback when we
            # don't actually know the current question state.
            logging.warning(
                "Could not fetch current questions for quiz %s before push; "
                "backup will restore settings only, not questions.",
                quiz_id)
        backup_manager.save_backup(
            course_id, "quizzes", str(quiz_id), backup_data,
            diff_summary=f"Pre-push backup for quiz {quiz_id}",
        )

    _emit_timeline_event(course_id, "push_started", {
        "object_type": "quiz",
        "object_id": str(quiz_id),
    })

    errors = []

    # Step 1: Update quiz settings (if provided)
    if "quiz" in content and content["quiz"]:
        settings_result = canvas_api.update_quiz(config, quiz_id,
                                                  **content["quiz"])
        if not settings_result:
            errors.append("Failed to update quiz settings.")

    # Step 2: Reconcile questions — create, update, and delete to match staged
    #
    # Key distinction:
    #   "questions" key ABSENT  → settings-only push, don't touch questions
    #   "questions": []         → explicit intent: quiz should have 0 questions
    #   "questions": [...]      → reconcile: create/update/delete to match
    has_questions_key = "questions" in content
    staged_questions = content.get("questions", [])
    questions_applied = 0

    if has_questions_key:
        # Fetch current questions to decide create vs update vs delete
        existing_questions = canvas_api.get_quiz_questions(config, quiz_id)
        existing_by_id = {q["id"]: q for q in existing_questions} if existing_questions else {}

        # Track which existing question IDs are accounted for by staged content
        staged_ids = set()

        for sq in staged_questions:
            sq_id = sq.get("id")

            if sq_id and sq_id in existing_by_id:
                # Update existing question
                staged_ids.add(sq_id)
                update_fields = {k: v for k, v in sq.items() if k != "id"}
                # Normalize `_html` feedback variants to plain field names
                # (canonical per fetch_canvas_to_stage.py). Plain wins if both
                # are present.
                for _plain in (
                    "correct_comments",
                    "incorrect_comments",
                    "neutral_comments",
                ):
                    _html_key = f"{_plain}_html"
                    if _html_key in update_fields:
                        if not update_fields.get(_plain):
                            update_fields[_plain] = update_fields[_html_key]
                        update_fields.pop(_html_key, None)
                result = canvas_api.update_quiz_question(
                    config, quiz_id, sq_id, **update_fields)
                if result:
                    questions_applied += 1
                else:
                    errors.append(f"Failed to update question {sq_id}.")
            else:
                # Create new question
                result = canvas_api.add_quiz_question(
                    config, quiz_id,
                    question_name=sq.get("question_name", "Question"),
                    question_text=sq.get("question_text", ""),
                    question_type=sq.get("question_type",
                                         "multiple_choice_question"),
                    points_possible=sq.get("points_possible", 1),
                    answers=sq.get("answers"),
                    position=sq.get("position"),
                    correct_comments=sq.get("correct_comments") or sq.get("correct_comments_html"),
                    incorrect_comments=sq.get("incorrect_comments") or sq.get("incorrect_comments_html"),
                    neutral_comments=sq.get("neutral_comments") or sq.get("neutral_comments_html"),
                    text_after_answers=sq.get("text_after_answers"),
                )
                if result:
                    questions_applied += 1
                else:
                    errors.append(
                        f"Failed to create question: "
                        f"{sq.get('question_name', 'unknown')}.")

        # Delete Canvas questions not present in staged JSON
        # This prevents stale questions from accumulating on repeated pushes.
        # When staged_questions is [], ALL existing questions are deleted.
        for existing_id in existing_by_id:
            if existing_id not in staged_ids:
                deleted = canvas_api.delete_quiz_question(
                    config, quiz_id, existing_id)
                if not deleted:
                    errors.append(
                        f"Failed to delete stale question {existing_id}.")

    # Step 3: Sync points_possible (Canvas doesn't auto-calculate)
    if has_questions_key and not errors:
        total_points = sum(
            q.get("points_possible", 0) for q in staged_questions)
        canvas_api.update_quiz(config, quiz_id,
                               points_possible=total_points)

        # Also sync linked assignment for graded quizzes
        refreshed = canvas_api.get_quiz(config, quiz_id)
        if refreshed and refreshed.get("quiz_type") == "assignment":
            asn_id = refreshed.get("assignment_id")
            if asn_id:
                canvas_api.update_assignment(config, asn_id,
                                              points_possible=total_points)

    # If any question push failed, do NOT clear staging
    if errors:
        return {
            "ok": False,
            "id": quiz_id,
            "errors": errors,
            "questions_applied": questions_applied,
            "questions_staged": len(staged_questions),
            "staging_preserved": True,
        }

    # Step 4: Verify
    # When questions key is present, enforce strict count/points checks.
    # When absent (settings-only push), skip strict checks.
    expected_q = len(staged_questions) if has_questions_key else None
    expected_pts = (sum(q.get("points_possible", 0)
                        for q in staged_questions)
                    if has_questions_key else None)
    verify = post_write_verify.verify_quiz(
        config, quiz_id,
        expected_points=expected_pts,
        expected_questions=expected_q,
    )

    if not verify.get("ok"):
        return {"ok": False, "id": quiz_id,
                "error": verify.get("error", "Verification failed."),
                "staging_preserved": True}

    _emit_timeline_event(course_id, "push_verified", {
        "object_type": "quiz",
        "content_type": "quiz",
        "object_id": str(quiz_id),
        "canvas_url": verify.get("canvas_url", ""),
        "question_count": verify.get("question_count", questions_applied),
        "points_possible": verify.get("points_possible", 0),
        "warnings": verify.get("warnings", []),
    })
    _cleanup_resolved_backlog(course_id, "quiz", quiz_id, verify)

    # Step 5: Clear staging only after full success
    staging_manager.clear_staged(course_id, "quizzes", str(quiz_id))

    # Step 6: Update build progress
    _track_progress(course_id, quiz_id, module_num, page_type)

    result = _attach_audience_violations({
        "ok": True,
        "id": quiz_id,
        "canvas_url": verify.get("canvas_url", ""),
        "question_count": verify.get("question_count", questions_applied),
        "points_possible": verify.get("points_possible", 0),
        "warnings": verify.get("warnings", []),
    }, audience_violations)
    if identical_issues:
        result["identical_feedback"] = identical_issues
    return _attach_schema_warnings(result, schema_warnings)


def push_pages_batch(config, slugs, course_id, dry_run=False,
                     allow_audience_violations=False):
    """Push multiple staged pages. Returns aggregate result."""
    results = []
    for slug in slugs:
        result = push_page(
            config, slug, course_id, dry_run,
            allow_audience_violations=allow_audience_violations)
        results.append(result)

    pushed = sum(1 for r in results if r.get("ok"))
    failed = sum(1 for r in results if not r.get("ok"))

    return {
        "ok": failed == 0,
        "pushed": pushed,
        "failed": failed,
        "results": results,
    }


# ============================================================
# CLI
# ============================================================
# ═══════════════════════════════════════════════════════════════════════════
# Mockup-module CREATE paths (locked 2026-05-27 — STP530 iteration)
# ═══════════════════════════════════════════════════════════════════════════
#
# The standard push_quiz / push_assignment functions UPDATE an existing
# Canvas object — they assume the object already exists. Mockup workflows
# need a CREATE path: stage a brand-new mockup quiz/assignment, push it as
# a new Canvas object, and wire it into a Mockup module the user has
# already created. These helpers wrap the multi-step Canvas sequence
# (create object → add questions/details → add to module → verify →
# clear staging → log timeline) into a single atomic call so it doesn't
# get hand-rolled per release.


def _read_mockup_quiz_payload(course_id, staged_key):
    """Load + validate a staged mockup quiz payload."""
    content = staging_manager.get_staged(course_id, "quizzes", staged_key)
    if content is None:
        return None, f"no staged quiz at quizzes/{staged_key}"
    if isinstance(content, str):
        try:
            content = json.loads(content)
        except json.JSONDecodeError as exc:
            return None, f"staged quiz is not valid JSON: {exc}"
    if not isinstance(content, dict):
        return None, "staged quiz must be a JSON object"
    if not content.get("title"):
        return None, "staged quiz missing 'title'"
    return content, None


def push_new_mockup_quiz(course_id, staged_key, mockup_module_id,
                         *, dry_run=False):
    """Create a NEW quiz from staged content + wire it into a mockup module.

    Atomic sequence:
        1. Read staged quiz JSON from courses/{id}/staging/quizzes/{key}
        2. Canvas: create_quiz(...)   (now accepts scoring_policy directly)
        3. Canvas: add_quiz_question(...) per staged question
        4. Canvas: add_module_item(...) into the mockup module
        5. Canvas: get_quiz(...) → verify
        6. staging_manager.clear_staged(...)
        7. timeline_logger.emit_event("mockup_quiz_created", ...)

    Returns {"ok": bool, ...details...}.
    """
    config = canvas_api.get_config(course_id=course_id)
    canvas_api.require_course_id(config)

    payload, err = _read_mockup_quiz_payload(course_id, staged_key)
    if err:
        return {"ok": False, "error": err, "staged_key": staged_key}

    if dry_run:
        _emit_dry_run_push_events(course_id, "mockup_quiz", staged_key)
        return {
            "ok": True,
            "dry_run": True,
            "staged_key": staged_key,
            "title": payload.get("title"),
            "question_count": len(payload.get("questions", []) or []),
            "module_id": mockup_module_id,
        }

    # Step 1: create the quiz shell (scoring_policy passed inline now)
    create_kwargs = {
        "title": payload["title"],
        "description": payload.get("description", ""),
        "quiz_type": payload.get("quiz_type", "assignment"),
        "allowed_attempts": payload.get("allowed_attempts", 1),
        "shuffle_answers": payload.get("shuffle_answers", True),
        "published": False,  # Mockups are always created unpublished.
    }
    if payload.get("time_limit") is not None:
        create_kwargs["time_limit"] = payload["time_limit"]
    if payload.get("points_possible") is not None:
        create_kwargs["points_possible"] = payload["points_possible"]
    if payload.get("scoring_policy"):
        create_kwargs["scoring_policy"] = payload["scoring_policy"]
    if payload.get("assignment_group_id"):
        create_kwargs["assignment_group_id"] = payload["assignment_group_id"]

    quiz = canvas_api.create_quiz(config, **create_kwargs, from_staging=True)
    if not quiz or "id" not in quiz:
        return {"ok": False, "error": "create_quiz failed",
                "staged_key": staged_key}

    quiz_id = quiz["id"]
    staged_questions = payload.get("questions", []) or []
    question_results = []
    for q in staged_questions:
        kwargs = {
            "question_name": q.get("question_name", ""),
            "question_text": q.get("question_text", ""),
            "question_type": q.get("question_type",
                                   "multiple_choice_question"),
            "points_possible": q.get("points_possible", 1),
            "answers": q.get("answers"),
        }
        # Forward per-question feedback channels supported by canvas_api.
        for fb in ("correct_comments", "incorrect_comments",
                   "neutral_comments", "correct_comments_html",
                   "incorrect_comments_html", "neutral_comments_html"):
            if q.get(fb) is not None:
                kwargs[fb] = q[fb]
        qr = canvas_api.add_quiz_question(config, quiz_id=quiz_id, **kwargs)
        question_results.append(qr)

    # add_quiz_question returns the created Question dict on success, None
    # on failure. A quiz that exists but is missing staged questions is not
    # a verified push — count failures so verification can't be fooled by
    # create_quiz alone succeeding.
    questions_failed = sum(1 for qr in question_results if not qr)

    # Step 2: wire into mockup module
    module_item = canvas_api.add_module_item(
        config, module_id=mockup_module_id,
        title=payload["title"], item_type="Quiz", content_id=quiz_id,
    )

    # Step 3: verify (quiz object exists AND every staged question landed)
    verified = canvas_api.get_quiz(config, quiz_id)
    verify_ok = (bool(verified) and verified.get("id") == quiz_id
                 and questions_failed == 0)

    # Step 4: clear staging on verified success
    cleared = False
    if verify_ok:
        try:
            staging_manager.clear_staged(course_id, "quizzes", staged_key)
            cleared = True
        except Exception as exc:
            logging.warning("clear_staged failed for %s: %s", staged_key, exc)

    # Step 5: timeline event
    _emit_timeline_event(course_id, "mockup_quiz_created", {
        "staged_key": staged_key,
        "quiz_id": quiz_id,
        "module_id": mockup_module_id,
        "question_count": len(question_results),
        "verified": verify_ok,
    })

    result = {
        "ok": verify_ok,
        "quiz_id": quiz_id,
        "staged_key": staged_key,
        "module_id": mockup_module_id,
        "module_item_id": (module_item or {}).get("id"),
        "question_count": len(question_results),
        "questions_failed": questions_failed,
        "staging_cleared": cleared,
    }
    if questions_failed:
        result["error"] = (
            f"{questions_failed} of {len(staged_questions)} question(s) "
            f"failed to create for quiz {quiz_id}; staging preserved "
            "for retry."
        )
    return result


def _read_mockup_assignment_payload(course_id, staged_key):
    """Load + validate a staged mockup assignment payload."""
    content = staging_manager.get_staged(course_id, "assignments", staged_key)
    if content is None:
        return None, f"no staged assignment at assignments/{staged_key}"
    if isinstance(content, str):
        try:
            content = json.loads(content)
        except json.JSONDecodeError as exc:
            return None, f"staged assignment is not valid JSON: {exc}"
    if not isinstance(content, dict):
        return None, "staged assignment must be a JSON object"
    if not content.get("name"):
        return None, "staged assignment missing 'name'"
    return content, None


def push_new_mockup_assignment(course_id, staged_key, mockup_module_id,
                               *, dry_run=False):
    """Create a NEW assignment from staged content + wire it into a mockup module.

    Atomic sequence: create_assignment → add_module_item → verify → clear staging
    → log timeline. Always pushes published=False (mockups are draft).
    """
    config = canvas_api.get_config(course_id=course_id)
    canvas_api.require_course_id(config)

    payload, err = _read_mockup_assignment_payload(course_id, staged_key)
    if err:
        return {"ok": False, "error": err, "staged_key": staged_key}

    if dry_run:
        _emit_dry_run_push_events(course_id, "mockup_assignment", staged_key)
        return {
            "ok": True,
            "dry_run": True,
            "staged_key": staged_key,
            "name": payload.get("name"),
            "module_id": mockup_module_id,
        }

    create_kwargs = {
        "name": payload["name"],
        "description": payload.get("description", ""),
        "points_possible": payload.get("points_possible", 100),
        "grading_type": payload.get("grading_type", "points"),
        "submission_types": payload.get("submission_types",
                                        ["online_text_entry"]),
        "published": False,  # Mockups are always created unpublished.
    }
    if payload.get("assignment_group_id"):
        create_kwargs["assignment_group_id"] = payload["assignment_group_id"]
    if payload.get("due_at"):
        create_kwargs["due_at"] = payload["due_at"]

    assignment = canvas_api.create_assignment(config, **create_kwargs, from_staging=True)
    if not assignment or "id" not in assignment:
        return {"ok": False, "error": "create_assignment failed",
                "staged_key": staged_key}

    assignment_id = assignment["id"]
    module_item = canvas_api.add_module_item(
        config, module_id=mockup_module_id,
        title=payload["name"], item_type="Assignment", content_id=assignment_id,
    )

    # Verify (re-fetch via API): use the assignment dict echo as proof of life
    verify_ok = bool(assignment) and assignment.get("id") == assignment_id

    cleared = False
    if verify_ok:
        try:
            staging_manager.clear_staged(course_id, "assignments", staged_key)
            cleared = True
        except Exception as exc:
            logging.warning("clear_staged failed for %s: %s", staged_key, exc)

    _emit_timeline_event(course_id, "mockup_assignment_created", {
        "staged_key": staged_key,
        "assignment_id": assignment_id,
        "module_id": mockup_module_id,
        "verified": verify_ok,
    })

    return {
        "ok": verify_ok,
        "assignment_id": assignment_id,
        "staged_key": staged_key,
        "module_id": mockup_module_id,
        "module_item_id": (module_item or {}).get("id"),
        "staging_cleared": cleared,
    }


def main():
    parser = argparse.ArgumentParser(
        description="ASUCB Push to Canvas — backup → push → verify → clear")
    parser.add_argument("--type", required=False,
                        choices=["page", "assignment", "quiz", "discussion",
                                 "syllabus"])
    # Mockup-module CREATE paths (alternative entry points)
    parser.add_argument("--create-mockup-quiz", action="store_true",
                        help="Create a NEW Canvas quiz from staged content "
                             "and wire it into a mockup module. Requires "
                             "--staged-key, --module-id, --course-id.")
    parser.add_argument("--create-mockup-assignment", action="store_true",
                        help="Create a NEW Canvas assignment from staged "
                             "content and wire it into a mockup module. "
                             "Requires --staged-key, --module-id, --course-id.")
    parser.add_argument("--staged-key",
                        help="Staged object key (used with "
                             "--create-mockup-quiz/--create-mockup-assignment)")
    parser.add_argument("--module-id",
                        help="Canvas module ID to wire the new mockup "
                             "object into")
    parser.add_argument("--slug", help="Page slug (for page type)")
    parser.add_argument("--slugs", help="Comma-separated page slugs (batch)")
    parser.add_argument("--id", help="Object ID (assignment/quiz/discussion)")
    parser.add_argument("--course-id", help="Course ID override")
    parser.add_argument("--dry-run", action="store_true",
                        help="Validate without pushing to Canvas")
    parser.add_argument("--confirm-verb",
                        choices=["push", "approved", "deploy"],
                        help="Explicit push verb (required for non-dry-run). "
                        "Accepts only 'push', 'approved', or 'deploy'. "
                        "Ambiguous affirmatives like 'ok'/'yes'/'confirm' "
                        "are intentionally rejected per CLAUDE.md "
                        "§Write Safety Guard.")
    parser.add_argument("--allow-audience-violations", action="store_true",
                        help="Warn but do not block on audience-lint violations")
    parser.add_argument("--allow-identical-feedback", action="store_true",
                        help="Warn but do not block when quiz feedback channels are identical")
    parser.add_argument("--module-num",
                        help="Module number for build progress tracking")
    parser.add_argument("--page-type",
                        help="Page type for build progress tracking "
                        "(e.g., overview, lesson, knowledge-check)")

    args = parser.parse_args()

    # Mockup CREATE paths short-circuit the standard --type pipeline.
    if args.create_mockup_quiz or args.create_mockup_assignment:
        if not args.staged_key:
            parser.error("--create-mockup-* requires --staged-key")
        if not args.module_id:
            parser.error("--create-mockup-* requires --module-id")
        if not args.course_id:
            parser.error("--create-mockup-* requires --course-id")
        if not args.dry_run and not args.confirm_verb:
            parser.error(
                "non-dry-run mockup create requires --confirm-verb "
                "{push,approved,deploy}."
            )
        if args.create_mockup_quiz:
            result = push_new_mockup_quiz(
                args.course_id, args.staged_key, args.module_id,
                dry_run=args.dry_run,
            )
        else:
            result = push_new_mockup_assignment(
                args.course_id, args.staged_key, args.module_id,
                dry_run=args.dry_run,
            )
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)
        return

    if not args.type:
        parser.error("--type is required unless --create-mockup-quiz "
                     "or --create-mockup-assignment is used")

    # Gate: require explicit push verb for non-dry-run writes.
    # See CLAUDE.md §Write Safety Guard. Behavior-only gate is insufficient;
    # this is the script-enforced floor.
    if not args.dry_run and not args.confirm_verb:
        parser.error(
            "non-dry-run push requires --confirm-verb {push,approved,deploy}. "
            "Ambiguous affirmatives ('ok','yes','confirm') are rejected."
        )

    config = canvas_api.get_config(course_id=args.course_id)
    canvas_api.require_course_id(config)
    course_id = config["course_id"]

    # Progress tracking context (passed to all push functions)
    mod_num = args.module_num
    pg_type = args.page_type

    if args.type == "page":
        if args.slugs:
            slugs = [s.strip() for s in args.slugs.split(",")]
            result = push_pages_batch(
                config, slugs, course_id, args.dry_run,
                allow_audience_violations=args.allow_audience_violations)
        elif args.slug:
            result = push_page(config, args.slug, course_id, args.dry_run,
                               module_num=mod_num, page_type=pg_type,
                               allow_audience_violations=(
                                   args.allow_audience_violations))
        else:
            parser.error("--type page requires --slug or --slugs")
            return

    elif args.type == "assignment":
        if not args.id:
            parser.error("--type assignment requires --id")
        result = push_assignment(config, args.id, course_id, args.dry_run,
                                 module_num=mod_num, page_type=pg_type,
                                 allow_audience_violations=(
                                     args.allow_audience_violations))

    elif args.type == "discussion":
        if not args.id:
            parser.error("--type discussion requires --id")
        result = push_discussion(config, args.id, course_id, args.dry_run,
                                 module_num=mod_num, page_type=pg_type,
                                 allow_audience_violations=(
                                     args.allow_audience_violations))

    elif args.type == "quiz":
        if not args.id:
            parser.error("--type quiz requires --id")
        result = push_quiz(config, args.id, course_id, args.dry_run,
                           module_num=mod_num, page_type=pg_type,
                           allow_audience_violations=(
                               args.allow_audience_violations),
                           allow_identical_feedback=(
                               args.allow_identical_feedback))

    elif args.type == "syllabus":
        import push_syllabus as _push_syllabus_mod
        result = _push_syllabus_mod.push_syllabus(
            course_id, dry_run=args.dry_run,
            allow_audience_violations=args.allow_audience_violations)

    print(json.dumps(result, indent=2))

    if not result.get("ok"):
        sys.exit(1)


if __name__ == "__main__":
    main()
