#!/usr/bin/env python3
"""Preflight checks for HTML-first slide decks.

This script is intentionally opinionated for the current skill:
- supports `index.html + slides/*.html` decks via `window.DECK_MANIFEST`
- supports single-slide HTML files
- supports `.slide`-based multi-slide files

Primary blockers:
1. title/content overlap
2. body/footer crowding
3. unintended content overlap
4. edge crowding for text-bearing blocks
5. text containers with too little inner padding for editable PPTX
6. stretched images or narrative footer captions that degrade editable PPTX exports

Severity model:
- blocker: should fail `--strict`
- warning: worth fixing, but not a hard stop
- allowed-overlap: intentional overlap explicitly whitelisted by the author
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

VIEWPORT_WIDTH = 1600
VIEWPORT_HEIGHT = 900

class PreflightError(RuntimeError):
    pass

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Layout preflight for HTML-first slide decks."
    )
    parser.add_argument("html", help="Path to deck index.html or slide HTML.")
    parser.add_argument(
        "--output",
        help="Optional JSON output path. Defaults to <html-stem>-preflight.json next to the source HTML.",
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help="Exit with code 2 when any blocking issue is found.",
    )
    parser.add_argument(
        "--editable-pptx",
        action="store_true",
        help="Use stricter slack thresholds intended for editable PPTX export.",
    )
    parser.add_argument(
        "--wait-ms",
        type=int,
        default=250,
        help="Extra settle time after load. Default: 250ms.",
    )
    return parser.parse_args()

def ensure_file(path: str) -> Path:
    resolved = Path(path).expanduser().resolve()
    if not resolved.is_file():
        raise PreflightError(f"HTML file not found: {resolved}")
    return resolved

def get_playwright():
    try:
        from playwright.sync_api import sync_playwright  # type: ignore

        return sync_playwright
    except ImportError as exc:
        raise PreflightError(
            "Playwright is required for deck preflight. "
            "Install it with `pip install playwright` and then run "
            "`playwright install chromium`."
        ) from exc

PRECHECK_JS = r"""
({ label, editablePptx }) => {
  const IGNORE_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT']);
  const DECORATIVE_HINTS = [
    'top-rule', 'eyebrow-line', 'divider', 'rule', 'ornament',
    'accent-line', 'progress-bar', 'guide-line', 'hairline'
  ];
  const HEADER_HINTS = [
    'kicker', 'eyebrow', 'title', 'subtitle', 'masthead',
    'chapter-head', 'section-head', 'lede'
  ];
  const FOOTER_HINTS = ['footer', 'page-footer', 'deck-footer', 'contentinfo'];
  const OVERLAP_TOLERANCE = editablePptx ? 4 : 2;
  const TITLE_CLEARANCE = editablePptx ? 28 : 12;
  const INSET_THRESHOLDS = editablePptx
    ? { top: 16, right: 20, bottom: 28, left: 20, hard: 10 }
    : { top: 8, right: 10, bottom: 12, left: 10, hard: 2 };
  const IMAGE_RATIO_THRESHOLD = editablePptx ? 0.08 : 0.12;

  const visible = (el) => {
    const style = window.getComputedStyle(el);
    if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
      return false;
    }
    const rect = el.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  };

  const textOf = (el) => (el.innerText || '').replace(/\s+/g, ' ').trim();
  const meaningfulTextLength = (el) => textOf(el).replace(/\s+/g, '').length;
  const boolAttr = (value) => value === '' || value === 'true' || value === '1' || value === 'required';

  const rectOf = (el) => {
    const rect = el.getBoundingClientRect();
    return {
      left: rect.left,
      top: rect.top,
      right: rect.right,
      bottom: rect.bottom,
      width: rect.width,
      height: rect.height,
    };
  };

  const overlaps = (a, b, padding = 0) => {
    const left = Math.max(a.left - padding, b.left - padding);
    const top = Math.max(a.top - padding, b.top - padding);
    const right = Math.min(a.right + padding, b.right + padding);
    const bottom = Math.min(a.bottom + padding, b.bottom + padding);
    return {
      hit: right - left > 0 && bottom - top > 0,
      left,
      top,
      right,
      bottom,
      width: Math.max(0, right - left),
      height: Math.max(0, bottom - top),
    };
  };

  const unionRects = (rects) => {
    if (!rects.length) return null;
    return rects.reduce((acc, rect) => ({
      left: Math.min(acc.left, rect.left),
      top: Math.min(acc.top, rect.top),
      right: Math.max(acc.right, rect.right),
      bottom: Math.max(acc.bottom, rect.bottom),
      width: Math.max(acc.right, rect.right) - Math.min(acc.left, rect.left),
      height: Math.max(acc.bottom, rect.bottom) - Math.min(acc.top, rect.top),
    }));
  };

  const describe = (el) => {
    const cls = typeof el.className === 'string' ? el.className.trim().replace(/\s+/g, '.') : '';
    if (cls) return `${el.tagName.toLowerCase()}.${cls}`;
    return el.tagName.toLowerCase();
  };

  const isPageNumberLike = (text) => {
    if (!text) return false;
    const normalized = text.replace(/\s+/g, ' ').trim();
    return /^\d+(\s*\/\s*\d+)?$/.test(normalized);
  };

  const hasContainerChrome = (el) => {
    const style = window.getComputedStyle(el);
    const bg = style.backgroundColor;
    const hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
    const borderWidths = [
      style.borderTopWidth,
      style.borderRightWidth,
      style.borderBottomWidth,
      style.borderLeftWidth,
    ].map((value) => parseFloat(value) || 0);
    const hasBorder = borderWidths.some((value) => value > 0);
    const hasRadius = (parseFloat(style.borderTopLeftRadius) || 0) > 0;
    const hasShadow = style.boxShadow && style.boxShadow !== 'none';
    return hasBg || hasBorder || hasRadius || hasShadow;
  };

  const getTextRect = (el) => {
    const textNodes = Array.from(
      el.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote')
    ).filter((node) => visible(node));
    if (!textNodes.length) return null;
    return unionRects(textNodes.map((node) => rectOf(node)));
  };

  const isDecorative = (el) => {
    const cls = typeof el.className === 'string' ? el.className : '';
    if (DECORATIVE_HINTS.some((hint) => cls.includes(hint))) return true;
    const rect = el.getBoundingClientRect();
    const text = textOf(el);
    if (!text && (rect.height <= 8 || rect.width <= 8)) return true;
    if (!text && rect.height <= 4) return true;
    return false;
  };

  const isFooterLike = (el, rootRect) => {
    const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
    const role = (el.getAttribute('role') || '').toLowerCase();
    if (FOOTER_HINTS.some((hint) => cls.includes(hint)) || role === 'contentinfo') {
      return true;
    }
    const rect = el.getBoundingClientRect();
    return (
      rect.top >= rootRect.bottom - Math.max(72, rootRect.height * 0.16) &&
      rect.width >= rootRect.width * 0.3 &&
      rect.height <= rootRect.height * 0.14
    );
  };

  const isHeaderLike = (el) => {
    const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
    if (HEADER_HINTS.some((hint) => cls.includes(hint))) return true;
    if (el.matches('h1, h2')) return true;
    const directHeading = Array.from(el.children).some((child) => child.matches && child.matches('h1, h2'));
    if (directHeading && !hasContainerChrome(el)) return true;
    return false;
  };

  const allowsOverlap = (el) => (
    el.hasAttribute('data-allow-overlap') ||
    el.classList.contains('allow-overlap') ||
    el.classList.contains('decorative-overlap')
  );

  const overlapIntent = (el) => {
    if (el.classList.contains('decorative-overlap')) return 'decorative';
    if (!el.hasAttribute('data-allow-overlap')) {
      return el.classList.contains('allow-overlap') ? 'generic' : '';
    }
    const value = (el.getAttribute('data-allow-overlap') || '').trim().toLowerCase();
    return value || 'generic';
  };

  const canUseOverlapWhitelist = (el) => {
    if (!allowsOverlap(el)) return false;
    const intent = overlapIntent(el);
    const textLen = meaningfulTextLength(el);
    const textRect = getTextRect(el);
    const hasText = textLen >= 8 || !!textRect;
    const hasChrome = hasContainerChrome(el);
    if (intent === 'decorative') {
      return !hasText;
    }
    return !hasText && !hasChrome;
  };

  const overlapSeverity = (hit, allowed) => {
    if (allowed) return 'allowed-overlap';
    if (hit.width < OVERLAP_TOLERANCE || hit.height < OVERLAP_TOLERANCE) return 'warning';
    return 'blocker';
  };

  const inspectRoot = (root, rootIndex) => {
    const rasterIllustrationsRequired = (() => {
      const rootAttr = (root.getAttribute('data-raster-illustrations') || '').trim().toLowerCase();
      const bodyAttr = (document.body.getAttribute('data-raster-illustrations') || '').trim().toLowerCase();
      return boolAttr(rootAttr) || boolAttr(bodyAttr);
    })();
    const svgIllustrationFallbackAllowed = (() => {
      const rootAttr = (root.getAttribute('data-illustration-fallback') || '').trim().toLowerCase();
      const bodyAttr = (document.body.getAttribute('data-illustration-fallback') || '').trim().toLowerCase();
      return rootAttr === 'svg' || bodyAttr === 'svg';
    })();

    const rootBounds = rectOf(root);
    const rootRect = root === document.body
      ? {
          left: 0,
          top: 0,
          right: window.innerWidth,
          bottom: window.innerHeight,
          width: window.innerWidth,
          height: window.innerHeight,
        }
      : rootBounds;
    const layoutRect = (rootBounds.width > 0 && rootBounds.height > 0)
      ? rootBounds
      : rootRect;

    const children = Array.from(root.children)
      .filter((el) => !IGNORE_TAGS.has(el.tagName))
      .filter((el) => visible(el))
      .filter((el) => !isDecorative(el));

    const blocks = children.map((el) => ({
      el,
      label: describe(el),
      rect: rectOf(el),
      text: textOf(el),
    }));

    const footerBlock = blocks.find((block) => isFooterLike(block.el, rootRect)) || null;
    const headerBlocks = blocks.filter((block) => block !== footerBlock && isHeaderLike(block.el));
    const contentBlocks = blocks.filter((block) => block !== footerBlock && !headerBlocks.includes(block));

    const issues = [];

    for (const block of blocks) {
      if (!allowsOverlap(block.el)) continue;
      if (canUseOverlapWhitelist(block.el)) continue;
      issues.push({
        type: 'overlap-whitelist-misuse',
        message: `${block.label} uses overlap whitelist on a text-bearing or layout container. Restrict overlap exceptions to decorative elements only.`,
        element: block.label,
        rect: block.rect,
        severity: 'blocker',
      });
    }

    if (rasterIllustrationsRequired) {
      const visibleImages = Array.from(root.querySelectorAll('img')).filter((img) => visible(img));
      const visibleIllustrationSvgs = Array.from(root.querySelectorAll('svg'))
        .filter((svg) => visible(svg))
        .filter((svg) => {
          const rect = svg.getBoundingClientRect();
          if (rect.width < 48 || rect.height < 48) return false;
          const primitiveCount = svg.querySelectorAll('path, circle, rect, ellipse, polygon, polyline, line').length;
          return primitiveCount >= 4;
        });

      if (!visibleImages.length && !(svgIllustrationFallbackAllowed && visibleIllustrationSvgs.length)) {
        issues.push({
          type: 'missing-raster-illustration',
          message: 'This slide requires raster illustrations, but no visible <img> assets were found. Search or generate real images and place them as <img> elements.',
          severity: 'blocker',
        });
      }

      if (!visibleImages.length && visibleIllustrationSvgs.length) {
        if (svgIllustrationFallbackAllowed) {
          issues.push({
            type: 'svg-illustration-fallback',
            message: 'SVG illustration fallback is in use. Prefer searched photos or AI-generated raster images first; keep this SVG only as an explicit fallback.',
            severity: 'warning',
          });
        } else {
          issues.push({
            type: 'svg-illustration-fallback',
            message: 'Raster illustrations are required here, but the slide appears to rely on SVG-drawn imagery instead. Prefer searched photos or AI-generated raster images over hand-drawn SVG stand-ins.',
            severity: 'blocker',
          });
        }
      }
    }

    const titleRect = unionRects(headerBlocks.map((block) => block.rect));
    if (titleRect) {
      const titleSafetyBand = {
        left: rootRect.left,
        top: titleRect.top,
        right: rootRect.right,
        bottom: titleRect.bottom + TITLE_CLEARANCE,
      };
      for (const block of contentBlocks) {
        const hit = overlaps(titleRect, block.rect, 0);
        if (hit.hit) {
          const severity = overlapSeverity(hit, canUseOverlapWhitelist(block.el));
          issues.push({
            type: 'title-overlap',
            message: `${block.label} overlaps the title/subtitle band.`,
            element: block.label,
            rect: block.rect,
            severity,
            intersection: {
              left: hit.left,
              top: hit.top,
              width: hit.width,
              height: hit.height,
            },
          });
        }

        if (
          editablePptx &&
          (block.text || hasContainerChrome(block.el)) &&
          block.rect.top < titleSafetyBand.bottom
        ) {
          const clearance = Math.round(block.rect.top - titleRect.bottom);
          issues.push({
            type: 'title-clearance-tight',
            message: `${block.label} starts only ${clearance}px below the title band; editable PPTX needs at least ${TITLE_CLEARANCE}px of vertical clearance.`,
            element: block.label,
            rect: block.rect,
            clearance,
            requiredClearance: TITLE_CLEARANCE,
            severity: clearance <= 0 ? 'blocker' : 'warning',
          });
        }
      }
    }

    const blocksForOverlap = headerBlocks.concat(contentBlocks);
    for (let i = 0; i < blocksForOverlap.length; i += 1) {
      for (let j = i + 1; j < blocksForOverlap.length; j += 1) {
        const a = blocksForOverlap[i];
        const b = blocksForOverlap[j];
        if (a === b) continue;
        const hit = overlaps(a.rect, b.rect, 0);
        if (!hit.hit) continue;
        const alreadyCovered = (
          (headerBlocks.includes(a) && contentBlocks.includes(b)) ||
          (headerBlocks.includes(b) && contentBlocks.includes(a))
        );
        if (alreadyCovered) continue;
        const severity = overlapSeverity(
          hit,
          canUseOverlapWhitelist(a.el) || canUseOverlapWhitelist(b.el)
        );
        issues.push({
          type: 'content-overlap',
          message: `${a.label} overlaps ${b.label}.`,
          elements: [a.label, b.label],
          severity,
          intersection: {
            left: hit.left,
            top: hit.top,
            width: hit.width,
            height: hit.height,
          },
        });
      }
    }

    const footerBandTop = footerBlock
      ? footerBlock.rect.top - (editablePptx ? 24 : 16)
      : rootRect.bottom - (editablePptx ? 42 : 28);
    for (const block of headerBlocks.concat(contentBlocks)) {
      if (block.rect.bottom > footerBandTop) {
        issues.push({
          type: 'footer-crowding',
          message: `${block.label} intrudes into the footer safety band.`,
          element: block.label,
          rect: block.rect,
          footerBandTop,
          severity: 'blocker',
        });
      }
    }

    if (editablePptx) {
      const footerTextBlocks = blocks.filter((block) => {
        if (!block.text || isPageNumberLike(block.text)) return false;
        if (!isFooterLike(block.el, layoutRect)) return false;
        if (block.text.length < 18) return false;
        if (
          block.el.hasAttribute('data-allow-footer-caption') ||
          block.el.classList.contains('allow-footer-caption')
        ) {
          return false;
        }
        return true;
      });
      for (const block of footerTextBlocks) {
        issues.push({
          type: 'footer-caption',
          message: `${block.label} adds narrative footer text; move it into the main layout or remove it for editable PPTX.`,
          element: block.label,
          rect: block.rect,
          severity: 'blocker',
        });
      }
    }

    const safeInsets = {
      left: Math.max(48, rootRect.width * 0.045),
      right: Math.max(48, rootRect.width * 0.045),
      top: Math.max(36, rootRect.height * 0.05),
      bottom: Math.max(36, rootRect.height * 0.05),
    };
    for (const block of headerBlocks.concat(contentBlocks)) {
      if (!block.text) continue;
      const crowded = (
        block.rect.left < rootRect.left + safeInsets.left ||
        block.rect.right > rootRect.right - safeInsets.right ||
        block.rect.top < rootRect.top + safeInsets.top ||
        block.rect.bottom > rootRect.bottom - safeInsets.bottom
      );
      if (crowded) {
        issues.push({
          type: 'edge-crowding',
          message: `${block.label} is too close to the slide edge for a stable export.`,
          element: block.label,
          rect: block.rect,
          safeInsets,
          severity: 'warning',
        });
      }
    }

    const tightThreshold = editablePptx ? 24 : 8;
    for (const block of contentBlocks) {
      if (!block.text) continue;
      const textRect = getTextRect(block.el);
      if (!textRect) continue;
      const bottomSlack = block.rect.bottom - textRect.bottom;
      if (bottomSlack < tightThreshold) {
        const severity = bottomSlack <= 0 ? 'blocker' : 'warning';
        issues.push({
          type: 'text-tight',
          message: `${block.label} has only ${Math.round(bottomSlack)}px bottom slack; editable PPT may overflow.`,
          element: block.label,
          rect: block.rect,
          bottomSlack: Math.round(bottomSlack),
          severity,
        });
      }

      if (!hasContainerChrome(block.el)) continue;
      const slacks = {
        top: textRect.top - block.rect.top,
        right: block.rect.right - textRect.right,
        bottom: block.rect.bottom - textRect.bottom,
        left: textRect.left - block.rect.left,
      };
      const insetProblems = Object.entries(slacks)
        .filter(([side, value]) => value < INSET_THRESHOLDS[side])
        .map(([side, value]) => `${side} ${Math.round(value)}px`);
      if (insetProblems.length) {
        const minSlack = Math.min(slacks.top, slacks.right, slacks.bottom, slacks.left);
        issues.push({
          type: 'text-inset-tight',
          message: `${block.label} leaves too little inner padding (${insetProblems.join(', ')}); PowerPoint text metrics may clip the card.`,
          element: block.label,
          rect: block.rect,
          slacks: Object.fromEntries(
            Object.entries(slacks).map(([side, value]) => [side, Math.round(value)])
          ),
          severity: minSlack <= INSET_THRESHOLDS.hard ? 'blocker' : 'warning',
        });
      }
    }

    for (const img of Array.from(root.querySelectorAll('img')).filter((node) => visible(node))) {
      if (
        img.hasAttribute('data-allow-distort') ||
        img.dataset.pptxFit === 'fill'
      ) {
        continue;
      }
      const naturalWidth = img.naturalWidth || 0;
      const naturalHeight = img.naturalHeight || 0;
      if (!naturalWidth || !naturalHeight) continue;
      const rect = rectOf(img);
      if (!rect.width || !rect.height) continue;
      const computed = window.getComputedStyle(img);
      const objectFit = computed.objectFit || 'fill';
      const naturalRatio = naturalWidth / naturalHeight;
      const renderedRatio = rect.width / rect.height;
      const ratioDelta = Math.abs(naturalRatio - renderedRatio) / naturalRatio;
      if (objectFit === 'fill' && ratioDelta > IMAGE_RATIO_THRESHOLD) {
        issues.push({
          type: 'image-aspect-distortion',
          message: `${describe(img)} stretches from ${naturalRatio.toFixed(2)} to ${renderedRatio.toFixed(2)}. Use object-fit: cover/contain or resize the frame.`,
          element: describe(img),
          rect,
          naturalRatio: Number(naturalRatio.toFixed(3)),
          renderedRatio: Number(renderedRatio.toFixed(3)),
          severity: 'blocker',
        });
      }
    }

    return {
      slideLabel:
        root.getAttribute('data-title') ||
        root.getAttribute('aria-label') ||
        root.getAttribute('id') ||
        `${label || document.title || 'slide'}#${rootIndex + 1}`,
      viewport: { width: rootRect.width, height: rootRect.height },
      issueCount: issues.length,
      issues,
    };
  };

  const slideRoots = Array.from(document.querySelectorAll('.slide'));
  const roots = slideRoots.length ? slideRoots : [document.body];
  return roots.map((root, index) => inspectRoot(root, index));
}
"""

def resolve_targets(page, source_html: Path, wait_ms: int) -> list[dict[str, str]]:
    page.goto(source_html.as_uri(), wait_until="load")
    page.wait_for_timeout(wait_ms)
    manifest = page.evaluate(
        """() => {
            if (!Array.isArray(window.DECK_MANIFEST)) return null;
            return window.DECK_MANIFEST
              .map((item) => {
                if (typeof item === 'string') return { file: item, label: item };
                if (!item || !item.file) return null;
                return { file: item.file, label: item.label || item.file };
              })
              .filter(Boolean);
        }"""
    )
    if not manifest:
        return [{"file": str(source_html), "label": source_html.name}]

    resolved_targets: list[dict[str, str]] = []
    for item in manifest:
        relative_file = item["file"]
        slide_path = (source_html.parent / relative_file).resolve()
        if slide_path.is_file():
            resolved_targets.append({"file": str(slide_path), "label": item["label"]})
    if not resolved_targets:
        return [{"file": str(source_html), "label": source_html.name}]
    return resolved_targets

def build_report(source_html: Path, results: list[dict], output_path: Path) -> dict:
    total_issues = sum(item["issueCount"] for item in results)
    severity_counts: dict[str, int] = {"blocker": 0, "warning": 0, "allowed-overlap": 0}
    issue_types_by_severity: dict[str, list[str]] = {
        "blocker": [],
        "warning": [],
        "allowed-overlap": [],
    }
    for item in results:
        for issue in item["issues"]:
            severity = issue.get("severity", "blocker")
            severity_counts[severity] = severity_counts.get(severity, 0) + 1

    for severity in issue_types_by_severity:
        issue_types_by_severity[severity] = sorted(
            {
                issue["type"]
                for item in results
                for issue in item["issues"]
                if issue.get("severity", "blocker") == severity
            }
        )

    blocking_types = sorted(
        {
            issue["type"]
            for item in results
            for issue in item["issues"]
            if issue.get("severity", "blocker") == "blocker"
            }
    )
    return {
        "source_html": str(source_html),
        "report_path": str(output_path),
        "slides_checked": len(results),
        "total_issues": total_issues,
        "blocker_count": severity_counts.get("blocker", 0),
        "warning_count": severity_counts.get("warning", 0),
        "allowed_overlap_count": severity_counts.get("allowed-overlap", 0),
        "blocking_issue_types": blocking_types,
        "issue_types_by_severity": issue_types_by_severity,
        "slides": results,
    }

def print_summary(report: dict) -> None:
    print(f"Preflight source : {report['source_html']}")
    print(f"Report           : {report['report_path']}")
    print(f"Slides checked   : {report['slides_checked']}")
    print(f"Total issues     : {report['total_issues']}")
    print(f"Blockers         : {report['blocker_count']}")
    print(f"Warnings         : {report['warning_count']}")
    print(f"Allowed overlap  : {report['allowed_overlap_count']}")
    for slide in report["slides"]:
        if not slide["issueCount"]:
            continue
        print(f"- {slide['slideLabel']} · {slide['issueCount']} issue(s)")
        for issue in slide["issues"]:
            severity = issue.get("severity", "blocker")
            print(f"  [{severity}] [{issue['type']}] {issue['message']}")

def main() -> int:
    args = parse_args()
    source_html = ensure_file(args.html)
    output_path = (
        Path(args.output).expanduser().resolve()
        if args.output
        else source_html.with_name(f"{source_html.stem}-preflight.json")
    )
    output_path.parent.mkdir(parents=True, exist_ok=True)

    sync_playwright = get_playwright()
    results: list[dict] = []

    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=True)
        context = browser.new_context(
            viewport={"width": VIEWPORT_WIDTH, "height": VIEWPORT_HEIGHT}
        )
        page = context.new_page()
        targets = resolve_targets(page, source_html, args.wait_ms)

        for target in targets:
            target_path = Path(target["file"]).resolve()
            page.goto(target_path.as_uri(), wait_until="load")
            page.wait_for_timeout(args.wait_ms)
            slide_results = page.evaluate(
                PRECHECK_JS,
                {
                    "label": target["label"],
                    "editablePptx": args.editable_pptx,
                },
            )
            for slide_result in slide_results:
                slide_result["source"] = str(target_path)
                results.append(slide_result)

        browser.close()

    report = build_report(source_html, results, output_path)
    output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    print_summary(report)

    if args.strict and report["blocker_count"]:
        return 2
    return 0

if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except PreflightError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise SystemExit(1)
