#!/usr/bin/env python3
"""ASUCB Staging Manager — per-course, object-aware staging.

Manages a local staging area where content is written before being pushed
to Canvas. Supports pages, assignments, discussions, and quizzes.

Storage layout:
    courses/{courseId}/staging/pages/{slug}.html
    courses/{courseId}/staging/assignments/{assignment_id}.html
    courses/{courseId}/staging/discussions/{topic_id}.html
    courses/{courseId}/staging/quizzes/{quiz_id}.json

Render-relevant .meta.json sidecar fields:
    The unified staging review (scripts/unified_staging_review.py) reads each
    staged item's `.meta.json` sidecar for grouping + display:
        - meta.title         → item title shown in the TOC + section header
        - meta.module_number → module bucket the item is grouped under

    Both fields are OPTIONAL on disk. The review surface auto-recovers where
    possible (filename `m{N}-…` pattern for module_number; quiz JSON title
    field for quizzes; first <h1>-<h6> text for HTML-bodied pages, assignments,
    and discussions). When neither the meta sidecar nor a fallback yields a
    value, the artifact renders as "Unmapped Staged Content" with the filename
    slug as title — supply meta.title + meta.module_number explicitly to avoid
    that degraded display.
"""

import argparse
import json
import os
import re
import shutil
import sys
from pathlib import Path

# Allow importing canvas_api from same directory
sys.path.insert(0, os.path.dirname(__file__))
from canvas_api import get_plugin_root
from staging_server import COURSE_ID_RE, OBJ_ID_RE
import staging_schema


# Object type → file extension mapping
EXTENSIONS = {
    "pages": ".html",
    "assignments": ".html",
    "discussions": ".html",
    "quizzes": ".json",
    "interactives": ".html",
    "rubrics": ".html",
}

VALID_TYPES = list(EXTENSIONS.keys())

# Regex for safe file keys (used by the 'files' staging type).
# Must match [A-Za-z0-9._-]+ with no path separators, no '..' sequences.
FILE_KEY_RE = re.compile(r'^[A-Za-z0-9._-]+$')

# Regex for safe file extensions (e.g. 'png', 'jpg', 'svg').
FILE_EXT_RE = re.compile(r'^[A-Za-z0-9]+$')


def _invalid_path():
    raise ValueError("Invalid path component")


def _validate_course_id(course_id):
    course_id = str(course_id or "")
    if not COURSE_ID_RE.fullmatch(course_id) or course_id in {".", ".."}:
        _invalid_path()
    return course_id


def _validate_object_type(object_type):
    object_type = str(object_type or "")
    if not OBJ_ID_RE.fullmatch(object_type) or object_type not in EXTENSIONS:
        _invalid_path()
    return object_type


def _validate_file_key(key):
    """Validate a file key for the 'files' staging type."""
    key = str(key or "")
    if not FILE_KEY_RE.fullmatch(key) or key in {".", ".."}:
        _invalid_path()
    return key


def _validate_file_ext(ext):
    """Validate a file extension (without leading dot) for the 'files' staging type."""
    ext = str(ext or "")
    if not FILE_EXT_RE.fullmatch(ext) or ext in {".", ".."}:
        _invalid_path()
    return ext


def _object_id_components(object_type, object_id):
    raw = str(object_id or "")
    if object_type == "interactives":
        raw = raw.removesuffix(EXTENSIONS[object_type])
        parts = raw.split("/")
        if not parts:
            _invalid_path()
        for part in parts:
            if not OBJ_ID_RE.fullmatch(part) or part in {".", ".."}:
                _invalid_path()
        return parts
    if not OBJ_ID_RE.fullmatch(raw) or raw in {".", ".."}:
        _invalid_path()
    return [raw]


def _resolve_inside(root, *parts):
    try:
        root_path = Path(root).resolve(strict=False)
        candidate = (root_path / Path(*parts)).resolve(strict=False)
    except (OSError, RuntimeError):
        _invalid_path()
    if not candidate.is_relative_to(root_path):
        _invalid_path()
    return candidate


def _staging_dir(course_id, object_type=None):
    """Return the staging directory path, creating it if needed."""
    course_id = _validate_course_id(course_id)
    courses_root = Path(get_plugin_root()) / "courses"
    parts = [course_id, "staging"]
    if object_type:
        object_type = _validate_object_type(object_type)
        parts.append(object_type)
    path = _resolve_inside(courses_root, *parts)
    os.makedirs(path, exist_ok=True)
    return str(path)


def _files_staging_dir(course_id):
    """Return the files staging directory path, creating it if needed."""
    course_id = _validate_course_id(course_id)
    courses_root = Path(get_plugin_root()) / "courses"
    path = _resolve_inside(courses_root, course_id, "staging", "files")
    os.makedirs(path, exist_ok=True)
    return path


def _staged_path(course_id, object_type, object_id):
    """Return the full file path for a staged object."""
    object_type = _validate_object_type(object_type)
    ext = EXTENSIONS[object_type]
    components = _object_id_components(object_type, object_id)
    staging_root = _staging_dir(course_id, object_type)
    path = _resolve_inside(
        staging_root,
        *components[:-1],
        f"{components[-1]}{ext}",
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    return str(path)


def stage(course_id, object_type, object_id, content, validate=True,
          module_number=None, title=None, source_canvas_id=None,
          provisional_id=None, destructive_reconcile_ok=None):
    """Stage content for later push to Canvas.

    Args:
        course_id: Canvas course ID
        object_type: One of 'pages', 'assignments', 'discussions', 'quizzes'
        object_id: Slug (pages) or numeric ID (others)
        content: HTML string or JSON-serializable dict (quizzes)
        validate: Run Tier 1 preflight checks (default True)
        module_number: Optional int — module this item belongs to (written to
            .meta.json). Used by the unified staging review for module grouping.
            When omitted, the review falls back to filename pattern (m{N}-…);
            when no pattern matches, the item renders as "Unmapped Staged
            Content". Pass explicitly to guarantee correct grouping.
        title: Optional str — display title for this item (written to
            .meta.json). Used by the unified staging review as the TOC label +
            section header. When omitted, the review falls back to quiz JSON
            title (quizzes) or first HTML heading text (pages/assignments/
            discussions); when no fallback resolves, the obj_id filename slug
            is used. Pass explicitly to guarantee correct display.
        source_canvas_id: Optional Canvas object ID for fetched/re-staged content
        provisional_id: Optional local ID for create-if-missing staged content
        destructive_reconcile_ok: Optional explicit flag for destructive quiz reconciliation

    Returns:
        dict with ok, path, course_id, object_type, object_id, preflight (if validate)
        — or {"ok": False, "error": ...} when a path component fails validation.
    """
    try:
        path = _staged_path(course_id, object_type, object_id)
    except ValueError as e:
        return {"ok": False, "error": str(e)}

    if object_type == "quizzes" and isinstance(content, (dict, list)):
        with open(path, "w", encoding="utf-8") as f:
            json.dump(content, f, indent=2, ensure_ascii=False)
    else:
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)

    # Write .meta.json sidecar for module grouping (non-page items)
    if (
        module_number is not None or title is not None
        or source_canvas_id is not None or provisional_id is not None
        or destructive_reconcile_ok is not None
    ):
        meta = {}
        if module_number is not None:
            meta["module_number"] = int(module_number)
        if title is not None:
            meta["title"] = title
        if source_canvas_id is not None:
            meta["source_canvas_id"] = source_canvas_id
        if provisional_id is not None:
            meta["provisional_id"] = provisional_id
        if destructive_reconcile_ok is not None:
            meta["destructive_reconcile_ok"] = bool(destructive_reconcile_ok)
        meta_path = path + ".meta.json"
        with open(meta_path, "w", encoding="utf-8") as f:
            json.dump(meta, f, indent=2, ensure_ascii=False)

    schema_result = staging_schema.validate_file(
        path,
        course_id=course_id,
        object_type=object_type,
        object_id=object_id,
    )
    if not schema_result.get("ok"):
        return {
            "ok": False,
            "path": path,
            "course_id": str(course_id),
            "object_type": object_type,
            "object_id": str(object_id),
            "schema": schema_result,
            "error": "Staged content failed schema validation.",
        }

    result = {
        "ok": True,
        "path": path,
        "course_id": str(course_id),
        "object_type": object_type,
        "object_id": str(object_id),
    }
    if schema_result.get("warnings"):
        result["schema_warnings"] = schema_result["warnings"]

    # Run Tier 1 preflight checks (payload-only, no Canvas/config context needed)
    if validate:
        preflight = _run_tier1_preflight(course_id, object_type, object_id, content)
        if preflight:
            result["preflight"] = preflight

    return result


def _run_tier1_preflight(course_id, object_type, object_id, content):
    """Run Tier 1 (payload-only) preflight checks and write sidecar."""
    try:
        from preflight_checks import (
            check_page_tier1, check_quiz_tier1, summarize_issues
        )
    except ImportError:
        return None  # Graceful degradation if preflight_checks.py not available

    issues = []

    if object_type in ("pages", "assignments", "discussions"):
        slug = str(object_id) if object_type == "pages" else ""
        issues = check_page_tier1(content, slug=slug)
    elif object_type == "quizzes" and isinstance(content, dict):
        issues = check_quiz_tier1(content)
    else:
        return None

    summary = summarize_issues(issues)

    # Write sidecar .issues.json
    main_path = _staged_path(course_id, object_type, object_id)
    sidecar_path = main_path + ".issues.json"
    with open(sidecar_path, "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=2, ensure_ascii=False)

    return summary


def get_staged(course_id, object_type, object_id):
    """Read staged content for an object.

    Returns:
        Content string (HTML) or parsed dict (quiz JSON), or None if not staged.
    """
    path = _staged_path(course_id, object_type, object_id)
    if not os.path.exists(path):
        return None

    with open(path, "r", encoding="utf-8") as f:
        if object_type == "quizzes":
            return json.load(f)
        return f.read()


def list_staged(course_id, object_type=None):
    """List all staged objects for a course.

    Args:
        course_id: Canvas course ID
        object_type: Filter to one type, or None for all types.
                     Pass "files" to list only staged binary files.

    Returns:
        List of dicts: [{object_type, object_id, path, size_bytes}]
        For "files" type entries: object_type="files", object_id=key.
    """
    results = []

    # If filtering for files only, delegate to list_staged_files.
    if object_type == "files":
        for item in list_staged_files(course_id):
            results.append({
                "object_type": "files",
                "object_id": item["key"],
                "path": item["path"],
                "size_bytes": item["size_bytes"],
            })
        return results

    types_to_check = [object_type] if object_type else VALID_TYPES

    for otype in types_to_check:
        staging = _staging_dir(course_id, otype)
        if not os.path.isdir(staging):
            continue
        ext = EXTENSIONS[otype]
        for fname in sorted(os.listdir(staging)):
            if fname.startswith(".") or fname.startswith("_"):
                continue
            if not fname.endswith(ext):
                continue
            # Skip sidecar files (e.g., 555.json.issues.json still ends with .json)
            if any(suf in fname for suf in SIDECAR_SUFFIXES):
                continue
            obj_id = fname[: -len(ext)]
            fpath = os.path.join(staging, fname)
            results.append({
                "object_type": otype,
                "object_id": obj_id,
                "path": fpath,
                "size_bytes": os.path.getsize(fpath),
            })

    # When listing all types, also include staged files.
    if object_type is None:
        for item in list_staged_files(course_id):
            results.append({
                "object_type": "files",
                "object_id": item["key"],
                "path": item["path"],
                "size_bytes": item["size_bytes"],
            })

    return results


def clear_staged(course_id, object_type=None, object_id=None):
    """Clear staged content and associated sidecars (.issues.json, .vision.json).

    Args:
        course_id: Canvas course ID
        object_type: Clear one type, or None for all. Pass "files" to clear
                     staged binary files.
        object_id: Clear one object, or None for all in type

    Returns:
        dict with ok and cleared count
    """
    cleared = 0

    # Handle binary file type separately.
    if object_type == "files":
        if object_id:
            result = clear_staged_file(course_id, object_id)
            return result
        for item in list_staged_files(course_id):
            result = clear_staged_file(course_id, item["key"])
            cleared += result.get("cleared", 0)
        return {"ok": True, "cleared": cleared}

    if object_type and object_id:
        path = _staged_path(course_id, object_type, object_id)
        if os.path.exists(path):
            os.remove(path)
            cleared = 1
        # Clean up sidecars
        cleared += _clear_sidecars(path)
    else:
        # list_staged with object_type=None already includes files entries.
        # Dispatch by type to avoid double-processing.
        for item in list_staged(course_id, object_type):
            if item["object_type"] == "files":
                result = clear_staged_file(course_id, item["object_id"])
                cleared += result.get("cleared", 0)
            else:
                os.remove(item["path"])
                cleared += 1
                cleared += _clear_sidecars(item["path"])

    return {"ok": True, "cleared": cleared}


TIMELINE_TYPE_TO_STAGING_TYPE = {
    "page": "pages",
    "pages": "pages",
    "assignment": "assignments",
    "assignments": "assignments",
    "discussion": "discussions",
    "discussions": "discussions",
    "quiz": "quizzes",
    "quizzes": "quizzes",
    "interactive": "interactives",
    "interactives": "interactives",
}


def _timeline_path(course_id):
    return Path(get_plugin_root()) / "courses" / str(course_id) / "timeline.jsonl"


def _verified_pushes(course_id):
    """Return {(object_type, object_id)} pairs from push_verified timeline events."""
    path = _timeline_path(course_id)
    verified = set()
    if not path.exists():
        return verified
    with path.open("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("event_type") != "push_verified":
                continue
            payload = record.get("payload") or {}
            raw_type = payload.get("object_type") or payload.get("content_type")
            object_type = TIMELINE_TYPE_TO_STAGING_TYPE.get(str(raw_type or ""))
            object_id = payload.get("object_id") or payload.get("slug")
            if object_type and object_id is not None:
                verified.add((object_type, str(object_id)))
    return verified


def clear_pushed_staged(course_id, object_type=None):
    """Clear staged items whose object IDs appear in push_verified timeline events.

    This is a cleanup helper for stale local staging artifacts. It does not
    push anything to Canvas and it leaves staged items without verified push
    records untouched.
    """
    verified = _verified_pushes(course_id)
    cleared_items = []
    skipped_items = []

    for item in list_staged(course_id, object_type):
        key = (item["object_type"], str(item["object_id"]))
        if key in verified:
            result = clear_staged(course_id, item["object_type"], item["object_id"])
            cleared_items.append({
                "object_type": item["object_type"],
                "object_id": item["object_id"],
                "cleared": result["cleared"],
            })
        else:
            skipped_items.append({
                "object_type": item["object_type"],
                "object_id": item["object_id"],
            })

    return {
        "ok": True,
        "cleared": sum(item["cleared"] for item in cleared_items),
        "cleared_items": cleared_items,
        "skipped_items": skipped_items,
    }


# ============================================================
# Binary file staging ('files' type)
# ============================================================

def stage_file(course_id, key, ext, src_path, meta=None):
    """Stage a binary file for preview.

    Copies bytes from src_path to courses/{id}/staging/files/{key}.{ext}
    and writes a {key}.meta.json sidecar. No preflight checks; no HTML/JSON
    validation. Path components are validated with the same regex+containment
    guard used by stage().

    Args:
        course_id: Canvas course ID (string or int).
        key: Slug-safe identifier, must match FILE_KEY_RE (e.g. 'hero-img').
        ext: File extension without leading dot (e.g. 'png', 'jpg').
        src_path: Path-like; source file to copy bytes from.
        meta: Optional dict with any of: original_filename, mime, alt,
              attribution, source_url, module, staged_at. Unknown keys are
              preserved; none are required. No clock call is made here; the
              caller supplies staged_at if desired.

    Returns:
        {"ok": True, "path": str, "key": str, "ext": str,
         "url_token": "asucb-staged-file://{key}"}
        or {"ok": False, "error": str} when validation fails.
    """
    try:
        key = _validate_file_key(key)
        ext = _validate_file_ext(ext)
        files_dir = _files_staging_dir(course_id)
    except ValueError as e:
        return {"ok": False, "error": str(e)}

    dest = _resolve_inside(files_dir, f"{key}.{ext}")
    meta_dest = _resolve_inside(files_dir, f"{key}.meta.json")

    try:
        shutil.copy2(str(src_path), str(dest))
    except (OSError, IOError) as e:
        return {"ok": False, "error": f"File copy failed: {e}"}

    sidecar = dict(meta) if meta else {}
    sidecar["ext"] = ext
    try:
        meta_dest.write_text(
            json.dumps(sidecar, indent=2, ensure_ascii=False),
            encoding="utf-8",
        )
    except OSError as e:
        return {"ok": False, "error": f"Sidecar write failed: {e}"}

    return {
        "ok": True,
        "path": str(dest),
        "key": key,
        "ext": ext,
        "url_token": f"asucb-staged-file://{key}",
    }


def get_staged_file(course_id, key):
    """Locate a staged file by key (extension is discovered via the sidecar).

    Returns:
        dict with path (Path), ext (str), meta (dict)
        or None if not staged.
    """
    try:
        key = _validate_file_key(key)
        files_dir = _files_staging_dir(course_id)
    except ValueError:
        return None

    meta_path = _resolve_inside(files_dir, f"{key}.meta.json")
    if not meta_path.exists():
        return None
    try:
        meta = json.loads(meta_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        meta = {}

    ext = meta.get("ext", "")
    if not ext:
        return None

    file_path = _resolve_inside(files_dir, f"{key}.{ext}")
    if not file_path.exists():
        return None

    return {"path": file_path, "ext": ext, "meta": meta}


def list_staged_files(course_id):
    """List all staged binary files for a course.

    Returns:
        List of dicts: [{key, ext, path, size_bytes, meta}]
    """
    try:
        files_dir = _files_staging_dir(course_id)
    except ValueError:
        return []

    results = []
    if not files_dir.is_dir():
        return results

    for meta_file in sorted(files_dir.iterdir()):
        if not meta_file.is_file():
            continue
        if not meta_file.name.endswith(".meta.json"):
            continue
        key_with_dot = meta_file.name[: -len(".meta.json")]
        # key_with_dot is just the key (no extension in the sidecar filename)
        key = key_with_dot
        if not FILE_KEY_RE.fullmatch(key):
            continue
        try:
            meta = json.loads(meta_file.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            meta = {}
        ext = meta.get("ext", "")
        if not ext:
            continue
        file_path = files_dir / f"{key}.{ext}"
        if not file_path.exists():
            continue
        results.append({
            "key": key,
            "ext": ext,
            "path": str(file_path),
            "size_bytes": file_path.stat().st_size,
            "meta": meta,
        })
    return results


def clear_staged_file(course_id, key):
    """Clear a single staged file and its sidecar.

    Returns:
        dict with ok and cleared count.
    """
    info = get_staged_file(course_id, key)
    if info is None:
        return {"ok": True, "cleared": 0}

    cleared = 0
    try:
        info["path"].unlink()
        cleared += 1
    except OSError:
        pass

    try:
        files_dir = _files_staging_dir(course_id)
        meta_path = _resolve_inside(files_dir, f"{key}.meta.json")
        if meta_path.exists():
            meta_path.unlink()
            cleared += 1
    except (ValueError, OSError):
        pass

    return {"ok": True, "cleared": cleared}


SIDECAR_SUFFIXES = (".issues.json", ".vision.json", ".meta.json")


def _clear_sidecars(main_path):
    """Delete sidecar files associated with a staged object.

    Handles both patterns:
      - {id}.html.issues.json (appended to full filename)
      - {id}.issues.json (appended to stem, replacing extension)
    """
    removed = 0
    for suffix in SIDECAR_SUFFIXES:
        # Pattern 1: append to full path (e.g., m1-overview.html.issues.json)
        sidecar = main_path + suffix
        if os.path.exists(sidecar):
            os.remove(sidecar)
            removed += 1
            continue
        # Pattern 2: replace extension (e.g., m1-overview.issues.json)
        stem = os.path.splitext(main_path)[0]
        sidecar = stem + suffix
        if os.path.exists(sidecar):
            os.remove(sidecar)
            removed += 1
    return removed


def rename_staged(course_id, object_type, old_id, new_id):
    """Rename a staged object (page slug change, etc.).

    Moves the main file + .meta.json sidecar + any .issues.json preflight sidecar.
    Refreshes preflight under the new slug if applicable.

    Args:
        course_id: Canvas course ID
        object_type: One of 'pages', 'assignments', 'discussions', 'quizzes'
        old_id: Current object_id (slug for pages, numeric for others)
        new_id: New object_id

    Returns dict with ok, old_path, new_path.
    """
    if object_type not in VALID_TYPES:
        return {"ok": False, "error": f"Invalid object type: {object_type}"}
    old_path = _staged_path(course_id, object_type, old_id)
    new_path = _staged_path(course_id, object_type, new_id)
    if not os.path.exists(old_path):
        return {"ok": False, "error": f"Source not staged: {object_type}/{old_id}"}
    if os.path.exists(new_path) and old_path != new_path:
        return {"ok": False, "error": f"Target already staged: {object_type}/{new_id}"}
    os.rename(old_path, new_path)

    # Move meta.json sidecar
    old_meta = old_path + ".meta.json"
    new_meta = new_path + ".meta.json"
    if os.path.exists(old_meta):
        os.rename(old_meta, new_meta)

    # Move preflight sidecar(s) — handle both naming patterns
    for suffix in SIDECAR_SUFFIXES:
        # Pattern 1: full path + suffix
        s1_old = old_path + suffix
        s1_new = new_path + suffix
        if os.path.exists(s1_old):
            os.rename(s1_old, s1_new)
        # Pattern 2: stem + suffix
        s2_old = os.path.splitext(old_path)[0] + suffix
        s2_new = os.path.splitext(new_path)[0] + suffix
        if os.path.exists(s2_old):
            os.rename(s2_old, s2_new)

    return {
        "ok": True,
        "course_id": str(course_id),
        "object_type": object_type,
        "renamed_from": str(old_id),
        "renamed_to": str(new_id),
        "old_path": old_path,
        "new_path": new_path,
    }


# ============================================================
# CLI
# ============================================================
def main():
    parser = argparse.ArgumentParser(description="ASUCB Staging Manager")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--stage", action="store_true", help="Stage content")
    group.add_argument("--get", action="store_true", help="Get staged content")
    group.add_argument("--list", action="store_true", help="List staged objects")
    group.add_argument("--clear", action="store_true", help="Clear staged content")
    group.add_argument("--clear-pushed", action="store_true",
                       help="Clear staged items already recorded as push_verified in timeline.jsonl")
    group.add_argument("--rename", nargs=2, metavar=("OLD_ID", "NEW_ID"),
                       help="Rename a staged object (requires --type). Moves main file + sidecars.")

    ALL_TYPES = VALID_TYPES + ["files"]
    parser.add_argument("--type", choices=ALL_TYPES, help="Object type")
    parser.add_argument("--id", help="Object ID (slug for pages, numeric for others; key for files)")
    parser.add_argument("--html-file", help="Path to HTML/JSON file to stage (text types)")
    # Binary file staging flags
    parser.add_argument("--file", help="Path to binary file to stage (for --type files)")
    parser.add_argument("--ext", help="File extension without leading dot (e.g. png); inferred from --file if omitted")
    parser.add_argument("--alt", help="Alt text for staged image")
    parser.add_argument("--attribution", help="Attribution text for staged file")
    parser.add_argument("--source-url", help="Original source URL of the file")
    parser.add_argument("--mime", help="MIME type (e.g. image/png); inferred from ext if omitted")
    parser.add_argument("--module", help="Module number this file belongs to")
    parser.add_argument("--staged-at", help="Timestamp for staged_at field (caller-supplied)")
    parser.add_argument("--meta-json", help="JSON blob with all metadata fields (overrides individual flags)")

    args = parser.parse_args()

    if args.stage:
        if not args.type or not args.id:
            parser.error("--stage requires --type and --id")

        if args.type == "files":
            # Binary file staging path
            if not args.file:
                parser.error("--type files requires --file PATH")
            ext = args.ext
            if not ext:
                # Infer from filename
                suffix = Path(args.file).suffix
                ext = suffix.lstrip(".") if suffix else ""
            if not ext:
                parser.error("Cannot determine extension; supply --ext")
            if args.meta_json:
                meta = json.loads(args.meta_json)
            else:
                meta = {}
                if args.alt:
                    meta["alt"] = args.alt
                if args.attribution:
                    meta["attribution"] = args.attribution
                if args.source_url:
                    meta["source_url"] = args.source_url
                if args.mime:
                    meta["mime"] = args.mime
                if args.module:
                    meta["module"] = args.module
                if args.staged_at:
                    meta["staged_at"] = args.staged_at
                meta["original_filename"] = Path(args.file).name
            result = stage_file(args.course_id, args.id, ext, args.file, meta=meta)
            print(json.dumps(result, indent=2))
        elif args.html_file:
            with open(args.html_file, "r", encoding="utf-8") as f:
                content = f.read()
            if args.type == "quizzes":
                content = json.loads(content)
            result = stage(args.course_id, args.type, args.id, content)
            print(json.dumps(result, indent=2))
        else:
            content = sys.stdin.read()
            if args.type == "quizzes":
                content = json.loads(content)
            result = stage(args.course_id, args.type, args.id, content)
            print(json.dumps(result, indent=2))

    elif args.get:
        if not args.type or not args.id:
            parser.error("--get requires --type and --id")
        content = get_staged(args.course_id, args.type, args.id)
        if content is None:
            print(json.dumps({"ok": False, "error": "Not staged"}))
            sys.exit(1)
        if isinstance(content, dict):
            print(json.dumps(content, indent=2))
        else:
            print(content)

    elif args.list:
        items = list_staged(args.course_id, args.type)
        print(json.dumps({"ok": True, "staged": items}, indent=2))

    elif args.clear:
        result = clear_staged(args.course_id, args.type, args.id)
        print(json.dumps(result, indent=2))

    elif args.clear_pushed:
        if args.id:
            parser.error("--clear-pushed does not accept --id")
        result = clear_pushed_staged(args.course_id, args.type)
        print(json.dumps(result, indent=2))

    elif args.rename:
        if not args.type:
            parser.error("--rename requires --type")
        old_id, new_id = args.rename
        result = rename_staged(args.course_id, args.type, old_id, new_id)
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)


if __name__ == "__main__":
    main()
