#!/usr/bin/env python3
"""Package a self-contained HTML interactive as a SCORM 1.2 .zip.

Wraps an ASUCB interactive (or any single self-contained HTML file) in a SCORM 1.2
envelope that reports completion + optional score to the Canvas gradebook. The packager
injects a small ``window.ASUCB`` JavaScript bridge that locates the LMS SCORM API and
reports ``cmi.core.lesson_status`` (and, in scored mode, ``cmi.core.score.raw``).

Opt-in only. The default ASUCB interactive deploy path stays iframe HTML; SCORM is chosen
explicitly. ASU's Canvas does NOT expose a SCORM content-migration API, so this script
produces a zip + manual-upload instructions — it does not auto-import to Canvas.

See docs/plan-scorm-packager.md for the full spec and the locked design decisions.
"""

import argparse
import html as html_lib
import os
import re
import sys
import zipfile

# ── constants ────────────────────────────────────────────────────────────────

BRIDGE_SENTINEL = "<!-- ASUCB-SCORM-BRIDGE v1 -->"
SUSPEND_DATA_SPM = 4096  # SCORM 1.2 cmi.core.suspend_data smallest-permitted-maximum

# identifier: manifest/org/resource id — must be an XML NMTOKEN-ish slug, no traversal
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9._-]+$")


class PackageError(Exception):
    """Raised for unrecoverable packaging input errors (surfaced as {"ok": False})."""


# ── helpers ──────────────────────────────────────────────────────────────────

def _slug(text):
    """Derive a safe identifier slug from arbitrary title text."""
    slug = re.sub(r"[^A-Za-z0-9._-]+", "_", str(text or "").strip())
    slug = slug.strip("_.-") or "scorm_package"
    return slug[:120]


def _validate_identifier(identifier):
    """Validate a manifest identifier. Reject traversal / unsafe chars."""
    identifier = str(identifier or "")
    if not IDENTIFIER_RE.fullmatch(identifier) or identifier in {".", ".."}:
        raise PackageError(f"unsafe identifier: {identifier!r}")
    return identifier


def _validate_output_path(output):
    """Reject path-traversal components in the output zip path."""
    output = str(output or "")
    if not output:
        raise PackageError("empty output path")
    parts = re.split(r"[\\/]+", output)
    if any(part == ".." for part in parts):
        raise PackageError(f"unsafe output path (traversal): {output!r}")
    return output


def _validate_mastery(mastery):
    """Validate a mastery threshold: None, or an int in 0-100.

    Out-of-range thresholds are meaningless (score.raw clamps at 100, so a
    mastery > 100 can never be reached) — reject rather than emit a package
    nobody can pass.
    """
    if mastery is None:
        return None
    try:
        m = int(mastery)
    except (TypeError, ValueError):
        raise PackageError(f"mastery must be an integer 0-100: {mastery!r}")
    if not 0 <= m <= 100:
        raise PackageError(f"mastery out of range 0-100: {m}")
    return m


# ── bridge injection ─────────────────────────────────────────────────────────

def _bridge_script(mode, mastery):
    """Return the <script> bridge block for the given mode/mastery.

    mode: "completion" | "scored". mastery: int 0-100 or None (scored only).
    """
    mode_js = "scored" if mode == "scored" else "completion"
    mastery_js = str(int(mastery)) if (mode == "scored" and mastery is not None) else "null"
    # SCORM 1.2 runtime + window.ASUCB bridge. No-ops cleanly when no API is present
    # (standalone preview / iframe) so the same HTML works tracked AND untracked.
    return f"""{BRIDGE_SENTINEL}
<script>
(function () {{
  var MODE = "{mode_js}";
  var MASTERY = {mastery_js};
  var API = null, initialized = false, finished = false;

  function findAPI(win) {{
    var hops = 0;
    while (win && win.API == null && win.parent != null && win.parent !== win) {{
      hops++;
      if (hops > 7) return null;
      win = win.parent;
    }}
    return (win && win.API) || null;
  }}
  function getAPI() {{
    var api = findAPI(window);
    if (!api && window.opener) api = findAPI(window.opener);
    return api || null;
  }}
  function set(k, v) {{ if (API) {{ try {{ API.LMSSetValue(k, String(v)); }} catch (e) {{}} }} }}
  function commit() {{ if (API) {{ try {{ API.LMSCommit(""); }} catch (e) {{}} }} }}
  function clamp(n) {{ n = Number(n); if (isNaN(n)) n = 0; return Math.max(0, Math.min(100, n)); }}

  var bridge = {{
    init: function () {{
      if (initialized) return;
      API = getAPI();
      if (!API) return;  // no LMS — dormant
      try {{ if (API.LMSInitialize("") === "false") return; }} catch (e) {{ return; }}
      initialized = true;
      var status = "";
      try {{ status = API.LMSGetValue("cmi.core.lesson_status"); }} catch (e) {{}}
      if (!status || status === "not attempted" || status === "unknown") {{
        set("cmi.core.lesson_status", "incomplete");
        if (MODE === "scored") {{ set("cmi.core.score.min", "0"); set("cmi.core.score.max", "100"); }}
        commit();
      }}
    }},
    setScore: function (n) {{
      if (!initialized) this.init();
      if (!initialized) return;
      set("cmi.core.score.raw", clamp(n));
      commit();
    }},
    setSuspendData: function (s) {{
      if (!initialized) this.init();
      if (!initialized) return;
      set("cmi.core.suspend_data", String(s).slice(0, {SUSPEND_DATA_SPM}));
      commit();
    }},
    complete: function (score, data) {{
      if (!initialized) this.init();
      if (!initialized) return;
      if (data != null) set("cmi.core.suspend_data", String(data).slice(0, {SUSPEND_DATA_SPM}));
      if (MODE === "scored" && score != null) {{
        var n = clamp(score);
        set("cmi.core.score.raw", n);
        if (MASTERY != null) set("cmi.core.lesson_status", n >= MASTERY ? "passed" : "failed");
        else set("cmi.core.lesson_status", "completed");
      }} else {{
        set("cmi.core.lesson_status", "completed");
      }}
      commit();
    }},
    finish: function () {{
      if (!initialized || finished) return;
      finished = true;
      try {{ API.LMSFinish(""); }} catch (e) {{}}
    }}
  }};

  window.ASUCB = window.ASUCB || bridge;
  if (document.readyState === "loading")
    document.addEventListener("DOMContentLoaded", function () {{ bridge.init(); }});
  else bridge.init();
  window.addEventListener("pagehide", function () {{ bridge.finish(); }});
  window.addEventListener("beforeunload", function () {{ bridge.finish(); }});
}})();
</script>"""


def inject_bridge(html, mode="completion", mastery=None):
    """Inject the SCORM 1.2 ASUCB bridge into ``html``.

    Idempotent: if the sentinel is already present, returns ``html`` unchanged.
    Inserts before </body> (fallback </html>, fallback append). Preserves all
    original markup verbatim — only the bridge block is added.
    """
    if mode not in ("completion", "scored"):
        raise PackageError(f"invalid mode: {mode!r}")
    mastery = _validate_mastery(mastery)
    if BRIDGE_SENTINEL in html:
        return html
    block = _bridge_script(mode, mastery)
    lower = html.lower()
    idx = lower.rfind("</body>")
    if idx == -1:
        idx = lower.rfind("</html>")
    if idx == -1:
        return html + "\n" + block + "\n"
    return html[:idx] + block + "\n" + html[idx:]


# ── manifest ─────────────────────────────────────────────────────────────────

def build_manifest(title, identifier=None, mode="completion", mastery=None):
    """Build a SCORM 1.2 imsmanifest.xml string for a single-SCO package."""
    if mode not in ("completion", "scored"):
        raise PackageError(f"invalid mode: {mode!r}")
    mastery = _validate_mastery(mastery)
    ident = _validate_identifier(identifier or _slug(title))
    org_id = f"{ident}_org"
    title_xml = html_lib.escape(str(title), quote=True)
    mastery_elem = ""
    if mode == "scored" and mastery is not None:
        mastery_elem = f"\n        <adlcp:masteryscore>{int(mastery)}</adlcp:masteryscore>"
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="{ident}" version="1.0"
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
    http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
  <metadata>
    <schema>ADL SCORM</schema>
    <schemaversion>1.2</schemaversion>
  </metadata>
  <organizations default="{org_id}">
    <organization identifier="{org_id}">
      <title>{title_xml}</title>
      <item identifier="item_1" identifierref="resource_1">
        <title>{title_xml}</title>{mastery_elem}
      </item>
    </organization>
  </organizations>
  <resources>
    <resource identifier="resource_1" type="webcontent" adlcp:scormtype="sco" href="index.html">
      <file href="index.html"/>
    </resource>
  </resources>
</manifest>
"""


# ── upload instructions ──────────────────────────────────────────────────────

def upload_instructions(zip_path, mode, mastery):
    # ASU has NO SCORM import API and does NOT import via the Files UI. SCORM runs
    # through the SCORM LTI tool (Course nav -> "SCORM"). Verified end-to-end on
    # canvas.asu.edu course 260866 (2026-06-12): score posts to the gradebook.
    if mode == "scored":
        import_type = '"graded assignment"'
        posts = "the score (cmi.core.score.raw, 0-100)"
    else:
        import_type = ('"graded assignment" (full credit on completion) or '
                       '"ungraded assignment" (tracking only)')
        posts = "completion (cmi.core.lesson_status)"
    mastery_line = (
        f"\nPassing threshold baked into the package: {int(mastery)}/100 "
        f"(score >= threshold -> passed)."
        if (mode == "scored" and mastery is not None) else ""
    )
    return (
        f"SCORM package ready: {zip_path}\n"
        f"Upload via the Canvas SCORM tool (verified ASU flow; there is no SCORM import API):\n"
        f"  1. Course navigation -> \"SCORM\" -> Upload -> select this .zip.\n"
        f"  2. Reload the SCORM page; the package appears by its title at the top.\n"
        f"  3. \"Choose import type\" -> {import_type} -> Go.  (import type is PERMANENT)\n"
        f"  4. Canvas creates an External-Tool assignment (defaults to 100 points) + a\n"
        f"     gradebook column; the injected bridge posts {posts} on completion."
        f"{mastery_line}"
    )


# ── package ──────────────────────────────────────────────────────────────────

def package(html_file, title, identifier=None, mode="completion",
            mastery=None, output=None, inject=True):
    """Package ``html_file`` as a SCORM 1.2 .zip.

    Returns {"ok": True, "zip": path, "mode": mode, "instructions": str} on success,
    or {"ok": False, "error": str} on any input error (path traversal, missing file,
    invalid mode/identifier) — graceful-failure contract, does not raise.
    """
    try:
        if mode not in ("completion", "scored"):
            raise PackageError(f"invalid mode: {mode!r}")
        if not os.path.isfile(html_file):
            raise PackageError(f"HTML file not found: {html_file}")
        ident = _validate_identifier(identifier or _slug(title))
        if output is None:
            base = os.path.splitext(os.path.basename(html_file))[0]
            output = os.path.join(os.path.dirname(os.path.abspath(html_file)),
                                  f"{base}-SCORM.zip")
        output = _validate_output_path(output)

        with open(html_file, "r", encoding="utf-8") as fh:
            html = fh.read()
        if inject:
            html = inject_bridge(html, mode=mode, mastery=mastery)
        manifest = build_manifest(title, identifier=ident, mode=mode, mastery=mastery)

        out_dir = os.path.dirname(os.path.abspath(output))
        if out_dir and not os.path.isdir(out_dir):
            os.makedirs(out_dir, exist_ok=True)
        with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("imsmanifest.xml", manifest)
            zf.writestr("index.html", html)
    except PackageError as exc:
        return {"ok": False, "error": str(exc)}
    except UnicodeDecodeError as exc:
        return {"ok": False, "error": f"HTML file is not valid UTF-8: {exc}"}
    except OSError as exc:
        return {"ok": False, "error": f"io error: {exc}"}

    return {
        "ok": True,
        "zip": output,
        "mode": mode,
        "instructions": upload_instructions(output, mode, mastery),
    }


# ── CLI ──────────────────────────────────────────────────────────────────────

def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Package a self-contained HTML interactive as a SCORM 1.2 .zip "
                    "(opt-in; ASUCB default deploy stays iframe).")
    parser.add_argument("--html-file", required=True, help="Path to the source HTML file")
    parser.add_argument("--title", required=True, help="SCO title (manifest org/item title)")
    parser.add_argument("--identifier", default=None,
                        help="Manifest identifier (default: slug of title)")
    parser.add_argument("--mode", choices=("completion", "scored"), default="completion",
                        help="Grade model (default: completion)")
    parser.add_argument("--mastery-score", dest="mastery", type=int, default=None,
                        help="Passing threshold 0-100 (scored mode only)")
    parser.add_argument("--output", default=None,
                        help="Output .zip path (default: <html-basename>-SCORM.zip)")
    parser.add_argument("--no-bridge", dest="inject", action="store_false",
                        help="Skip JS bridge injection (HTML already wired)")
    args = parser.parse_args(argv)

    if args.mode == "completion" and args.mastery is not None:
        print("WARN: --mastery-score is ignored in completion mode.", file=sys.stderr)

    result = package(
        html_file=args.html_file, title=args.title, identifier=args.identifier,
        mode=args.mode, mastery=args.mastery, output=args.output, inject=args.inject,
    )
    if not result.get("ok"):
        print(f"FATAL: {result.get('error')}", file=sys.stderr)
        return 1
    print(result["instructions"])
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
