#!/usr/bin/env python3
"""Render an HTML courseware deck, create contact sheets, and flag visual defects."""

from __future__ import annotations

import argparse
import json
import math
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path

try:
    from PIL import Image, ImageDraw
    from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
    from playwright.sync_api import sync_playwright
except ImportError as exc:  # pragma: no cover - dependency guidance
    raise SystemExit(
        "Missing dependency. Install Playwright and Pillow, then run "
        "`python -m playwright install chromium`. Original error: " + str(exc)
    )


DEFAULT_SLIDE_SELECTOR = ".slide-card"
DEFAULT_VISUAL_SELECTOR = ".semantic-visual,.business-visual,.notion-visual,[data-visual-type]"
MODE_CONFIG = {
    "desktop": {"viewport": {"width": 1440, "height": 900}, "media": "screen"},
    "fullscreen": {"viewport": {"width": 2000, "height": 1200}, "media": "screen"},
    "mobile": {"viewport": {"width": 390, "height": 844}, "media": "screen"},
    "print": {"viewport": {"width": 1440, "height": 900}, "media": "print"},
}
REPETITION_EXEMPT_TYPES = {"method", "platform-screenshot", "product-screenshot", "quiz"}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Render every slide and audit semantic visuals, overflow, fonts, assets, and balance."
    )
    parser.add_argument("url", help="Deck URL, usually a local preview such as http://127.0.0.1:8000")
    parser.add_argument("--output-dir", default="outputs/visual-audit", help="Evidence directory")
    parser.add_argument("--modes", default="desktop,fullscreen,mobile,print", help="Comma-separated modes")
    parser.add_argument("--slide-selector", default=DEFAULT_SLIDE_SELECTOR)
    parser.add_argument("--body-selector", default=".slide-body")
    parser.add_argument("--visual-selector", default=DEFAULT_VISUAL_SELECTOR)
    parser.add_argument("--expected-slides", type=int)
    parser.add_argument("--min-visual-types", type=int, default=4)
    parser.add_argument("--timeout-ms", type=int, default=30000)
    parser.add_argument(
        "--fail-on",
        default="P0",
        help="Comma-separated severities that produce a non-zero exit code, e.g. P0,P1",
    )
    return parser.parse_args()


def add_issue(issues: list[dict], mode: str, page_number: int | None, severity: str, code: str, message: str) -> None:
    issues.append(
        {
            "mode": mode,
            "page": page_number,
            "severity": severity,
            "code": code,
            "message": message,
        }
    )


def slide_number(item: dict, index: int) -> int:
    value = item.get("global")
    try:
        number = int(value)
        return number if number > 0 else index + 1
    except (TypeError, ValueError):
        return index + 1


def discover_slides(page, selector: str) -> list[dict]:
    return page.eval_on_selector_all(
        selector,
        """elements => elements.map((element, index) => ({
            id: element.id || `visual-audit-slide-${index + 1}`,
            global: element.dataset.global || index + 1,
            hadId: Boolean(element.id)
        }))""",
    )


def ensure_slide_ids(page, selector: str, slides: list[dict]) -> None:
    if all(item.get("hadId") for item in slides):
        return
    page.eval_on_selector_all(
        selector,
        "elements => elements.forEach((element, index) => { if (!element.id) element.id = `visual-audit-slide-${index + 1}`; })",
    )


def deck_position(slide_id: str) -> tuple[str, int] | None:
    match = re.fullmatch(r"(m\d+)-s(\d+)", slide_id)
    if not match:
        return None
    return match.group(1), int(match.group(2))


def enter_fullscreen(page, first_slide_id: str) -> bool:
    position = deck_position(first_slide_id)
    if not position:
        return False
    module_id, _ = position
    available = page.evaluate("typeof window.openFullscreen === 'function'")
    if not available:
        return False
    page.evaluate("moduleId => window.openFullscreen(moduleId)", module_id)
    try:
        page.locator(".fullscreen-overlay").wait_for(state="visible", timeout=5000)
        return True
    except PlaywrightTimeoutError:
        return False


def go_to_slide(page, slide_id: str) -> None:
    position = deck_position(slide_id)
    if position and page.evaluate("typeof window.goToDeckPosition === 'function'"):
        page.evaluate("([moduleId, number]) => window.goToDeckPosition(moduleId, number)", list(position))
        page.wait_for_timeout(70)


def choose_root(page, slide_id: str, selector: str, true_fullscreen: bool):
    if true_fullscreen:
        overlay_match = page.locator(f'.fullscreen-overlay #{slide_id}')
        if overlay_match.count() and overlay_match.first.is_visible():
            return overlay_match.first
        overlay_slide = page.locator(f".fullscreen-overlay {selector}").filter(has=page.locator("svg, img, h1, h2, h3"))
        visible_count = overlay_slide.count()
        for index in range(visible_count):
            if overlay_slide.nth(index).is_visible():
                return overlay_slide.nth(index)
    root = page.locator(f"#{slide_id}").first
    if root.count() and root.is_visible():
        return root
    return None


def audit_root(root, body_selector: str, visual_selector: str, mode: str) -> dict:
    thresholds = {
        "desktop": {"title": 32, "subtitle": 17, "body": 15, "caption": 14, "svg": 12.5},
        "fullscreen": {"title": 40, "subtitle": 21, "body": 17, "caption": 18, "svg": 18},
        "mobile": {"title": 26, "subtitle": 15, "body": 14, "caption": 13, "svg": 5},
        "print": {"title": 28, "subtitle": 15, "body": 12.5, "caption": 12.5, "svg": 10},
    }[mode]
    return root.evaluate(
        """(root, args) => {
          const visible = element => {
            const style = getComputedStyle(element);
            return element.getClientRects().length > 0 && style.visibility !== 'hidden' && style.display !== 'none';
          };
          const px = element => parseFloat(getComputedStyle(element).fontSize) || 0;
          const body = root.querySelector(args.bodySelector) || root;
          const bodyRect = body.getBoundingClientRect();
          const overflowSelector = [
            '.info-card h4', '.info-card p', '.step-item h4', '.step-item p',
            '.list-box h4', '.list-box p', '.table-like strong', '.table-like span',
            '.insight-box', '.mini-insight', '.agenda-text', '.agenda-output-card',
            '.speaker-role-card strong', '.speaker-proof-v2', '.speaker-cert',
            '.speaker-metric span', '.speaker-method-track strong', '.speaker-method-track em',
            '.semantic-caption', '.chapter-business-caption'
          ].join(',');
          const overflows = [...root.querySelectorAll(overflowSelector)]
            .filter(element => visible(element) && element.textContent.trim())
            .filter(element => element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2)
            .map(element => ({tag: element.tagName, text: element.textContent.trim().slice(0, 80)}));
          const fontChecks = [
            ['.slide-title', args.thresholds.title, 'title'],
            ['.slide-subtitle', args.thresholds.subtitle, 'subtitle'],
            ['.info-card p,.step-item p,.list-box p,.table-like span', args.thresholds.body, 'body'],
            ['.semantic-caption,.chapter-business-caption', args.thresholds.caption, 'caption']
          ];
          const lowFonts = fontChecks.flatMap(([selector, minimum, role]) =>
            [...root.querySelectorAll(selector)]
              .filter(element => visible(element) && element.textContent.trim())
              .filter(element => px(element) + 0.01 < minimum)
              .map(element => ({role, minimum, actual: px(element), text: element.textContent.trim().slice(0, 80)}))
          );
          const lowSvgFonts = [...root.querySelectorAll('svg text')]
            .filter(element => visible(element) && element.textContent.trim())
            .flatMap(element => {
              const matrix = element.getScreenCTM();
              if (!matrix) return [];
              const actual = px(element) * Math.hypot(matrix.a, matrix.b);
              return actual + 0.01 < args.thresholds.svg
                ? [{minimum: args.thresholds.svg, actual, text: element.textContent.trim().slice(0, 80)}]
                : [];
            });
          const svgTextOutside = [];
          const svgTextClipped = [];
          [...root.querySelectorAll('svg text')].forEach(element => {
            const svg = element.ownerSVGElement;
            if (!svg || !visible(element)) return;
            const box = element.getBoundingClientRect();
            const canvas = svg.getBoundingClientRect();
            const margin = 2;
            const outside = box.left < canvas.left - margin || box.top < canvas.top - margin ||
              box.right > canvas.right + margin || box.bottom > canvas.bottom + margin;
            if (!outside) return;
            const finding = {
              text: element.textContent.trim().slice(0, 80),
              box: {left:box.left,top:box.top,right:box.right,bottom:box.bottom}
            };
            svgTextOutside.push(finding);
            // SVG glyph bounds can extend slightly beyond the SVG viewport while
            // remaining visible. Only HTML clipping ancestors indicate real loss.
            let ancestor = svg.parentElement;
            while (ancestor && ancestor !== root.parentElement) {
              const style = getComputedStyle(ancestor);
              const clipsX = ['hidden', 'clip'].includes(style.overflowX);
              const clipsY = ['hidden', 'clip'].includes(style.overflowY);
              if (clipsX || clipsY) {
                const frame = ancestor.getBoundingClientRect();
                const clipped = (clipsX && (box.left < frame.left - margin || box.right > frame.right + margin)) ||
                  (clipsY && (box.top < frame.top - margin || box.bottom > frame.bottom + margin));
                if (clipped) {
                  const className = typeof ancestor.className === 'string' ? ancestor.className : '';
                  svgTextClipped.push({...finding, ancestor: className || ancestor.tagName});
                  break;
                }
              }
              ancestor = ancestor.parentElement;
            }
          });
          const imageFailures = [...root.querySelectorAll('img')]
            .filter(image => visible(image) && (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0))
            .map(image => image.currentSrc || image.src || image.alt || 'unknown image');
          const visuals = [...root.querySelectorAll(args.visualSelector)].filter(visible).map(visual => {
            const rect = visual.getBoundingClientRect();
            const svg = visual.querySelector('svg');
            const svgRect = svg?.getBoundingClientRect();
            return {
              type: visual.dataset.visualType || '',
              question: visual.dataset.businessQuestion || '',
              business: visual.classList.contains('business-visual'),
              mainAudit: visual.classList.contains('semantic-visual') || visual.classList.contains('business-visual'),
              widthRatio: bodyRect.width ? rect.width / bodyRect.width : 0,
              svgFillWidth: svgRect && rect.width ? svgRect.width / rect.width : null,
              svgFillHeight: svgRect && rect.height ? svgRect.height / rect.height : null
            };
          });
          return {
            bodyOverflow: {
              x: Math.max(0, body.scrollWidth - body.clientWidth),
              y: Math.max(0, body.scrollHeight - body.clientHeight)
            },
            overflows,
            lowFonts,
            lowSvgFonts,
            svgTextOutside,
            svgTextClipped,
            imageFailures,
            visuals
          };
        }""",
        {"bodySelector": body_selector, "visualSelector": visual_selector, "thresholds": thresholds},
    )


def record_findings(issues: list[dict], result: dict, mode: str, number: int) -> None:
    body_overflow = result["bodyOverflow"]
    if body_overflow["x"] > 8 or body_overflow["y"] > 8:
        add_issue(issues, mode, number, "P0", "body-overflow", f"Slide body overflows by x={body_overflow['x']}px, y={body_overflow['y']}px")
    if result["overflows"]:
        sample = result["overflows"][:3]
        add_issue(issues, mode, number, "P0", "text-overflow", f"Visible text overflow: {sample}")
    if result["imageFailures"]:
        add_issue(issues, mode, number, "P0", "image-failure", f"Images failed to load: {result['imageFailures'][:3]}")
    if result["svgTextClipped"]:
        add_issue(issues, mode, number, "P0", "svg-text-clipped", f"SVG text crosses a clipping boundary: {result['svgTextClipped'][:3]}")
    elif result["svgTextOutside"]:
        add_issue(issues, mode, number, "P2", "svg-text-outside", f"SVG text extends beyond its canvas but remains visible: {result['svgTextOutside'][:3]}")
    if result["lowFonts"]:
        add_issue(issues, mode, number, "P1", "low-font", f"HTML text below mode threshold: {result['lowFonts'][:4]}")
    if result["lowSvgFonts"]:
        severity = "P2" if mode in {"mobile", "print"} else "P1"
        add_issue(issues, mode, number, severity, "low-svg-font", f"SVG effective text below mode threshold: {result['lowSvgFonts'][:4]}")
    for visual in result["visuals"]:
        if visual["business"] and not visual["question"]:
            add_issue(issues, mode, number, "P1", "missing-business-question", "Business visual has no data-business-question")
        if mode == "fullscreen" and visual["mainAudit"] and visual["widthRatio"] < 0.43:
            add_issue(issues, mode, number, "P1", "small-main-visual", f"Visual width is only {visual['widthRatio']:.1%} of the body")
        if visual["svgFillWidth"] is not None and visual["svgFillWidth"] < 0.65:
            add_issue(issues, mode, number, "P2", "small-svg-in-frame", f"SVG uses only {visual['svgFillWidth']:.1%} of its frame width")


def screenshot_slide(root, path: Path) -> bool:
    try:
        root.screenshot(path=str(path), animations="disabled")
        return True
    except Exception:
        return False


def make_contact_sheets(paths: list[Path], output_dir: Path, mode: str, per_sheet: int = 21) -> list[Path]:
    results: list[Path] = []
    for chunk_index in range(math.ceil(len(paths) / per_sheet)):
        chunk = paths[chunk_index * per_sheet : (chunk_index + 1) * per_sheet]
        thumbs = []
        for local_index, path in enumerate(chunk):
            image = Image.open(path).convert("RGB")
            image.thumbnail((320, 200))
            thumb = Image.new("RGB", (320, 200), "white")
            thumb.paste(image, ((320 - image.width) // 2, (200 - image.height) // 2))
            draw = ImageDraw.Draw(thumb)
            number = chunk_index * per_sheet + local_index + 1
            draw.rectangle((0, 0, 58, 25), fill=(21, 54, 109))
            draw.text((8, 5), f"P{number:02d}", fill="white")
            thumbs.append(thumb)
        columns = min(7, len(thumbs)) or 1
        rows = math.ceil(len(thumbs) / columns)
        sheet = Image.new("RGB", (columns * 320, rows * 200), "white")
        for index, thumb in enumerate(thumbs):
            sheet.paste(thumb, ((index % columns) * 320, (index // columns) * 200))
        start = chunk_index * per_sheet + 1
        end = start + len(chunk) - 1
        path = output_dir / f"contact-{mode}-{start:02d}-{end:02d}.jpg"
        sheet.save(path, quality=92)
        results.append(path)
    return results


def write_reports(output_dir: Path, report: dict) -> None:
    (output_dir / "audit-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    counts = Counter(issue["severity"] for issue in report["issues"])
    lines = [
        "# 课件配图自动审计报告",
        "",
        f"- URL：{report['url']}",
        f"- 页数：{report['slide_count']}",
        f"- 模式：{', '.join(report['modes'])}",
        f"- 结果：P0 {counts['P0']}，P1 {counts['P1']}，P2 {counts['P2']}",
        f"- 视觉类型：{', '.join(report['visual_types']) or '未识别'}",
        "",
        "## 问题",
        "",
        "| 严重度 | 模式 | 页码 | 类型 | 说明 |",
        "|---|---|---:|---|---|",
    ]
    if report["issues"]:
        for issue in report["issues"]:
            message = issue["message"].replace("|", "\\|").replace("\n", " ")
            lines.append(f"| {issue['severity']} | {issue['mode']} | {issue['page'] or '-'} | {issue['code']} | {message} |")
    else:
        lines.append("| - | - | - | - | 自动检查未发现问题；仍需人工扫描联系表与异常页原图。 |")
    lines.extend(["", "## 证据", ""])
    for mode, evidence in report["evidence"].items():
        lines.append(f"- {mode}：{len(evidence['screenshots'])} 张截图；联系表 {', '.join(evidence['contact_sheets'])}")
    lines.extend(
        [
            "",
            "> 自动审计不能判断全部图文语义与观看舒适度。交付前必须人工扫描 100% 联系表，并放大复核报警页。",
            "",
        ]
    )
    (output_dir / "audit-report.md").write_text("\n".join(lines), encoding="utf-8")


def main() -> int:
    args = parse_args()
    modes = [mode.strip() for mode in args.modes.split(",") if mode.strip()]
    invalid_modes = [mode for mode in modes if mode not in MODE_CONFIG]
    if invalid_modes:
        raise SystemExit(f"Unsupported modes: {', '.join(invalid_modes)}")
    output_dir = Path(args.output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)
    issues: list[dict] = []
    evidence: dict[str, dict] = {}
    visual_types: set[str] = set()
    canonical_slides: list[dict] | None = None

    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=True)
        for mode in modes:
            config = MODE_CONFIG[mode]
            page = browser.new_page(viewport=config["viewport"], device_scale_factor=1)
            page.set_default_timeout(args.timeout_ms)
            console_errors: list[str] = []
            failed_requests: list[str] = []
            page.on("console", lambda message, bucket=console_errors: bucket.append(message.text) if message.type == "error" else None)
            page.on("requestfailed", lambda request, bucket=failed_requests: bucket.append(f"{request.url}: {request.failure}"))
            page.goto(args.url, wait_until="networkidle")
            page.emulate_media(media=config["media"])
            slides = discover_slides(page, args.slide_selector)
            ensure_slide_ids(page, args.slide_selector, slides)
            slides = discover_slides(page, args.slide_selector)
            canonical_slides = canonical_slides or slides
            if len(slides) != len(canonical_slides):
                add_issue(issues, mode, None, "P0", "slide-count-shift", f"Mode found {len(slides)} slides; expected {len(canonical_slides)}")
            true_fullscreen = mode == "fullscreen" and bool(slides) and enter_fullscreen(page, slides[0]["id"])
            mode_dir = output_dir / mode
            mode_dir.mkdir(parents=True, exist_ok=True)
            screenshot_paths: list[Path] = []
            previous_type = None
            repeated_type_run = 0
            for index, item in enumerate(slides):
                number = slide_number(item, index)
                go_to_slide(page, item["id"])
                root = choose_root(page, item["id"], args.slide_selector, true_fullscreen)
                if root is None:
                    add_issue(issues, mode, number, "P0", "slide-not-visible", f"Could not render slide {item['id']}")
                    continue
                result = audit_root(root, args.body_selector, args.visual_selector, mode)
                record_findings(issues, result, mode, number)
                page_types = [visual["type"] for visual in result["visuals"] if visual["type"]]
                visual_types.update(page_types)
                business_types = [
                    visual["type"]
                    for visual in result["visuals"]
                    if visual["type"] and visual["business"] and visual["type"] not in REPETITION_EXEMPT_TYPES
                ]
                primary_type = business_types[0] if business_types else None
                if primary_type and primary_type == previous_type:
                    repeated_type_run += 1
                    if repeated_type_run >= 3:
                        add_issue(issues, mode, number, "P2", "repeated-visual-template", f"Visual type '{primary_type}' repeats for three or more consecutive slides")
                else:
                    repeated_type_run = 1 if primary_type else 0
                previous_type = primary_type
                shot = mode_dir / f"{number:03d}-{item['id']}.png"
                if screenshot_slide(root, shot):
                    screenshot_paths.append(shot)
                else:
                    add_issue(issues, mode, number, "P0", "screenshot-failed", f"Could not capture slide {item['id']}")
            if console_errors:
                add_issue(issues, mode, None, "P0", "console-error", f"Console errors: {console_errors[:5]}")
            if failed_requests:
                add_issue(issues, mode, None, "P0", "request-failed", f"Failed requests: {failed_requests[:5]}")
            contacts = make_contact_sheets(screenshot_paths, output_dir, mode) if screenshot_paths else []
            evidence[mode] = {
                "screenshots": [str(path) for path in screenshot_paths],
                "contact_sheets": [str(path) for path in contacts],
                "true_fullscreen": true_fullscreen,
            }
            if mode == "fullscreen" and not true_fullscreen:
                add_issue(issues, mode, None, "P2", "fullscreen-fallback", "Deck has no supported fullscreen API; used the large viewport fallback")
            page.close()
        browser.close()

    slide_count = len(canonical_slides or [])
    if args.expected_slides is not None and slide_count != args.expected_slides:
        add_issue(issues, "all", None, "P0", "unexpected-slide-count", f"Found {slide_count} slides; expected {args.expected_slides}")
    if slide_count and len(visual_types) < args.min_visual_types:
        add_issue(issues, "all", None, "P2", "low-visual-variety", f"Only {len(visual_types)} visual types found; target is at least {args.min_visual_types}")

    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "url": args.url,
        "slide_count": slide_count,
        "modes": modes,
        "visual_types": sorted(visual_types),
        "issues": issues,
        "evidence": evidence,
    }
    write_reports(output_dir, report)
    counts = Counter(issue["severity"] for issue in issues)
    print(f"Audited {slide_count} slides in {len(modes)} mode(s): P0={counts['P0']} P1={counts['P1']} P2={counts['P2']}")
    print(f"Report: {output_dir / 'audit-report.md'}")
    fail_on = {item.strip().upper() for item in args.fail_on.split(",") if item.strip()}
    return 1 if any(issue["severity"] in fail_on for issue in issues) else 0


if __name__ == "__main__":
    sys.exit(main())
