"""Tests for scripts/package_scorm.py — SCORM 1.2 packager.

Covers the 8 acceptance criteria in docs/plan-scorm-packager.md.
"""

import os
import sys
import xml.etree.ElementTree as ET
import zipfile

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import package_scorm as ps  # noqa: E402

FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "pmg525_branching_sample.html")

MINI_HTML = "<!doctype html><html><head><title>T</title></head><body><h1>Hi</h1></body></html>"


# ── AC1: bridge contents ──────────────────────────────────────────────────────

def test_inject_bridge_contains_required_pieces():
    out = ps.inject_bridge(MINI_HTML, mode="completion")
    assert ps.BRIDGE_SENTINEL in out
    assert "window.ASUCB" in out
    assert "function findAPI" in out
    assert "LMSInitialize" in out
    assert "LMSCommit" in out
    assert "LMSFinish" in out
    assert "cmi.core.lesson_status" in out


def test_inject_bridge_inserts_before_body_close():
    out = ps.inject_bridge(MINI_HTML, mode="completion")
    assert out.index(ps.BRIDGE_SENTINEL) < out.lower().index("</body>")


def test_inject_bridge_no_body_appends():
    out = ps.inject_bridge("<div>no body tag</div>", mode="completion")
    assert ps.BRIDGE_SENTINEL in out
    assert out.startswith("<div>no body tag</div>")


# ── AC2: idempotent ───────────────────────────────────────────────────────────

def test_inject_bridge_idempotent():
    once = ps.inject_bridge(MINI_HTML, mode="completion")
    twice = ps.inject_bridge(once, mode="completion")
    assert once == twice
    assert once.count(ps.BRIDGE_SENTINEL) == 1


# ── AC3: original markup preserved ────────────────────────────────────────────

def test_inject_bridge_preserves_original_markup():
    out = ps.inject_bridge(MINI_HTML, mode="completion")
    # Everything before </body> in the original must survive verbatim.
    assert "<h1>Hi</h1>" in out
    # Removing the injected block must reconstruct the original exactly.
    start = out.index(ps.BRIDGE_SENTINEL)
    end = out.index("</script>", start) + len("</script>") + 1  # +1 for trailing newline
    reconstructed = out[:start] + out[end:]
    assert reconstructed == MINI_HTML


def test_inject_bridge_preserves_real_sample():
    with open(FIXTURE, encoding="utf-8") as fh:
        original = fh.read()
    out = ps.inject_bridge(original, mode="scored", mastery=70)
    # The branching screens and choose() logic must remain intact.
    assert "function choose(option)" in out
    assert "screen-2A" in out
    assert "currentPath" in out
    assert ps.BRIDGE_SENTINEL in out


# ── AC4: manifest ─────────────────────────────────────────────────────────────

def test_manifest_wellformed_and_sco():
    xml = ps.build_manifest("My Title", identifier="my_pkg", mode="completion")
    root = ET.fromstring(xml)  # raises on malformed
    ns = {"ims": "http://www.imsproject.org/xsd/imscp_rootv1p1p2",
          "adlcp": "http://www.adlnet.org/xsd/adlcp_rootv1p2"}
    res = root.find(".//ims:resources/ims:resource", ns)
    assert res is not None
    assert res.get("{http://www.adlnet.org/xsd/adlcp_rootv1p2}scormtype") == "sco"
    assert res.get("href") == "index.html"


def test_manifest_masteryscore_only_when_scored():
    completion = ps.build_manifest("T", identifier="t", mode="completion")
    assert "masteryscore" not in completion
    scored_no_m = ps.build_manifest("T", identifier="t", mode="scored")
    assert "masteryscore" not in scored_no_m
    scored = ps.build_manifest("T", identifier="t", mode="scored", mastery=70)
    assert "<adlcp:masteryscore>70</adlcp:masteryscore>" in scored


def test_manifest_escapes_title():
    xml = ps.build_manifest('A & B <x> "q"', identifier="t", mode="completion")
    assert "&amp;" in xml and "&lt;x&gt;" in xml
    ET.fromstring(xml)  # still well-formed


# ── AC5: package zip layout ───────────────────────────────────────────────────

def test_package_zip_layout(tmp_path):
    out = tmp_path / "pkg.zip"
    res = ps.package(FIXTURE, "PMG525 Sim", identifier="pmg525",
                     mode="completion", output=str(out))
    assert res["ok"] is True
    assert os.path.isfile(out)
    with zipfile.ZipFile(out) as zf:
        names = set(zf.namelist())
        assert names == {"imsmanifest.xml", "index.html"}
        html = zf.read("index.html").decode("utf-8")
        manifest = zf.read("imsmanifest.xml").decode("utf-8")
    assert ps.BRIDGE_SENTINEL in html
    ET.fromstring(manifest)


def test_package_default_output_beside_source(tmp_path):
    src = tmp_path / "myinteractive.html"
    src.write_text(MINI_HTML, encoding="utf-8")
    res = ps.package(str(src), "Title", mode="completion")
    assert res["ok"] is True
    assert res["zip"].endswith("myinteractive-SCORM.zip")
    assert os.path.isfile(res["zip"])


def test_package_no_inject_leaves_html_untouched(tmp_path):
    src = tmp_path / "x.html"
    src.write_text(MINI_HTML, encoding="utf-8")
    res = ps.package(str(src), "T", mode="completion",
                     output=str(tmp_path / "o.zip"), inject=False)
    assert res["ok"] is True
    with zipfile.ZipFile(res["zip"]) as zf:
        html = zf.read("index.html").decode("utf-8")
    assert ps.BRIDGE_SENTINEL not in html
    assert html == MINI_HTML


# ── AC6: CLI mode/mastery ─────────────────────────────────────────────────────

def test_cli_scored_mastery_in_manifest(tmp_path, capsys):
    out = tmp_path / "cli.zip"
    rc = ps.main(["--html-file", FIXTURE, "--title", "Sim",
                  "--mode", "scored", "--mastery-score", "65", "--output", str(out)])
    assert rc == 0
    with zipfile.ZipFile(out) as zf:
        manifest = zf.read("imsmanifest.xml").decode("utf-8")
    assert "<adlcp:masteryscore>65</adlcp:masteryscore>" in manifest


def test_cli_default_completion_no_mastery(tmp_path):
    out = tmp_path / "cli2.zip"
    rc = ps.main(["--html-file", FIXTURE, "--title", "Sim", "--output", str(out)])
    assert rc == 0
    with zipfile.ZipFile(out) as zf:
        manifest = zf.read("imsmanifest.xml").decode("utf-8")
    assert "masteryscore" not in manifest


# ── AC7: path safety ──────────────────────────────────────────────────────────

def test_unsafe_identifier_rejected(tmp_path):
    res = ps.package(FIXTURE, "T", identifier="../evil",
                     mode="completion", output=str(tmp_path / "o.zip"))
    assert res["ok"] is False
    assert "identifier" in res["error"]


def test_unsafe_output_path_rejected():
    res = ps.package(FIXTURE, "T", mode="completion", output="../../etc/passwd.zip")
    assert res["ok"] is False
    assert "traversal" in res["error"]


def test_missing_html_file_graceful():
    res = ps.package("/nonexistent/none.html", "T", mode="completion")
    assert res["ok"] is False
    assert "not found" in res["error"]


def test_invalid_mode_graceful(tmp_path):
    res = ps.package(FIXTURE, "T", mode="bogus", output=str(tmp_path / "o.zip"))
    assert res["ok"] is False


# ── AC8: real sample round-trip ───────────────────────────────────────────────

def test_real_sample_roundtrip(tmp_path):
    out = tmp_path / "pmg525-SCORM.zip"
    res = ps.package(FIXTURE, "PMG 525 — Solara Decision Sim", identifier="PMG525",
                     mode="scored", mastery=70, output=str(out))
    assert res["ok"] is True
    with zipfile.ZipFile(out) as zf:
        html = zf.read("index.html").decode("utf-8")
        manifest = zf.read("imsmanifest.xml").decode("utf-8")
    # bridge present, original interactive intact, manifest valid + scored
    assert ps.BRIDGE_SENTINEL in html
    assert "function choose(option)" in html
    assert "<adlcp:masteryscore>70</adlcp:masteryscore>" in manifest
    ET.fromstring(manifest)
    assert "scored" == res["mode"]


# ── review fix: mastery range validation ──────────────────────────────────────

@pytest.mark.parametrize("bad", [150, -5, 101])
def test_mastery_out_of_range_rejected(tmp_path, bad):
    res = ps.package(FIXTURE, "T", mode="scored", mastery=bad,
                     output=str(tmp_path / "o.zip"))
    assert res["ok"] is False
    assert "mastery" in res["error"]


@pytest.mark.parametrize("ok_val", [0, 70, 100])
def test_mastery_in_range_ok(tmp_path, ok_val):
    res = ps.package(FIXTURE, "T", mode="scored", mastery=ok_val,
                     output=str(tmp_path / f"o{ok_val}.zip"))
    assert res["ok"] is True


def test_build_manifest_rejects_bad_mastery():
    with pytest.raises(ps.PackageError):
        ps.build_manifest("T", identifier="t", mode="scored", mastery=200)


# ── review fix: non-UTF-8 input is graceful, not a traceback ───────────────────

def test_non_utf8_file_graceful(tmp_path):
    bad = tmp_path / "latin1.html"
    # 0xFF is invalid UTF-8; write raw bytes
    bad.write_bytes(b"<html><body>caf\xe9 \xff\xfe</body></html>")
    res = ps.package(str(bad), "T", mode="completion", output=str(tmp_path / "o.zip"))
    assert res["ok"] is False
    assert "UTF-8" in res["error"]
