#!/usr/bin/env python3
"""Deploy interactive HTML files to Canvas: upload to Files, embed iframes in pages.

Usage:
    python deploy_interactives.py <content_data_path> [--from-staging] [--source-dir <dir>] [--folder-name <name>]
    python deploy_interactives.py --html-file PATH --title TITLE [--course-id ID] [--module-id ID] [--folder-name NAME]

Requires environment variables:
    CANVAS_TOKEN, CANVAS_DOMAIN, CANVAS_COURSE_ID

The content data file must define ALL_ACTIVITIES with page_slug and page_type fields.

Single-file mode (--html-file): uploads one standalone HTML interactive to Canvas
Files and creates a host Page that embeds it via <iframe src=.../files/{id}/preview>.
Canvas strips <script> from page bodies, so JS interactives MUST ship as an
uploaded HTML file behind an iframe — this applies to hand-built interactives too,
not only generator output.
"""

import os
import sys
import json
import time
import argparse
import html
import subprocess
import tempfile
from collections import defaultdict
from pathlib import Path

# Add parent dir to path for canvas_api import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from canvas_api import (
    get_config, require_course_id, upload_file, get_or_create_folder,
    get_page, update_page, create_page, add_module_item,
)
import backup_manager


PLUGIN_ROOT = Path(__file__).resolve().parents[1]

# Iframe height per activity type
IFRAME_HEIGHTS = {
    "dialog_cards": 550,
    "sequencing": 650,
    "fill_blanks": 600,
    "branching": 700,
    "quiz": 650,
}

# Type labels for the subtitle line
TYPE_LABELS = {
    "dialog_cards": "Flashcards",
    "sequencing": "Sequencing Activity",
    "fill_blanks": "Fill-in-the-Blanks",
    "branching": "Branching Scenario",
    "quiz": "Review Quiz",
}

# Estimated time per type
TIME_EST = {
    "dialog_cards": 3,
    "sequencing": 5,
    "fill_blanks": 5,
    "branching": 5,
    "quiz": 5,
}

# Default instruction text per activity type (used when activity has no custom instruction_text)
DEFAULT_INSTRUCTIONS = {
    "dialog_cards": "Review these key terms by clicking each card to reveal its definition. Use Shuffle to test yourself in a different order.",
    "sequencing": "Arrange the items in the correct order by clicking an item, then clicking the numbered slot where it belongs.",
    "fill_blanks": "Read each passage and fill in the missing terms. Check your answers when you\u2019re ready.",
    "branching": "Read each scenario carefully and choose the best course of action. Your choices affect the outcome.",
    "quiz": "Answer each question to test your understanding. You\u2019ll receive feedback after each submission.",
}


def create_all_folders(config, folder_name, module_keys):
    """Create top-level folder and per-module subfolders in course files."""
    print("\n=== CREATING FOLDERS ===")
    top_id = get_or_create_folder(config, "", folder_name)
    if not top_id:
        print(f"FATAL: Could not create {folder_name} folder")
        sys.exit(1)

    folder_ids = {}
    for key in sorted(module_keys):
        fid = get_or_create_folder(config, folder_name, key.upper())
        if fid:
            folder_ids[key] = fid
        time.sleep(0.2)

    return folder_ids


def upload_all_files(config, folder_ids, output_root):
    """Upload all HTML files and return {module_key: {filename: file_id}}."""
    print("\n=== UPLOADING FILES ===")
    file_ids = defaultdict(dict)

    for module_key in sorted(folder_ids.keys()):
        folder_id = folder_ids[module_key]
        module_dir = os.path.join(output_root, module_key)

        if not os.path.isdir(module_dir):
            print(f"  SKIP: {module_key} — no output directory")
            continue

        html_files = sorted(f for f in os.listdir(module_dir) if f.endswith(".html"))
        for filename in html_files:
            filepath = os.path.join(module_dir, filename)
            src_text = Path(filepath).read_text(encoding="utf-8")
            injected = inject_self_resize(src_text)
            if injected != src_text:
                tmp = tempfile.NamedTemporaryFile(
                    suffix=".html", delete=False, mode="w", encoding="utf-8"
                )
                tmp.write(injected)
                tmp.close()
                upload_path = tmp.name
            else:
                upload_path = filepath
            try:
                fid = upload_file(config, upload_path, folder_id)
            finally:
                if upload_path != filepath:
                    os.unlink(upload_path)
            if fid:
                file_ids[module_key][filename] = fid
                print(f"  OK: {module_key}/{filename} → file_id={fid}")
            else:
                print(f"  FAILED: {module_key}/{filename}")
            time.sleep(0.3)

    return dict(file_ids)


_AUTORESIZE_SENTINEL = "asucb-iframe-autoresize"
_AUTORESIZE_BLOCK = """\
<!-- asucb-iframe-autoresize -->
<script>
(function(){
  function fit(){
    try{
      var h = Math.ceil(document.documentElement.getBoundingClientRect().height) + 4;
      if(window.frameElement){ window.frameElement.style.height = h + 'px'; }
    }catch(e){}
  }
  window.addEventListener('load', fit);
  window.addEventListener('resize', fit);
  if(window.ResizeObserver){ try{ new ResizeObserver(fit).observe(document.body); }catch(e){} }
  setTimeout(fit, 150); setTimeout(fit, 600);
})();
</script>"""


def inject_self_resize(html_text: str) -> str:
    """Inject an iframe auto-resize snippet into *html_text* before </body>.

    Idempotent: if the sentinel or *window.frameElement.style.height* already
    appears in the source the text is returned unchanged.
    """
    if _AUTORESIZE_SENTINEL in html_text or "window.frameElement.style.height" in html_text:
        return html_text

    import re
    # Find the last </body> tag (case-insensitive)
    match = None
    for m in re.finditer(r"</body>", html_text, re.IGNORECASE):
        match = m
    if match:
        return html_text[: match.start()] + _AUTORESIZE_BLOCK + "\n" + html_text[match.start() :]
    # No </body> — append to end
    return html_text + "\n" + _AUTORESIZE_BLOCK


def staging_source_dir(course_id):
    """Return the default staged interactives directory for a course."""
    return PLUGIN_ROOT / "courses" / str(course_id) / "staging" / "interactives"


def resolve_source_dir(source_dir, from_staging, course_id):
    """Resolve the HTML source directory, with --source-dir as override."""
    if source_dir:
        return Path(source_dir).expanduser().resolve()
    if from_staging:
        if not course_id:
            raise ValueError("CANVAS_COURSE_ID is required when deploying from staging")
        return staging_source_dir(course_id).resolve()
    raise ValueError("--source-dir is required when --no-from-staging is used")


def run_a11y_check(source_dir):
    """Run the deterministic interactive a11y gate."""
    checker = PLUGIN_ROOT / "scripts" / "check_interactive_a11y.py"
    result = subprocess.run(
        [sys.executable, str(checker), str(source_dir)],
        text=True,
        check=False,
    )
    return result.returncode == 0


def run_brand_check(source_dir):
    """Run the deterministic ASU brand gate.

    Font/italic/banned-contrast-pair failures (FAILURE items) block deploy.
    Off-palette warnings are printed but do not block (D1 decision).
    Returns True if no blocking failures.
    """
    checker = PLUGIN_ROOT / "scripts" / "check_asu_brand.py"
    result = subprocess.run(
        [sys.executable, str(checker), str(source_dir)],
        text=True,
        capture_output=True,
        check=False,
    )
    try:
        report = json.loads(result.stdout)
    except (json.JSONDecodeError, ValueError):
        # Could not parse output — treat as gate failure
        print(result.stdout, file=sys.stderr)
        return False

    if report.get("warnings"):
        print(f"  Brand warnings (advisory, off-palette): {len(report['warnings'])} item(s)")
        for w in report["warnings"][:5]:
            print(f"    [{w.get('code')}] {w.get('message')}")
        if len(report["warnings"]) > 5:
            print(f"    ... and {len(report['warnings']) - 5} more")

    return report.get("pass", False)


def iframe_html(course_id, file_id, activity):
    """Generate the iframe embed block for a Canvas page."""
    act_type = activity["type"]
    title = html.escape(str(activity["title"]), quote=True)
    height = IFRAME_HEIGHTS.get(act_type, 600)
    label = TYPE_LABELS.get(act_type, "Interactive Activity")
    minutes = TIME_EST.get(act_type, 5)

    # Instruction text: use custom if provided, otherwise fall back to default
    instruction = activity.get("instruction_text", "")
    if not instruction:
        instruction = DEFAULT_INSTRUCTIONS.get(act_type, "Complete the activity below to check your understanding.")
    instruction = html.escape(str(instruction), quote=True)

    return f'''<div style="margin: 25px 0; padding: 20px; background-color: #f8f4f0; border: 1px solid #ddd; border-radius: 8px;">
  <h3 style="color: #8C1D40; margin: 0 0 5px 0;">{title}</h3>
  <p style="color: #595959; font-size: 14px; margin: 0 0 8px 0;">{label} | ~{minutes} min | Formative (not graded)</p>
  <p style="color: #333; font-size: 15px; margin: 0 0 12px 0;">{instruction}</p>
  <iframe src="/courses/{course_id}/files/{file_id}/preview"
          width="100%" height="{height}"
          style="border: 1px solid #ccc; border-radius: 6px;"
          sandbox="allow-same-origin allow-scripts allow-forms"
          scrolling="no"
          title="{title}"
          loading="lazy"></iframe>
</div>'''


def find_injection_point(body, page_type):
    """Find the insertion index in page HTML based on page type."""
    markers_by_type = {
        "prepare": [
            "<h2>What to Think About</h2>",
            "<h2>Think About This Before the Lecture</h2>",
            "<h2>Reflection Questions</h2>",
        ],
        "guided": [
            "<h2>Scenario 1",
            "<h2>Instructions</h2>",
        ],
        "conclusion": [
            '<h2 style="color: #8C1D40; margin-top: 0;">Discussion',
            "<h2>Connections Forward</h2>",
            '<h2 style="color: #8C1D40;">Self-Assessment</h2>',
        ],
    }

    markers = markers_by_type.get(page_type, [])
    for m in markers:
        idx = body.find(m)
        if idx >= 0:
            return idx, m
    return None, None


def patch_pages(config, file_ids, activities_by_page):
    """Update Canvas pages with iframe embeds."""
    print("\n=== PATCHING PAGES ===")
    results = {}

    for page_slug, activities in activities_by_page.items():
        page = get_page(config, page_slug)
        if not page:
            results[page_slug] = False
            continue

        body = page.get("body", "")

        # Idempotent check
        existing = sum(1 for a in activities if a["filename"] in body or a["title"] in body)
        if existing == len(activities):
            print(f"  SKIP: {page_slug} — already has all {len(activities)} iframe(s)")
            results[page_slug] = True
            continue

        page_type = activities[0]["page_type"]
        inject_idx, marker = find_injection_point(body, page_type)

        if inject_idx is None:
            print(f"  ERROR: {page_slug} — no injection marker found for page_type={page_type}")
            results[page_slug] = False
            continue

        iframe_blocks = []
        for activity in activities:
            module_key = activity["filename"].split("-")[0]
            fid = file_ids.get(module_key, {}).get(activity["filename"])
            if not fid:
                print(f"  ERROR: No file_id for {activity['filename']}")
                continue
            iframe_blocks.append(iframe_html(config["course_id"], fid, activity))

        if not iframe_blocks:
            results[page_slug] = False
            continue

        combined_html = "\n".join(iframe_blocks) + "\n"
        new_body = body[:inject_idx] + combined_html + body[inject_idx:]

        # Back up the current page body before overwriting it. The interactive
        # HTML is already staged and preview-approved, but this host-page edit
        # mutates an existing Canvas page, so it needs a rollback point like any
        # other body write (Write Safety Guard). A failed backup aborts this
        # page's deploy entirely (#208) — never overwrite a live page without
        # a rollback point.
        if body.strip():
            backup_result = backup_manager.save_backup(
                config["course_id"], "pages", page_slug, body,
                diff_summary=f"Pre-deploy backup before injecting {len(iframe_blocks)} interactive iframe(s)",
            )
            if not backup_result.get("ok"):
                print(f"  ERROR: {page_slug} — backup failed ({backup_result.get('error')}); aborting deploy for this page")
                results[page_slug] = False
                continue

        if update_page(config, page_slug, new_body):
            print(f"  OK: {page_slug} — {len(iframe_blocks)} iframe(s) inserted")
            results[page_slug] = True
        else:
            results[page_slug] = False

        time.sleep(0.3)

    return results


def _slug_from_title(title):
    """Best-effort slug Canvas will assign; only used for module wiring + logging."""
    import re
    slug = re.sub(r"[^a-z0-9]+", "-", str(title).lower()).strip("-")
    return slug or "interactive"


def single_html_iframe_body(course_id, file_id, title, height=650):
    """Iframe-embed page body for a single standalone interactive HTML file."""
    safe_title = html.escape(str(title), quote=True)
    return f'''<div style="margin: 25px 0;">
  <iframe src="/courses/{course_id}/files/{file_id}/preview"
          width="100%" height="{height}"
          style="border: 1px solid #ccc; border-radius: 6px;"
          sandbox="allow-same-origin allow-scripts allow-forms"
          scrolling="no"
          title="{safe_title}"
          loading="lazy"></iframe>
</div>'''


def deploy_single_html(html_file, title, course_id, module_id, folder_name):
    """Upload one standalone HTML interactive and create an iframe host page.

    Steps: get_or_create_folder -> upload_file(text/html) -> create_page
    (iframe body, unpublished, from_staging=True) -> optional add_module_item.
    Returns (page_slug, file_id) on success.
    """
    config = get_config(course_id=course_id)
    require_course_id(config)

    src = Path(html_file).expanduser().resolve()
    if not src.is_file():
        print(f"FATAL: HTML file not found: {src}", file=sys.stderr)
        sys.exit(1)

    folder_id = get_or_create_folder(config, "", folder_name)
    if not folder_id:
        print(f"FATAL: Could not create/find folder '{folder_name}'", file=sys.stderr)
        sys.exit(1)

    src_text = src.read_text(encoding="utf-8")
    injected = inject_self_resize(src_text)
    if injected != src_text:
        tmp = tempfile.NamedTemporaryFile(
            suffix=".html", delete=False, mode="w", encoding="utf-8"
        )
        tmp.write(injected)
        tmp.close()
        upload_path = tmp.name
    else:
        upload_path = str(src)

    try:
        file_id = upload_file(config, upload_path, folder_id, content_type="text/html")
    finally:
        if upload_path != str(src):
            os.unlink(upload_path)

    if not file_id:
        print(f"FATAL: Upload failed for {src}", file=sys.stderr)
        sys.exit(1)

    body = single_html_iframe_body(config["course_id"], file_id, title)
    page = create_page(config, title, body, published=False, from_staging=True)
    if not page:
        print("FATAL: Page creation failed", file=sys.stderr)
        sys.exit(1)

    page_slug = page.get("url") or _slug_from_title(title)

    if module_id:
        add_module_item(config, module_id, title, "Page", content_id=page_slug)

    print(f"Created page slug: {page_slug}")
    print(f"Uploaded file id: {file_id}")
    return page_slug, file_id


def main():
    parser = argparse.ArgumentParser(
        description="Deploy interactive HTML files to Canvas."
    )
    parser.add_argument(
        "content_data",
        nargs="?",
        help="Path to Python file containing ALL_ACTIVITIES dict (batch mode)",
    )
    parser.add_argument(
        "--html-file",
        help="Single standalone HTML interactive to upload + embed via iframe",
    )
    parser.add_argument(
        "--title",
        help="Page title for --html-file mode",
    )
    parser.add_argument(
        "--course-id",
        help="Course ID for --html-file mode (else CANVAS_COURSE_ID env)",
    )
    parser.add_argument(
        "--module-id",
        help="If given (--html-file mode), wire the created page into this module",
    )
    parser.add_argument(
        "--scorm",
        action="store_true",
        help="Opt-in: package --html-file as a SCORM 1.2 .zip instead of the default "
             "iframe deploy. Produces a zip + Canvas SCORM-tool upload instructions "
             "(ASU has no SCORM import API; the import is a manual UI step). The "
             "default (no --scorm) iframe path is unchanged.",
    )
    parser.add_argument(
        "--scorm-mode",
        choices=["completion", "scored"],
        default="completion",
        help="SCORM grade model with --scorm: completion (default) or scored (0-100).",
    )
    parser.add_argument(
        "--scorm-mastery",
        type=int,
        default=None,
        help="Passing threshold 0-100 for --scorm --scorm-mode scored.",
    )
    parser.add_argument(
        "--scorm-output",
        default=None,
        help="Output .zip path for --scorm (default: <html-basename>-SCORM.zip "
             "beside the source HTML).",
    )
    parser.add_argument(
        "--source-dir",
        help="Override directory containing reviewed generated HTML files",
    )
    parser.add_argument(
        "--from-staging",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Read from courses/{course-id}/staging/interactives/ (default: true)",
    )
    parser.add_argument(
        "--folder-name",
        default="H5P",
        help="Canvas folder name for uploads (default: H5P)",
    )
    parser.add_argument(
        "--confirm-verb",
        choices=["push", "approved", "deploy"],
        help="Explicit push verb required for non-dry-run --html-file deploy. "
             "Accepts only 'push', 'approved', or 'deploy'. "
             "Ambiguous affirmatives like 'ok'/'yes'/'confirm' are intentionally "
             "rejected per CLAUDE.md Write Safety Guard.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Validate inputs without uploading to Canvas (--html-file mode only).",
    )
    parser.add_argument(
        "--staged-slug",
        help="If provided, clear this staged page slug from staging after a "
             "successful --html-file deploy (matches staging_manager.clear_staged).",
    )
    args = parser.parse_args()

    # Single standalone HTML interactive mode.
    if args.html_file:
        if not args.title:
            parser.error("--title is required with --html-file")
        # SCORM packaging mode (opt-in). Produces a local SCORM 1.2 .zip + manual
        # upload instructions. Not a Canvas write (the SCORM-tool import is the ID's
        # deliberate UI step), so the confirm-verb push gate does not apply here.
        if args.scorm:
            import package_scorm
            result = package_scorm.package(
                html_file=args.html_file, title=args.title,
                mode=args.scorm_mode, mastery=args.scorm_mastery,
                output=args.scorm_output, inject=True,
            )
            if not result.get("ok"):
                print(f"FATAL: SCORM packaging failed: {result.get('error')}",
                      file=sys.stderr)
                sys.exit(1)
            print(result["instructions"])
            return
        # Gate: require explicit push verb for non-dry-run writes.
        # Mirrors push_to_canvas.py confirm-verb gate (CLAUDE.md Write Safety Guard).
        if not args.dry_run and not args.confirm_verb:
            parser.error(
                "non-dry-run --html-file deploy requires --confirm-verb "
                "{push,approved,deploy}. "
                "Ambiguous affirmatives ('ok','yes','confirm') are rejected."
            )
        if args.dry_run:
            print(f"[dry-run] Would upload {args.html_file!r} and create page {args.title!r}.")
            return
        page_slug, _file_id = deploy_single_html(
            args.html_file, args.title, args.course_id,
            args.module_id, args.folder_name,
        )
        # Clear staged page on success if a slug was provided.
        slug_to_clear = args.staged_slug or page_slug
        if slug_to_clear:
            try:
                import staging_manager as _sm
                course_id_for_clear = args.course_id or os.environ.get("CANVAS_COURSE_ID", "")
                if course_id_for_clear:
                    result = _sm.clear_staged(course_id_for_clear, "pages", slug_to_clear)
                    if result and result.get("ok"):
                        print(f"Cleared staged page: {slug_to_clear}")
                    else:
                        print(f"[warn] clear_staged returned: {result}", file=sys.stderr)
            except Exception as exc:
                print(f"[warn] could not clear staged page {slug_to_clear!r}: {exc}", file=sys.stderr)
        return

    if not args.content_data:
        parser.error("content_data is required unless --html-file is used")

    env_course_id = os.environ.get("CANVAS_COURSE_ID")
    try:
        source_dir = resolve_source_dir(args.source_dir, args.from_staging, env_course_id)
    except ValueError as e:
        parser.error(str(e))

    if not source_dir.is_dir():
        print(
            "Source dir not found. Generate first with generator.py, review the staging preview, then deploy from staging.",
            file=sys.stderr,
        )
        sys.exit(1)

    print(f"Staging path: {source_dir}")
    print("Approval prompt: deploy only after explicit user approval of the staging-server preview.")
    print("Running WCAG gate before Canvas API calls...")
    if not run_a11y_check(source_dir):
        print("FATAL: WCAG gate failed; refusing deploy.", file=sys.stderr)
        sys.exit(1)

    print("Running ASU brand gate before Canvas API calls...")
    if not run_brand_check(source_dir):
        print("FATAL: ASU brand gate failed (font/italic/contrast-pair violations); refusing deploy.", file=sys.stderr)
        sys.exit(1)

    config = get_config()
    require_course_id(config)

    # Import content data
    import importlib.util
    spec = importlib.util.spec_from_file_location("content_data", args.content_data)
    content_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(content_module)
    ALL_ACTIVITIES = content_module.ALL_ACTIVITIES

    # Collect activities grouped by page_slug
    activities_by_page = defaultdict(list)
    module_keys = set()
    for module_key, activities in sorted(ALL_ACTIVITIES.items()):
        module_keys.add(module_key)
        for activity in activities:
            activities_by_page[activity["page_slug"]].append(activity)

    print(f"Total activities: {sum(len(v) for v in ALL_ACTIVITIES.values())}")
    print(f"Pages to patch: {len(activities_by_page)}")

    folder_ids = create_all_folders(config, args.folder_name, module_keys)
    file_ids = upload_all_files(config, folder_ids, source_dir)
    total_uploaded = sum(len(v) for v in file_ids.values())
    print(f"\nFiles uploaded: {total_uploaded}")

    results = patch_pages(config, file_ids, activities_by_page)

    # Summary
    print("\n\n=== DEPLOYMENT SUMMARY ===")
    print(f"Folders: {len(folder_ids)}")
    print(f"Files uploaded: {total_uploaded}")
    ok_pages = sum(1 for v in results.values() if v)
    print(f"Pages patched: {ok_pages}/{len(activities_by_page)}")

    # Save file IDs
    id_file = os.path.join(source_dir, "file_ids.json")
    with open(id_file, "w") as f:
        json.dump(file_ids, f, indent=2)
    print(f"\nFile IDs saved to: {id_file}")


if __name__ == "__main__":
    main()
