#!/usr/bin/env python3
"""Read-only accessibility classifier for PDF/DOCX/PPTX files (file-remediation Stage 1).

Classifies each file into a remediation class + route without modifying anything.
Never opens encrypted PDFs with a password; never claims "compliant" — Assess is not
a compliance check (see skills/file-remediation/SKILL.md). HTML report generation is
deferred to a later phase; this module only emits JSON records.

Usage:
    python remediate_assess.py --path file1.pdf file2.docx dir/
    python remediate_assess.py --course-id 260866 --emit-backlog
    python remediate_assess.py --path file.pdf --json out.json
"""

from __future__ import annotations

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

SCRIPTS_DIR = Path(__file__).resolve().parent
if str(SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPTS_DIR))

from lxml import etree
from pypdf import PdfReader

try:
    import canvas_api
except Exception:
    canvas_api = None
try:
    import backlog_manager
except Exception:
    backlog_manager = None
try:
    import setup_remediation
except Exception:
    setup_remediation = None

MAX_SIZE_MB = 150
MAX_PAGES = 300
SUPPORTED_EXTS = (".pdf", ".docx", ".pptx")

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DC_NS = "http://purl.org/dc/elements/1.1/"

def _size_mb(path):
    return round(path.stat().st_size / (1024 * 1024), 3)

# ── PDF ──────────────────────────────────────────────────────────────────

def _page_text_len(page):
    try:
        return len(page.extract_text() or "")
    except Exception:
        return 0

def _page_image_count(page):
    # ponytail: only scans page /Resources /XObject, misses images nested inside
    # Form XObjects (same blind spot as _page_math_hint's font scan below) — a
    # real PDF can show image_total=0 despite embedded images (STP 530 fixture:
    # 0 counted, 14 actual, all inside Form XObjects). Informational only —
    # classify_pdf's scanned/hybrid heuristic uses text length alone, never this
    # count, precisely because it's not reliable. Upgrade path: recurse into
    # nested /Resources when Phase 2 needs an accurate image census.
    try:
        xobjects = page.get("/Resources", {}).get("/XObject")
        if xobjects is None:
            return 0
        count = 0
        for ref in xobjects.values():
            try:
                if ref.get_object().get("/Subtype") == "/Image":
                    count += 1
            except Exception:
                continue
        return count
    except Exception:
        return 0

def _page_math_hint(page):
    # ponytail: only scans page /Resources /Font, misses fonts nested inside Form
    # XObjects. Upgrade path: recurse into nested /Resources when Phase 2 needs
    # full math-density coverage.
    try:
        fonts = page.get("/Resources", {}).get("/Font")
        if fonts is None:
            return False
        for ref in fonts.values():
            try:
                base_font = str(ref.get_object().get("/BaseFont", "")).lstrip("/")
                if "+" in base_font:
                    base_font = base_font.split("+", 1)[1]
                if base_font.startswith("CM"):
                    return True
            except Exception:
                continue
    except Exception:
        pass
    return False

def _verapdf_detail(path):
    if setup_remediation is None:
        return {"verapdf": "not installed"}
    vp_bin = setup_remediation.get_verapdf()
    if not vp_bin:
        return {"verapdf": "not installed"}
    import subprocess
    try:
        proc = subprocess.run(
            [str(vp_bin), "--flavour", "ua1", "--format", "json", str(path)],
            capture_output=True, text=True, timeout=60,
        )
        data = json.loads(proc.stdout)
        jobs = data.get("report", {}).get("jobs", [])
        validation = jobs[0].get("validationResult", {}) if jobs else {}
        failed = [
            f"{r.get('specification', '')} {r.get('clause', '')}".strip()
            for r in (validation.get("details", {}).get("ruleSummaries") or [])
            if r.get("failedChecks")
        ]
        return {"compliant": validation.get("compliant"), "failedRules": failed}
    except Exception as exc:
        return {"compliant": None, "failedRules": [], "verapdf_error": str(exc)[:200]}

def classify_pdf(path):
    """Classify a single PDF. Never attempts to decrypt an encrypted file."""
    size_mb = _size_mb(path)
    try:
        reader = PdfReader(str(path))
    except Exception as exc:
        return {"class": "corrupt", "route": "flag", "flags": ["corrupt"],
                "detail": {"error": str(exc)[:300]}, "size_mb": size_mb}

    if reader.is_encrypted:
        return {"class": "encrypted", "route": "flag", "flags": ["encrypted"],
                "detail": {}, "size_mb": size_mb}

    try:
        page_count = len(reader.pages)
    except Exception as exc:
        return {"class": "corrupt", "route": "flag", "flags": ["corrupt"],
                "detail": {"error": str(exc)[:300]}, "size_mb": size_mb}

    flags = ["oversized"] if (size_mb > MAX_SIZE_MB or page_count > MAX_PAGES) else []

    tagged = has_lang = False
    try:
        catalog = reader.trailer["/Root"]
        mark_info = catalog.get("/MarkInfo")
        tagged = bool(mark_info and mark_info.get("/Marked")) or "/StructTreeRoot" in catalog
        has_lang = bool(catalog.get("/Lang"))
    except Exception:
        pass
    try:
        has_title = bool(reader.metadata and reader.metadata.title)
    except Exception:
        has_title = False

    if tagged:
        result = {
            "class": "digital-tagged", "route": "E3-metadata-patch", "flags": flags,
            "detail": {"has_title": has_title, "has_lang": has_lang, "page_count": page_count},
            "size_mb": size_mb,
        }
        result["detail"].update(_verapdf_detail(path))
        return result

    image_only_pages = text_pages = image_total = 0
    math_hint = False
    for page in reader.pages:
        text_len = _page_text_len(page)
        image_total += _page_image_count(page)  # informational only — see ponytail note above
        if text_len < 100:
            image_only_pages += 1
        else:
            text_pages += 1
        math_hint = math_hint or _page_math_hint(page)

    ratio = image_only_pages / page_count if page_count else 0
    if ratio >= 0.8:
        cls, route = "scanned", "OCR+E2"
    elif image_only_pages >= 2 and text_pages >= 2:
        cls, route = "hybrid", "E2+OCR-pages"
    else:
        cls, route = "digital-untagged", "E2-insitu"

    detail = {
        "page_count": page_count, "image_only_pages": image_only_pages,
        "text_pages": text_pages, "image_total": image_total, "math_hint": math_hint,
        "has_title": has_title, "has_lang": has_lang,
    }
    detail.update(_verapdf_detail(path))
    return {"class": cls, "route": route, "flags": flags, "detail": detail, "size_mb": size_mb}

# ── DOCX ─────────────────────────────────────────────────────────────────

def classify_docx(path):
    size_mb = _size_mb(path)
    try:
        with zipfile.ZipFile(path) as z:
            names = set(z.namelist())
            total_images = missing_alt = 0
            has_heading = False
            total_tables = tables_missing_header = 0

            if "word/document.xml" in names:
                tree = etree.fromstring(z.read("word/document.xml"))
                for docPr in tree.iter("{*}docPr"):
                    total_images += 1
                    if not (docPr.get("descr") or "").strip():
                        missing_alt += 1
                for pStyle in tree.iter("{*}pStyle"):
                    if str(pStyle.get(f"{{{W_NS}}}val", "")).lower().startswith("heading"):
                        has_heading = True
                        break
                for tbl in tree.iter("{*}tbl"):
                    total_tables += 1
                    if not any(
                        tr.find("{*}trPr") is not None
                        and tr.find("{*}trPr").find("{*}tblHeader") is not None
                        for tr in tbl.iter("{*}tr")
                    ):
                        tables_missing_header += 1

            has_title = False
            if "docProps/core.xml" in names:
                core = etree.fromstring(z.read("docProps/core.xml"))
                title_el = core.find(f"{{{DC_NS}}}title")
                has_title = bool(title_el is not None and (title_el.text or "").strip())
    except Exception as exc:
        return {"class": "corrupt", "route": "flag", "flags": ["corrupt"],
                "detail": {"error": str(exc)[:300]}, "size_mb": size_mb}

    flags = []
    if missing_alt:
        flags.append(f"images_missing_alt:{missing_alt}")
    if not has_heading:
        flags.append("no_heading_styles")
    if not has_title:
        flags.append("missing_title")
    if tables_missing_header:
        flags.append(f"tables_missing_header:{tables_missing_header}")

    detail = {
        "total_images": total_images, "images_missing_alt": missing_alt,
        "has_heading_style": has_heading, "has_title": has_title,
        "total_tables": total_tables, "tables_missing_header": tables_missing_header,
    }
    return {"class": "docx", "route": "E1-office-inplace", "flags": flags, "detail": detail,
            "size_mb": size_mb}

# ── PPTX ─────────────────────────────────────────────────────────────────

_IMG_EXT_RE = re.compile(r"\.(png|jpe?g|gif|bmp|tiff?|webp)$", re.IGNORECASE)

def classify_pptx(path):
    size_mb = _size_mb(path)
    try:
        with zipfile.ZipFile(path) as z:
            names = set(z.namelist())
            slide_names = sorted(n for n in names if re.match(r"ppt/slides/slide\d+\.xml$", n))
            total_images = bad_alt = slides_missing_title = 0

            for slide_name in slide_names:
                tree = etree.fromstring(z.read(slide_name))
                has_title_ph = any(
                    ph.get("type") in ("title", "ctrTitle") for ph in tree.iter("{*}ph")
                )
                if not has_title_ph:
                    slides_missing_title += 1
                for pic in tree.iter("{*}pic"):
                    cNvPr = pic.find(".//{*}cNvPr")
                    if cNvPr is None:
                        continue
                    total_images += 1
                    descr = (cNvPr.get("descr") or "").strip()
                    if not descr or _IMG_EXT_RE.search(descr):
                        bad_alt += 1

            has_title = False
            if "docProps/core.xml" in names:
                core = etree.fromstring(z.read("docProps/core.xml"))
                title_el = core.find(f"{{{DC_NS}}}title")
                has_title = bool(title_el is not None and (title_el.text or "").strip())
    except Exception as exc:
        return {"class": "corrupt", "route": "flag", "flags": ["corrupt"],
                "detail": {"error": str(exc)[:300]}, "size_mb": size_mb}

    flags = []
    if bad_alt:
        flags.append(f"images_bad_alt:{bad_alt}")
    if slides_missing_title:
        flags.append(f"slides_missing_title:{slides_missing_title}")
    if not has_title:
        flags.append("missing_title")

    detail = {
        "total_slides": len(slide_names), "total_images": total_images,
        "images_bad_alt": bad_alt, "slides_missing_title": slides_missing_title,
        "has_title": has_title,
    }
    return {"class": "pptx", "route": "E1-office-inplace", "flags": flags, "detail": detail,
            "size_mb": size_mb}

# ── Dispatch ─────────────────────────────────────────────────────────────

def classify_file(path):
    """Classify one file by extension. Returns the full per-file record."""
    path = Path(path)
    ext = path.suffix.lower()
    if not path.exists():
        return {"file": str(path), "type": ext.lstrip("."), "size_mb": 0.0,
                "class": "missing", "route": "flag", "flags": ["missing"], "detail": {}}
    if ext == ".pdf":
        result = classify_pdf(path)
    elif ext == ".docx":
        result = classify_docx(path)
    elif ext == ".pptx":
        result = classify_pptx(path)
    else:
        result = {"class": "unsupported", "route": "flag", "flags": ["unsupported_type"],
                   "detail": {}, "size_mb": _size_mb(path)}
    result["file"] = str(path)
    result["type"] = ext.lstrip(".") or "unknown"
    return result

def _collect_paths(path_args):
    files = []
    for raw in path_args:
        p = Path(raw)
        if p.is_dir():
            for ext in SUPPORTED_EXTS:
                files.extend(sorted(p.rglob(f"*{ext}")))
        else:
            files.append(p)
    return files

# ── Canvas course-files discovery ───────────────────────────────────────

_CONTENT_TYPES = [
    "application/pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
]

def download_course_files(course_id):
    """List + download course Files of type pdf/docx/pptx to content_cache/files/.

    Returns (local_files, files_meta): local_files is [(local_path, file_id), ...]
    for supported types (unchanged shape); files_meta is the raw list_files() result
    (all content types matched) for reuse by --with-linkage (folder, file-html sweep)
    without re-listing.
    """
    if canvas_api is None:
        raise RuntimeError("canvas_api not available")
    import requests
    config = canvas_api.get_config(course_id=course_id)
    files = canvas_api.list_files(config, content_types=_CONTENT_TYPES)
    dest_dir = Path(canvas_api.get_plugin_root()) / "courses" / str(course_id) / \
        "content_cache" / "files"
    dest_dir.mkdir(parents=True, exist_ok=True)

    local_files = []
    for f in files:
        name = f.get("display_name") or f.get("filename") or f"file-{f.get('id')}"
        if Path(name).suffix.lower() not in SUPPORTED_EXTS:
            continue
        url = f.get("url")
        if not url:
            continue
        local_path = dest_dir / f"{f.get('id')}-{name}"
        try:
            resp = requests.get(url, headers=config["headers"], timeout=60, stream=True)
            resp.raise_for_status()
            with local_path.open("wb") as fh:
                for chunk in resp.iter_content(chunk_size=65536):
                    fh.write(chunk)
        except Exception as exc:
            print(f"WARN: failed to download {name}: {exc}", file=sys.stderr)
            continue
        local_files.append((local_path, f.get("id")))
    return local_files, files

def _emit_backlog(course_id, record, canvas_file_id=None):
    if backlog_manager is None:
        return
    if record.get("retire_candidate"):
        issue = ("unlinked in searched content — review for retirement — "
                 "retire_file.py backs it up locally, then retires it, after explicit approval")
    else:
        issue = f"class={record['class']}, route={record['route']}"
        if record.get("flags"):
            issue += f", flags={','.join(record['flags'])}"
    severity = "warning" if record["route"] == "flag" else "info"
    try:
        backlog_manager.add(course_id, {
            "finding_type": "file_a11y",
            "source": "remediate_assess.py",
            "severity": severity,
            "detail": issue,
            "issue": issue,
            "file_name": Path(record["file"]).name,
            "file_id": canvas_file_id,
        })
    except Exception as exc:
        print(f"WARN: backlog emit failed for {record['file']}: {exc}", file=sys.stderr)

# ── Linkage / reach sweep (--with-linkage) ──────────────────────────────
# ponytail: one read-only sweep, no concurrency, no cache layer — course scale
# is tens of objects. Not eligible for any Canvas write path.

_FILE_ID_RE = re.compile(r"/files/(\d+)")
_HTML_EXTS = (".html", ".htm")
LINKAGE_LABEL_CAP = 20

LINKAGE_CAVEAT = (
    "Reach counts references found in pages, assignments, quizzes (descriptions + "
    "questions/answers), discussions, announcements, syllabus, module items, and "
    "uploaded HTML files. Files referenced only via LTI/New Quizzes item banks, "
    "Classic question banks, Canvas Studio embeds, front page/course card/calendar "
    "events, blueprint-associated or other courses, or external/hardcoded links are "
    "NOT visible to this sweep — confirm before retiring any file."
)


def _extract_file_ids(text):
    if not text:
        return set()
    return {int(m) for m in _FILE_ID_RE.findall(text)}


def _fetch_text(config, url):
    import requests
    try:
        resp = requests.get(url, headers=config["headers"], timeout=30)
        resp.raise_for_status()
        return resp.text
    except Exception:
        return ""


def sweep_linkage(config, files_meta):
    """One read-only pass over the course. Returns (reach, surfaces_swept).

    reach: {canvas_file_id: {(surface_type, surface_id): label}} — dict keys give
    distinct-pair reach count via len(); label is the human-readable linked_from entry.
    Only file_ids present in files_meta are tracked (skips reach lookups for files
    outside the assessed set).
    """
    file_ids = {f.get("id") for f in files_meta if f.get("id") is not None}
    reach = {fid: {} for fid in file_ids}

    def add(fid, surface_type, surface_id, label):
        if fid in reach:
            reach[fid][(surface_type, surface_id)] = label

    surfaces_swept = []

    for p in canvas_api.get_all_pages(config, include_body=True):
        slug = p.get("url") or p.get("page_id")
        for fid in _extract_file_ids(p.get("body")):
            add(fid, "page", slug, f"page:{slug}")
    surfaces_swept.append("pages")

    for a in canvas_api.list_assignments(config):
        aid = a.get("id")
        for fid in _extract_file_ids(a.get("description")):
            add(fid, "assignment", aid, f"assignment:{aid}")
    surfaces_swept.append("assignments")

    for q in canvas_api.list_quizzes(config):
        qid = q.get("id")
        for fid in _extract_file_ids(q.get("description")):
            add(fid, "quiz", qid, f"quiz:{qid}")
        try:
            questions = canvas_api.get_quiz_questions(config, qid)
        except Exception:
            questions = []
        for qq in questions:
            hit = _extract_file_ids(qq.get("question_text"))
            for ans in (qq.get("answers") or []):
                hit |= _extract_file_ids(ans.get("text") or ans.get("html"))
            for fid in hit:
                add(fid, "quiz-question", qid, f"quiz-question:{qid}")
    surfaces_swept.append("quizzes+questions")

    for d in canvas_api.list_discussions(config):
        did = d.get("id")
        for fid in _extract_file_ids(d.get("message")):
            add(fid, "discussion", did, f"discussion:{did}")
    surfaces_swept.append("discussions")

    try:
        announcements = canvas_api.list_announcements(config)
    except Exception:
        announcements = []
    for d in announcements:
        did = d.get("id")
        for fid in _extract_file_ids(d.get("message")):
            add(fid, "announcement", did, f"announcement:{did}")
    surfaces_swept.append("announcements")

    syllabus = canvas_api.get_syllabus(config)
    for fid in _extract_file_ids(syllabus.get("html")):
        add(fid, "syllabus", "syllabus", "syllabus")
    surfaces_swept.append("syllabus")

    for m in canvas_api.list_modules(config):
        for item in (m.get("items") or []):
            if item.get("type") == "File" and item.get("content_id") is not None:
                add(item["content_id"], "module-item", item.get("id"), "module-item:file")
    surfaces_swept.append("module-items")

    for f in files_meta:
        name = f.get("display_name") or f.get("filename") or ""
        url = f.get("url")
        if not (name.lower().endswith(_HTML_EXTS) and url):
            continue
        body = _fetch_text(config, url)
        for fid in _extract_file_ids(body):
            add(fid, "file-html", f.get("id"), "file-html")
    surfaces_swept.append("uploaded-html-files")

    return reach, surfaces_swept


def enrich_with_linkage(records_with_ids, reach, config):
    """Add reach/linked_from/folder/retire_candidate to each record.

    records_with_ids: [(record, canvas_file_id, folder_id), ...]. Resolves folder
    full_name via canvas_api.get_folder, caching one lookup per distinct folder_id.
    Returns list of {file, folder, class} for reach==0 records (unlinked/retire).
    """
    folder_cache = {}
    unlinked = []
    for record, fid, folder_id in records_with_ids:
        pairs = reach.get(fid, {})
        labels = sorted(set(pairs.values()))
        truncated = len(labels) > LINKAGE_LABEL_CAP
        record["reach"] = len(pairs)
        record["linked_from"] = labels[:LINKAGE_LABEL_CAP] + (
            [f"...+{len(labels) - LINKAGE_LABEL_CAP} more"] if truncated else []
        )
        if folder_id not in folder_cache:
            folder = canvas_api.get_folder(config, folder_id) if folder_id else None
            folder_cache[folder_id] = folder.get("full_name") if folder else None
        record["folder"] = folder_cache[folder_id]
        record["retire_candidate"] = record["reach"] == 0
        if record["retire_candidate"]:
            unlinked.append({"file": record["file"], "folder": record["folder"],
                              "class": record["class"]})
    return unlinked

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

def main():
    parser = argparse.ArgumentParser(description="Assess PDF/DOCX/PPTX files for accessibility")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--path", nargs="+", metavar="FILE_OR_DIR")
    group.add_argument("--course-id")
    parser.add_argument("--json", metavar="OUT", default="-",
                         help="Write JSON output to file (default: stdout)")
    parser.add_argument("--emit-backlog", action="store_true",
                         help="Emit a file_a11y backlog finding per file (requires --course-id)")
    parser.add_argument("--with-linkage", action="store_true",
                         help="Course mode only: add inbound-link reach + retire-candidate "
                              "detection per file (one extra read-only course sweep)")
    args = parser.parse_args()

    if args.emit_backlog and not args.course_id:
        parser.error("--emit-backlog requires --course-id")
    if args.with_linkage and not args.course_id:
        parser.error("--with-linkage requires --course-id")

    records = []
    unlinked = None
    if args.course_id:
        local_files, files_meta = download_course_files(args.course_id)
        meta_by_id = {f.get("id"): f for f in files_meta}
        records_with_ids = []
        for local_path, file_id in local_files:
            record = classify_file(local_path)
            records.append(record)
            records_with_ids.append((record, file_id, meta_by_id.get(file_id, {}).get("folder_id")))

        if args.with_linkage:
            config = canvas_api.get_config(course_id=args.course_id)
            reach, surfaces_swept = sweep_linkage(config, files_meta)
            unlinked = enrich_with_linkage(records_with_ids, reach, config)
            print(f"Linkage sweep surfaces: {', '.join(surfaces_swept)}.", file=sys.stderr)

        if args.emit_backlog:
            for record, file_id, _ in records_with_ids:
                _emit_backlog(args.course_id, record, canvas_file_id=file_id)
    else:
        for path in _collect_paths(args.path):
            records.append(classify_file(path))

    if args.with_linkage:
        payload = {"records": records, "unlinked": unlinked, "linkage_caveat": LINKAGE_CAVEAT}
    else:
        payload = records
    output = json.dumps(payload, indent=2, default=str)
    if args.json == "-":
        print(output)
    else:
        Path(args.json).write_text(output, encoding="utf-8")

    by_class, by_route = {}, {}
    for r in records:
        by_class[r["class"]] = by_class.get(r["class"], 0) + 1
        by_route[r["route"]] = by_route.get(r["route"], 0) + 1
    print(f"Assessed {len(records)} file(s). By class: {by_class}. By route: {by_route}.",
          file=sys.stderr)
    if args.with_linkage:
        print(f"{len(records)} files, {len(unlinked)} unlinked (retire candidates), "
              f"{len(surfaces_swept)} surfaces swept.", file=sys.stderr)

if __name__ == "__main__":
    main()
