#!/usr/bin/env python3
"""Apply image-alt manifest verdicts to LIVE Canvas HTML.

Reads courses/{id}/reports/vision/alt-manifest.json + alt-classifications.json
(image_id -> {classification, proposed_alt}), groups classified images by
(object_type, object_id), LIVE-fetches each object's HTML, injects alt/role
per classification, and pushes through the gated write path.

MUST-2 (mirrors repush_transform.py): transform the LIVE body, never a stale
local copy. Post-write verification is field-targeted (substring check on the
specific <img>), never whole-body equality.

Usage:
    python apply_image_alt.py --course-id 123456 --dry-run
    python apply_image_alt.py --course-id 123456 --confirm-verb push
    python apply_image_alt.py --course-id 123456 --clear-cache
"""

from __future__ import annotations

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

sys.path.insert(0, os.path.dirname(__file__))

from bs4 import BeautifulSoup

import canvas_api
import staging_manager
import push_to_canvas

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
_VALID_CONFIRM_VERBS = {"push", "approved", "deploy"}

# manifest object_type -> backup_manager/canvas object_type
_OBJ_MAP = {
    "page": "pages",
    "assignment": "assignments",
    "discussion": "discussions",
    "quiz": "quizzes",
}

_FILE_ID_RE = re.compile(r"/files/(\d+)(?=$|[/?#])")  # /files/123, /123/preview, /123?dl — not /123abc


def _norm_src(src: str) -> str:
    return (src or "").split("?", 1)[0]


def _file_id(src: str):
    m = _FILE_ID_RE.search(src or "")
    return m.group(1) if m else None


def _img_matches(img_src: str, target_src: str) -> bool:
    if _norm_src(img_src) == _norm_src(target_src):
        return True
    fid, tid = _file_id(img_src), _file_id(target_src)
    return fid is not None and fid == tid


def _load_manifest_and_classifications(course_id: str):
    vdir = PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision"
    manifest_path = vdir / "alt-manifest.json"
    classifications_path = vdir / "alt-classifications.json"
    if not manifest_path.exists():
        raise FileNotFoundError(f"Manifest not found: {manifest_path}")
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    classes = {}
    if classifications_path.exists():
        for entry in json.loads(classifications_path.read_text(encoding="utf-8")):
            classes[entry["image_id"]] = entry
    return manifest.get("images", []), classes


def _group_targets(records: list, classifications: dict) -> dict:
    """Return {(object_type, object_id): [record_with_verdict, ...]}, and a
    list of skip messages for images that can't be actioned."""
    groups: dict = {}
    skipped = []
    for rec in records:
        image_id = rec.get("image_id")
        entry = classifications.get(image_id)
        if entry is None:
            continue  # unclassified — skip
        classification = entry.get("classification")
        if classification not in ("relevant", "decorative"):
            skipped.append(f"{image_id}: unknown classification {classification!r}")
            continue

        object_type = rec.get("object_type")
        object_id = rec.get("object_id")

        if object_type == "quiz":
            # The manifest does not carry question_id — quiz images live in
            # question_text, and a bare quiz_id is not enough to locate the
            # question to PUT. Log + skip rather than guess.
            skipped.append(
                f"quiz/{object_id} image {image_id}: no question_id in manifest, "
                "cannot target the containing question — skipped"
            )
            continue

        if object_type not in _OBJ_MAP:
            skipped.append(f"{image_id}: unsupported object_type {object_type!r}")
            continue

        key = (_OBJ_MAP[object_type], str(object_id))
        groups.setdefault(key, []).append({
            "image_id": image_id,
            "src": rec.get("src", ""),
            "classification": classification,
            "proposed_alt": entry.get("proposed_alt"),
        })
    return groups, skipped


def _inject(html: str, targets: list) -> tuple:
    """Apply alt/role edits for matching <img> tags. Returns (new_html, changes)."""
    # ponytail: BeautifulSoup str() re-serializes the whole body, so unrelated
    # tags may get normalized attribute order/quoting (renders identically).
    # Accepted: the dry-run preview + per-image verify catch real problems, and
    # a hand-rolled minimal-diff HTML editor is a fragility trap not worth it.
    soup = BeautifulSoup(html or "", "html.parser")
    changes = []
    for img in soup.find_all("img"):
        img_src = img.get("src", "")
        for t in targets:
            if not _img_matches(img_src, t["src"]):
                continue
            if t["classification"] == "decorative":
                if img.get("alt") != "" or img.get("role") != "presentation":
                    img["alt"] = ""
                    img["role"] = "presentation"
                    changes.append({"image_id": t["image_id"], "applied": "decorative", "src": t["src"]})
            elif t["classification"] == "relevant":
                if t["proposed_alt"] is not None and img.get("alt") != t["proposed_alt"]:
                    img["alt"] = t["proposed_alt"]
                    changes.append({
                        "image_id": t["image_id"], "applied": "relevant",
                        "alt": t["proposed_alt"], "src": t["src"],
                    })
            break  # one target per img
    return str(soup), changes


def _get_body(config, object_type: str, object_id: str):
    if object_type == "pages":
        page = canvas_api.get_page(config, object_id)
        return (page.get("body") or "") if page else None
    if object_type == "assignments":
        a = canvas_api.get_assignment(config, object_id)
        return (a.get("description") or "") if a else None
    if object_type == "discussions":
        d = canvas_api.get_discussion(config, object_id)
        return (d.get("message") or "") if d else None
    return None


def _stage_and_push(config, course_id, object_type, object_id, new_body):
    """Stage the transformed body, then push via the gated push_to_canvas
    path (backup -> push -> verify -> clear-staging) — same discipline as
    repush_transform.py. Returns the push_to_canvas result dict."""
    stage = staging_manager.stage(course_id, object_type, str(object_id), new_body)
    if not stage.get("ok"):
        return {"ok": False, "error": f"staging failed: {stage.get('error')}"}
    if object_type == "pages":
        return push_to_canvas.push_page(config, object_id, course_id)
    if object_type == "assignments":
        return push_to_canvas.push_assignment(config, object_id, course_id)
    if object_type == "discussions":
        return push_to_canvas.push_discussion(config, object_id, course_id)
    return {"ok": False, "error": f"unsupported object_type {object_type}"}


def _verify(config, object_type: str, object_id: str, changes: list) -> bool:
    """Per-image field-targeted verify: re-fetch, locate each changed <img>
    by its own src, and assert THAT tag carries the expected alt/role. Not a
    body-wide substring — a pre-existing decorative image elsewhere must not
    mask a failed injection."""
    body = _get_body(config, object_type, object_id) or ""
    soup = BeautifulSoup(body, "html.parser")
    imgs = soup.find_all("img")
    for c in changes:
        match = next((im for im in imgs if _img_matches(im.get("src", ""), c["src"])), None)
        if match is None:
            return False
        if c["applied"] == "decorative":
            if match.get("alt", None) != "" or match.get("role") != "presentation":
                return False
        elif c["applied"] == "relevant":
            if match.get("alt") != c["alt"]:
                return False
    return True


def run(course_id: str, dry_run: bool, confirm_verb: str, clear_cache: bool,
        as_json: bool) -> dict:
    result = {"objects": [], "totals": {"images_changed": 0, "verified": 0, "errors": 0}}

    if not dry_run:
        if not confirm_verb or confirm_verb.lower() not in _VALID_CONFIRM_VERBS:
            result["error"] = (
                "--confirm-verb required unless --dry-run. "
                f"Accepted: {', '.join(sorted(_VALID_CONFIRM_VERBS))}"
            )
            _emit(result, as_json)
            return result

    records, classifications = _load_manifest_and_classifications(course_id)
    groups, skipped = _group_targets(records, classifications)
    result["skipped"] = skipped

    if groups:
        config = canvas_api.get_config(course_id=course_id)

    for (object_type, object_id), targets in groups.items():
        entry = {"object": f"{object_type}/{object_id}", "images_changed": 0, "verified": False}
        body = _get_body(config, object_type, object_id)
        if body is None:
            entry["error"] = "could not fetch live content"
            result["objects"].append(entry)
            result["totals"]["errors"] += 1
            continue

        new_body, changes = _inject(body, targets)
        entry["images_changed"] = len(changes)
        entry["changes"] = changes

        if not changes:
            result["objects"].append(entry)
            continue

        if dry_run:
            result["objects"].append(entry)
            result["totals"]["images_changed"] += len(changes)
            continue

        push = _stage_and_push(config, course_id, object_type, object_id, new_body)
        # push_to_canvas takes a pre-push backup internally (backup_manager)
        # but does not return its path; point at the standard backup dir.
        entry["backup_path"] = str(
            PLUGIN_ROOT / "courses" / str(course_id) / "backups" / object_type)
        if not push.get("ok"):
            entry["error"] = f"push failed: {push.get('error')}"
            result["objects"].append(entry)
            result["totals"]["errors"] += 1
            continue

        verified = _verify(config, object_type, object_id, changes)
        entry["verified"] = verified
        if not verified:
            entry["error"] = (f"post-write verify failed; pre-push backup in "
                              f"{entry.get('backup_path')}")
            result["totals"]["errors"] += 1
        else:
            result["totals"]["verified"] += 1
        result["totals"]["images_changed"] += len(changes)
        result["objects"].append(entry)

    if clear_cache:
        img_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision" / "images"
        freed = 0
        n = 0
        if img_dir.exists():
            for f in img_dir.iterdir():
                if f.is_file():
                    freed += f.stat().st_size
                    f.unlink()
                    n += 1
        result["cache_cleared"] = {"files_removed": n, "bytes_freed": freed}

    _emit(result, as_json, dry_run=dry_run)
    return result


def _emit(result: dict, as_json: bool, dry_run: bool = False):
    if as_json:
        print(json.dumps(result, indent=2, default=str))
        return
    if result.get("error"):
        print(f"ERROR: {result['error']}", file=sys.stderr)
        return
    label = "DRY-RUN" if dry_run else "APPLY"
    for entry in result.get("objects", []):
        if entry.get("error"):
            print(f"  ERROR  {entry['object']}: {entry['error']}")
        elif entry["images_changed"]:
            print(f"  {label}  {entry['object']}: {entry['images_changed']} image(s) changed"
                  + (f" (verified)" if entry.get("verified") else ""))
        else:
            print(f"  SKIP   {entry['object']}: no changes")
    for s in result.get("skipped", []):
        print(f"  SKIP   {s}")
    if result.get("cache_cleared"):
        cc = result["cache_cleared"]
        print(f"  CACHE  removed {cc['files_removed']} file(s), freed {cc['bytes_freed']} bytes")
    t = result["totals"]
    print(f"\nTotals: {t['images_changed']} image(s) changed, "
          f"{t['verified']} verified, {t['errors']} error(s)")


def main():
    parser = argparse.ArgumentParser(
        description="Apply image alt/decorative classifications to live Canvas HTML."
    )
    parser.add_argument("--course-id", required=True)
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--confirm-verb", default=None,
                         help="push|approved|deploy — required unless --dry-run")
    parser.add_argument("--clear-cache", action="store_true",
                         help="Delete the downloaded image cache after running")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    dry_run = args.dry_run or not args.confirm_verb
    result = run(args.course_id, dry_run, args.confirm_verb, args.clear_cache, args.json)
    sys.exit(1 if result.get("error") or result["totals"]["errors"] else 0)


if __name__ == "__main__":
    main()
