#!/usr/bin/env python3
"""Render and measure visual courseware in desktop, mobile, fullscreen-like, and print modes."""

from __future__ import annotations

import argparse
import functools
import http.server
import json
import os
import socketserver
import threading
from pathlib import Path

from playwright.sync_api import sync_playwright


CHROME_CANDIDATES = [
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/Applications/Chromium.app/Contents/MacOS/Chromium",
    "/usr/bin/google-chrome",
    "/usr/bin/chromium",
]
VIEWPORTS = {
    "desktop": {"width": 1440, "height": 900},
    "mobile": {"width": 390, "height": 844},
    "fullscreen": {"width": 1600, "height": 900},
}


class QuietHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, _format: str, *_args: object) -> None:
        return


class ReusableTCPServer(socketserver.TCPServer):
    allow_reuse_address = True


def start_server(project: Path):
    handler = functools.partial(QuietHandler, directory=str(project))
    server = ReusableTCPServer(("127.0.0.1", 0), handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, thread, f"http://127.0.0.1:{server.server_address[1]}/index.html"


def measure(page, mode: str) -> dict:
    return page.evaluate(
        """(mode) => {
          const slide = document.querySelector('.slide:not([hidden])');
          if (!slide) return {fatal: 'no active slide'};
          const sr = slide.getBoundingClientRect();
          const visible = [...slide.querySelectorAll('*')].filter(el => {
            const cs = getComputedStyle(el); const r = el.getBoundingClientRect();
            return cs.display !== 'none' && cs.visibility !== 'hidden' && Number(cs.opacity) !== 0 && r.width > 1 && r.height > 1;
          });
          const outside = visible.filter(el => {
            const r = el.getBoundingClientRect();
            const horizontal = r.left < sr.left - 2 || r.right > sr.right + 2;
            const vertical = r.top < sr.top - 2 || r.bottom > sr.bottom + 2;
            return horizontal || (mode !== 'mobile' && vertical);
          }).slice(0, 8).map(el => ({tag: el.tagName, className: String(el.className || '').slice(0, 80)}));
          const fontSizes = visible.filter(el => el.children.length === 0 && (el.textContent || '').trim())
            .map(el => parseFloat(getComputedStyle(el).fontSize)).filter(Number.isFinite);
          const controls = document.querySelector('.deck-controls');
          const cr = controls ? controls.getBoundingClientRect() : null;
          const controlTextOverlap = cr ? visible.filter(el => el.children.length === 0 && (el.textContent || '').trim()).filter(el => {
            const r = el.getBoundingClientRect();
            return r.left < cr.right && r.right > cr.left && r.top < cr.bottom && r.bottom > cr.top;
          }).slice(0, 8).map(el => ({tag: el.tagName, text: (el.textContent || '').trim().slice(0, 60)})) : [];
          const union = visible.reduce((box, el) => { const r = el.getBoundingClientRect(); return {
            left: Math.min(box.left, r.left), top: Math.min(box.top, r.top), right: Math.max(box.right, r.right), bottom: Math.max(box.bottom, r.bottom)
          }; }, {left: sr.right, top: sr.bottom, right: sr.left, bottom: sr.top});
          const occupied = Math.max(0, Math.min(sr.width, union.right - union.left)) * Math.max(0, Math.min(sr.height, union.bottom - union.top));
          return {
            pageId: slide.dataset.pageId || '', slideType: slide.dataset.slideType || '',
            viewport: {width: innerWidth, height: innerHeight},
            slide: {width: Math.round(sr.width), height: Math.round(sr.height), scrollWidth: slide.scrollWidth, scrollHeight: slide.scrollHeight},
            documentOverflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
            slideOverflowX: slide.scrollWidth > slide.clientWidth + 2,
            slideOverflowY: mode === 'mobile' ? false : slide.scrollHeight > slide.clientHeight + 2,
            outside, controlTextOverlap, minFontPx: fontSizes.length ? Math.min(...fontSizes) : null,
            occupancy: sr.width && sr.height ? Number((occupied / (sr.width * sr.height)).toFixed(3)) : 0
          };
        }""",
        mode,
    )


def run_audit(project: Path, report_path: Path, screenshot_root: Path | None, limit: int | None, pdf_path: Path | None) -> dict:
    manifest = json.loads((project / "course-manifest.json").read_text(encoding="utf-8"))
    pages = manifest["pages"][:limit] if limit else manifest["pages"]
    findings: list[dict] = []
    results: dict[str, list] = {name: [] for name in ["desktop", "mobile", "fullscreen", "print"]}
    console_errors: list[str] = []
    failed_requests: list[str] = []
    server, thread, url = start_server(project)
    try:
        with sync_playwright() as playwright:
            executable = next((item for item in CHROME_CANDIDATES if os.path.exists(item)), None)
            launch_args = {"headless": True}
            if executable:
                launch_args["executable_path"] = executable
            browser = playwright.chromium.launch(**launch_args)
            try:
                for mode, viewport in VIEWPORTS.items():
                    context = browser.new_context(viewport=viewport, device_scale_factor=1)
                    page = context.new_page()
                    page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
                    page.on("requestfailed", lambda request: failed_requests.append(request.url))
                    for index, manifest_page in enumerate(pages, start=1):
                        page.goto(f"{url}#slide-{index}", wait_until="networkidle")
                        page.wait_for_timeout(120)
                        if mode == "fullscreen":
                            page.click("#fullscreenToggle")
                            try:
                                page.wait_for_function("document.fullscreenElement === document.getElementById('deckFrame')", timeout=3000)
                            except Exception as error:
                                findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": "fullscreen", "message": f"real fullscreen did not activate: {error}"})
                        metrics = measure(page, mode)
                        metrics["index"] = index
                        results[mode].append(metrics)
                        if metrics.get("fatal"):
                            findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": "render", "message": metrics["fatal"]})
                        for code in ["documentOverflowX", "slideOverflowX", "slideOverflowY"]:
                            if metrics.get(code):
                                findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": code, "message": "unexpected scroll overflow"})
                        if metrics.get("outside"):
                            findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": "element-bounds", "message": f"{len(metrics['outside'])} visible elements cross the slide boundary", "elements": metrics["outside"]})
                        if metrics.get("controlTextOverlap"):
                            findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": "control-overlap", "message": "navigation controls cover visible text", "elements": metrics["controlTextOverlap"]})
                        threshold = 10 if mode == "mobile" else 11
                        if metrics.get("minFontPx") is not None and metrics["minFontPx"] < threshold:
                            findings.append({"severity": "error", "mode": mode, "pageId": manifest_page["id"], "code": "font-size", "message": f"minimum visible font {metrics['minFontPx']}px is below {threshold}px"})
                        if metrics.get("occupancy", 0) < 0.12 or metrics.get("occupancy", 0) > 0.985:
                            findings.append({"severity": "warning", "mode": mode, "pageId": manifest_page["id"], "code": "occupancy", "message": f"content occupancy is {metrics.get('occupancy')}"})
                        if screenshot_root:
                            folder = screenshot_root / mode
                            folder.mkdir(parents=True, exist_ok=True)
                            page.screenshot(path=str(folder / f"{index:02d}-{manifest_page['id']}.png"), full_page=(mode == "mobile"))
                        if mode == "fullscreen":
                            page.evaluate("document.fullscreenElement ? document.exitFullscreen() : Promise.resolve()")
                            page.wait_for_function("document.fullscreenElement === null", timeout=3000)
                    context.close()

                context = browser.new_context(viewport={"width": 1600, "height": 900})
                page = context.new_page()
                page.goto(url, wait_until="networkidle")
                page.emulate_media(media="print")
                print_count = page.locator(".slide").count()
                visible_count = page.locator(".slide").evaluate_all("els => els.filter(el => getComputedStyle(el).display !== 'none').length")
                results["print"].append({"pageCount": print_count, "visiblePageCount": visible_count})
                expected_count = len(manifest["pages"])
                if print_count != expected_count or visible_count != expected_count:
                    findings.append({"severity": "error", "mode": "print", "pageId": "all", "code": "print-pages", "message": f"expected {expected_count} printable pages, got {visible_count}/{print_count}"})
                if pdf_path:
                    pdf_path.parent.mkdir(parents=True, exist_ok=True)
                    page.pdf(path=str(pdf_path), print_background=True, prefer_css_page_size=True)
                context.close()
            finally:
                browser.close()
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=3)

    for message in sorted(set(console_errors)):
        findings.append({"severity": "error", "mode": "runtime", "pageId": "load", "code": "console", "message": message})
    for request in sorted(set(failed_requests)):
        findings.append({"severity": "error", "mode": "runtime", "pageId": "load", "code": "request", "message": request})
    errors = [item for item in findings if item["severity"] == "error"]
    report = {
        "ok": not errors,
        "project": project.name,
        "pageCount": len(manifest["pages"]),
        "auditedPageCount": len(pages),
        "modes": results,
        "consoleErrors": sorted(set(console_errors)),
        "failedRequests": sorted(set(failed_requests)),
        "findings": findings,
    }
    report_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return report


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("project", type=Path)
    parser.add_argument("--report", type=Path)
    parser.add_argument("--screenshots", type=Path)
    parser.add_argument("--pdf", type=Path)
    parser.add_argument("--limit", type=int)
    args = parser.parse_args()
    project = args.project.resolve()
    report_path = (args.report or project / "audit-report.json").resolve()
    report = run_audit(project, report_path, args.screenshots.resolve() if args.screenshots else None, args.limit, args.pdf.resolve() if args.pdf else None)
    print(json.dumps({"ok": report["ok"], "report": str(report_path), "pageCount": report["pageCount"], "findings": len(report["findings"])}, ensure_ascii=False, indent=2))
    return 0 if report["ok"] else 1


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