#!/usr/bin/env python3
"""
content-compare — diff the WordPress rebuild (brentwooddev) against a local copy
of the live site (the brentwood Laravel mirror) page by page, to catch subtle
human content-entry errors: changed text, different link text/targets, and the
WRONG IMAGE in the right spot.

Image strategy: images are aligned by ALT text first (the human usually keeps the
caption), then perceptual hash decides if the picture itself matches. So:
  - same alt + matching picture          -> fine
  - same alt + DIFFERENT picture         -> "REVIEW" (the wrong/re-cropped image)
  - live image with no counterpart       -> "missing in WP"
  - WP image with no counterpart          -> "extra in WP"
(filenames differ after import, so we never compare by URL.)

Both sides fetched LOCALLY (no gate / Cloudflare):
    WP   rebuild : http://172.17.0.1:3097   Host: brentwooddev.demoing.info
    LIVE mirror  : http://172.17.0.1:3082   Host: brentwood.demoing.info   (copy of brentwood.ca)
Only the main CONTENT region is compared (header / mega-menu / footer stripped).

Usage:
    compare.py --out reports/run1 /why-brentwood/ /admissions/ ...
    compare.py --out reports/run1 --pages-file pages.txt
Output: a self-contained HTML report per page + JSON, and index.html (worst-first).
"""
import sys, os, re, json, argparse, difflib, io, base64, traceback
from collections import defaultdict
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from PIL import Image
import imagehash

WP   = dict(base='http://172.17.0.1:3097', host='brentwooddev.demoing.info', sel='main')
LIVE = dict(base='http://172.17.0.1:3082', host='brentwood.demoing.info',    sel='#content')
LOCAL_HOSTS = {WP['host'], LIVE['host'], 'brentwood.ca', 'www.brentwood.ca'}
PHASH_SAME = 10        # <= this  => same image (content-matched, no alt needed)
PHASH_CLOSE = 18       # <= this  => close enough (recompressed/resized) -> OK
MEANINGFUL_PX = 120    # min side to call an image "content" vs an icon
ALT_MATCH = 0.85       # alt similarity to treat two images as the same intended slot
TIMEOUT = 30
DECORATIVE = re.compile(r'quote image|^\s*logo\s*$|brentwood logo|^icon|spacer|placeholder|arrow', re.I)
CHROME_TEXT = {'www.brentwood.ca', 'brentwood.ca', 'www.brentwood.ca/', ''}
_imgcache = {}

# Listing widgets that are NOT editorial page content and differ structurally between
# the two sites — photo galleries, related-post / blog-card grids, and the course-modal
# grids (anchor links on live, /courses/ URLs in the rebuild). Stripping the whole
# container removes their text, links AND images from the comparison in one pass.
GRID_GALLERY_SEL = (
    '[class*="gallery" i],[class*="masonry" i],[class*="carousel" i],'
    '[class*="slider" i],[class*="slideshow" i],[class*="lightbox" i],'
    '[class*="swiper" i],[class*="post-grid" i],[class*="postgrid" i],'
    '[class*="post-list" i],[class*="blog-grid" i],[class*="news-grid" i],'
    '[class*="card-grid" i],[class*="related-post" i],[class*="loop-entries" i],'
    '[class*="course" i],[class*="modal" i],'
    # instructor / staff "team modal" popups (bw-tm__*, bw-instructor__*) — the photo+bio
    # render inline in the rebuild but live keeps them outside #content, so they false-flag.
    '[class*="bw-tm" i],[class*="bw-instructor" i],[class*="team-modal" i],'
    '.wp-block-query,.wp-block-post-template,[class*="query-loop" i]'
)
# Link targets that are imported CPTs / in-page modal triggers, not page content to verify:
# blog/news/event/staff/livestream cards and course modals (#anchor on live, /courses/ in WP).
# Leading slash optional — live emits some of these as relative hrefs ("blogs/...").
SKIP_LINK = re.compile(r'^(#|/?(blogs?|courses?|staff|livestreams?|news|events?|people)(/|$))', re.I)
CPT_HREF  = re.compile(r'^/?(blogs?|courses?|staff|livestreams?|news|events?|people)(/|$)', re.I)

# ----------------------------------------------------------------------------- fetch
def fetch_page(cfg, path):
    url = cfg['base'].rstrip('/') + '/' + path.lstrip('/')
    s = requests.Session(); s.max_redirects = 5
    try:
        r = s.get(url, headers={'Host': cfg['host'], 'User-Agent': 'content-compare/1.0'},
                  timeout=TIMEOUT, allow_redirects=True)
        return r.status_code, r.text
    except requests.TooManyRedirects:
        return 599, ''
    except requests.RequestException:
        return 0, ''

def local_image_url(src):
    p = urlparse(src)
    q = ('?' + p.query) if p.query else ''
    if p.netloc == WP['host']:
        return WP['base'] + p.path + q, {'Host': WP['host']}
    if p.netloc in (LIVE['host'], 'brentwood.ca', 'www.brentwood.ca'):
        return LIVE['base'] + p.path + q, {'Host': LIVE['host']}
    return src, {}

def _get_image(src):
    url, headers = local_image_url(src)
    headers['User-Agent'] = 'content-compare/1.0'
    return requests.get(url, headers=headers, timeout=TIMEOUT).content

def fetch_image(src):
    if src in _imgcache:
        return _imgcache[src]
    out = (None, None)
    try:
        im = Image.open(io.BytesIO(_get_image(src))).convert('RGB')
        out = (imagehash.phash(im), im.size)
    except Exception:
        pass
    _imgcache[src] = out
    return out

def public_url(src):
    """Rewrite a local image URL to one that loads in a browser: live -> public
    brentwood.ca; WP stays brentwooddev.demoing.info (gated — the team has access)."""
    p = urlparse(src)
    q = ('?' + p.query) if p.query else ''
    if p.netloc in (LIVE['host'], 'brentwood.demoing.info'):
        return 'https://brentwood.ca' + p.path + q
    return src

# ----------------------------------------------------------------------------- extract
def content_node(html, cfg):
    soup = BeautifulSoup(html, 'lxml')
    node = soup.select_one(cfg['sel'])
    if node is None:
        return None
    # Kadence renders many CONTENT images as CSS background-image inside <style> blocks;
    # capture those URLs before we strip <style>, so they count as images (not false "missing").
    bgs = []
    _rx = re.compile(r"background-image\s*:[^;{}]*url\(\s*['\"]?([^)'\"]+?)['\"]?\s*\)", re.I)
    for st in soup.find_all('style'):
        bgs += _rx.findall(st.get_text() or '')
    for el in node.find_all(style=True):
        bgs += _rx.findall(el.get('style', ''))
    node.bg_imgs = bgs
    for t in node(['script', 'style', 'noscript', 'svg', 'header', 'footer', 'nav']):
        t.decompose()
    if cfg is WP:
        # strip the site-wide pre-footer CTA row (contact block + live timestamp) that
        # sits inside .entry-content on every page and would otherwise add junk to every diff
        for div in node.select('.wp-block-kadence-rowlayout'):
            tx = div.get_text(' ', strip=True)
            if 'Where Students Choose To Be' in tx or '743-5521' in tx:
                div.decompose()
    if cfg is LIVE:
        # strip the leading logo / breadcrumb link ("www.brentwood.ca")
        for a in node.find_all('a'):
            if a.get_text(strip=True).lower() in ('www.brentwood.ca', 'brentwood.ca'):
                a.decompose()
    # drop galleries / blog-post grids / course-modal grids (both sides)
    for el in node.select(GRID_GALLERY_SEL):
        el.decompose()
    strip_feeds(node)        # blog/news/course card grids (class-agnostic)
    strip_galleries(node)    # photo galleries / sliders (class-agnostic)
    return node

def strip_galleries(node):
    """Remove photo galleries / sliders / media grids: blocks that are image-dense and
    text-sparse. Class-agnostic (works on Kadence rebuild AND Tailwind live), so a JS
    carousel with only utility classes is still caught. Outermost match wins. A block that
    holds most of the page's text is the CONTENT itself (a visual landing page), not an
    embedded gallery — never strip that, or the page falsely reads as blank."""
    node_txt = len(node.get_text(' ', strip=True)) or 1
    cand = [el for el in node.find_all(['div', 'section', 'ul', 'figure'])
            if len(el.find_all('img')) >= 6
            and len(el.get_text(' ', strip=True)) / max(len(el.find_all('img')), 1) < 160
            and len(el.get_text(' ', strip=True)) < 0.5 * node_txt]
    cset = {id(c) for c in cand}
    outer = []
    for el in cand:
        p, nested = el.parent, False
        while p is not None:
            if id(p) in cset:
                nested = True; break
            p = p.parent
        if not nested:
            outer.append(el)
    for el in outer:
        el.decompose()

def strip_feeds(node):
    """Remove blog / news / course feed grids: a container whose links mostly point to
    imported CPTs. Strips the cards' text, thumbnails and links together."""
    node_txt = len(node.get_text(' ', strip=True)) or 1
    drop = []
    for el in node.find_all(['ul', 'div', 'section']):
        links = el.find_all('a')
        if len(links) < 3 or len(el.get_text(' ', strip=True)) >= 0.5 * node_txt:
            continue
        cpt = sum(1 for a in links if CPT_HREF.match(norm_href(a.get('href', '') or '')))
        if cpt >= 3 and cpt / len(links) >= 0.6:
            drop.append(el)
    for el in drop:
        if el.parent is not None:        # may already be gone via an ancestor
            el.decompose()

def norm_txt(s):
    return ' '.join((s or '').split())

def norm_href(href):
    if not href:
        return ''
    href = href.strip()
    if href.startswith(('#', 'mailto:', 'tel:', 'javascript:')):
        return href
    p = urlparse(href)
    path = (p.path or '/').rstrip('/') or '/'
    q = ('?' + p.query) if p.query else ''
    if p.netloc and p.netloc not in LOCAL_HOSTS:
        return p.netloc + path + q
    return path + q

def extract(node, host):
    text = norm_txt(node.get_text(' ', strip=True))
    links = []
    for a in node.find_all('a'):
        t = norm_txt(a.get_text(' ', strip=True))
        if not t and not a.find('img'):
            continue
        if t.lower() in CHROME_TEXT:
            continue
        href = norm_href(a.get('href', ''))
        if SKIP_LINK.match(href):
            continue                      # imported CPT / course-modal / in-page anchor
        links.append({'text': t, 'href': href})
    imgs = []
    for im in node.find_all('img'):
        src = im.get('src') or im.get('data-src') or im.get('data-lazy-src') or ''
        if not src or src.startswith('data:'):
            continue
        alt = norm_txt(im.get('alt', ''))
        if DECORATIVE.search(alt):
            continue
        imgs.append({'src': urljoin('https://' + host + '/', src), 'alt': alt, 'bg': False})
    for bsrc in getattr(node, 'bg_imgs', []):
        if not bsrc or bsrc.startswith('data:') or DECOR_FN.search(bsrc):
            continue
        imgs.append({'src': urljoin('https://' + host + '/', bsrc), 'alt': '', 'bg': True})
    return text, links, imgs

# ----------------------------------------------------------------------------- diffs
def _norm(s):
    """Lowercase, alphanumeric-only, single-spaced — for robust 'does this appear?' checks."""
    return ' '.join(re.sub(r'[^a-z0-9]+', ' ', (s or '').lower()).split())

def body_text(html):
    """Raw full visible text of the WHOLE page (used as the candidate pool / haystack so
    relocated or reordered content isn't flagged as a difference)."""
    soup = BeautifulSoup(html, 'lxml')
    for t in soup(['script', 'style', 'noscript']):
        t.decompose()
    return soup.get_text(' ', strip=True)

def _present(fragment, haystack_norm):
    f = _norm(fragment)
    return len(f) < 8 or f in haystack_norm     # too-short fragments are never "missing"

def _text_clean(s):
    for a, b in [('—', '-'), ('–', '-'), ('’', "'"), ('‘', "'"), ('“', '"'), ('”', '"'), ('…', '...')]:
        s = s.replace(a, b)
    s = re.sub(r'\b\d{1,4}p\b', ' ', s)         # live-only scroll/stat artifacts ("75p", "100p")
    return ' '.join(s.split())

def _sents(s):
    return [b.strip() for b in re.split(r'(?<=[.!?])\s+|\s{2,}|\n+', _text_clean(s))
            if len(b.strip()) > 20]

TYPO_LO = 0.80      # word-overlap band [TYPO_LO, 1.0) where a sentence is "the same but mistyped"

def _trim_common(a, b):
    """Strip the shared word prefix & suffix; return (core_a, core_b, prefix_len, suffix_len)."""
    i = 0
    while i < len(a) and i < len(b) and a[i] == b[i]:
        i += 1
    j = 0
    while j < len(a) - i and j < len(b) - i and a[-1 - j] == b[-1 - j]:
        j += 1
    return a[i:len(a) - j], b[i:len(b) - j], i, j

def diff_text(wp, live, wp_full='', live_full=''):
    """Catch human transcription errors only. Each live sentence is aligned to its closest
    sentence anywhere on the rebuild, then flagged ONLY when the difference is an internal
    SUBSTITUTION — sandwiched between a shared prefix and suffix, both sides non-empty, a few
    words at most. That's a typo / wrong word / wrong number. Reordered or relocated copy
    matches exactly (suppressed); a differing leading/trailing UI label is at the boundary
    (no shared prefix or suffix -> suppressed); paraphrases differ too much (suppressed)."""
    wpc, lvc = _text_clean(wp), _text_clean(live)
    ratio = round(difflib.SequenceMatcher(a=lvc.split(), b=wpc.split(), autojunk=False).ratio(), 3)
    pool = _sents(wp_full or wp)
    pool_w = [_norm(s).split() for s in pool]
    changes, seen = [], set()
    for ls in _sents(live):
        ln = _norm(ls).split()
        if len(ln) < 5:
            continue
        best, bn, bs = None, None, 0.0
        for ws, wn in zip(pool, pool_w):
            if not wn or abs(len(wn) - len(ln)) > max(3, len(ln) // 2):
                continue
            r = difflib.SequenceMatcher(a=ln, b=wn, autojunk=False).ratio()
            if r > bs:
                best, bn, bs = ws, wn, r
                if bs >= 0.999:
                    break
        if best is None or bs < TYPO_LO or bs >= 0.999 or best in seen:
            continue
        ca, cb, pre, suf = _trim_common(ln, bn)
        if pre > 0 and suf > 0 and ca and cb and max(len(ca), len(cb)) <= 6:
            seen.add(best)
            changes.append({'op': 'changed', 'live': ls[:300], 'wp': best[:300]})
    return ratio, changes

def diff_links(wp, live, wp_text='', live_text=''):
    """Compare links per-target, anchor-by-anchor. A link present on one side only is
    suppressed if its anchor text appears as PLAIN TEXT on the other side (same content,
    just markup/position differs — e.g. live shows 'Academics' as text, rebuild as a link)."""
    wh, lh = defaultdict(list), defaultdict(list)
    for l in wp:
        if l['text']:
            wh[l['href']].append(l['text'])
    for l in live:
        if l['text']:
            lh[l['href']].append(l['text'])
    wtl, ltl = _norm(wp_text), _norm(live_text)       # normalized FULL-page bodies (haystacks)
    findings = []
    for h in set(wh) | set(lh):
        if h in ('#', ''):
            continue
        lset = {t.lower(): t for t in lh.get(h, [])}
        wset = {t.lower(): t for t in wh.get(h, [])}
        if h not in wh:                                  # target only on live
            for k, t in lset.items():
                if not _present(t, wtl):
                    findings.append({'type': 'missing-in-wp', 'text': t[:80], 'live': h, 'wp': ''})
            continue
        if h not in lh:                                  # target only on rebuild
            for k, t in wset.items():
                if not _present(t, ltl):
                    findings.append({'type': 'extra-in-wp', 'text': t[:80], 'wp': h, 'live': ''})
            continue
        live_only = [lset[k] for k in lset if k not in wset]
        dev_only = [wset[k] for k in wset if k not in lset]
        for lt in live_only:                             # same target, anchor text changed?
            best, bs = None, 0.0
            for wt in dev_only:
                r = difflib.SequenceMatcher(a=lt.lower(), b=wt.lower()).ratio()
                if r > bs:
                    best, bs = wt, r
            if best is not None and bs >= 0.3:
                findings.append({'type': 'text-differs', 'text': lt[:60], 'live': lt[:90], 'wp': best[:90]})
                dev_only.remove(best)
            elif not _present(lt, wtl):
                findings.append({'type': 'missing-in-wp', 'text': lt[:80], 'live': h, 'wp': ''})
        for wt in dev_only:
            if not _present(wt, ltl):
                findings.append({'type': 'extra-in-wp', 'text': wt[:80], 'wp': h, 'live': ''})
    return findings

SIZE_TOK = re.compile(r'^\d+x\d+$')
IMG_STOP = {'small', 'large', 'medium', 'thumb', 'retina', 'full', 'scaled', 'photos', 'photo',
            'image', 'img', 'uploads', 'content', 'assets', 'blocks', 'themes', 'kadence', 'child',
            'images', 'web', 'compressed', 'edited', 'banner1'}
DECOR_FN = re.compile(r'logo|/dash|-dash|quote-start|quote-end|/icon|-icon|spacer|placeholder|/arrow|\.svg|/login\b|/login\.jpg|[-/]login\.', re.I)

def img_tokens(src):
    """Identity tokens from an image URL (photo id + base filename), size/ext-agnostic."""
    parts = urlparse(src).path.lower().split('/')
    fn = parts[-1] if parts else ''
    parent = parts[-2] if len(parts) >= 2 else ''
    raw = re.sub(r'\.(jpe?g|png|webp|gif|svg)', '', fn)
    toks = set()
    for t in re.split(r'[^a-z0-9]+', raw + '-' + parent):
        if not t or t in IMG_STOP or SIZE_TOK.match(t):
            continue
        if t.isdigit() and len(t) < 3:
            continue
        if len(t) >= 3:
            toks.add(t)
    return toks

def is_decorative(im):
    return bool(DECOR_FN.search(urlparse(im['src']).path)) or bool(DECORATIVE.search(im.get('alt', '')))

def diff_images(wp, live):
    """Match content images by filename tokens (reliable: import preserves the source id/name)."""
    wp = [w for w in wp if not is_decorative(w)]
    live = [l for l in live if not is_decorative(l)]
    for im in wp + live:
        im['toks'] = img_tokens(im['src'])
    findings, used, alt_missing = [], set(), 0
    for li in live:
        match = None
        for k, wi in enumerate(wp):
            if k in used:
                continue
            if li['toks'] and (li['toks'] & wi['toks']):
                match = k
                break
        if match is not None:
            wi = wp[match]; used.add(match)
            if li['alt'] and not wi['alt']:
                alt_missing += 1
                findings.append({'type': 'alt-missing', 'live_src': li['src'], 'wp_src': wi['src'],
                                 'live_alt': li['alt']})
        else:
            findings.append({'type': 'missing-in-wp', 'live_src': li['src'], 'live_alt': li['alt']})
    for k, wi in enumerate(wp):
        if k not in used and not wi.get('bg'):   # background images never reported as "extra" (template noise)
            findings.append({'type': 'extra-in-wp', 'wp_src': wi['src'], 'wp_alt': wi['alt']})
    return findings

# ----------------------------------------------------------------------------- report
def esc(s):
    return (str(s) if s is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

CSS = ("body{font:14px/1.5 system-ui,Arial;margin:2rem;max-width:1100px}code{background:#f3f3f3;padding:1px 4px}"
       ".src{color:#666;font-size:12px}h2{border-bottom:2px solid #c8272c;padding-bottom:4px;margin-top:1.8rem}"
       "table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:6px;text-align:left;vertical-align:top;font-size:13px}"
       "th{background:#faf0f0}.card{border:1px solid #ddd;border-left-width:5px;padding:10px;margin:8px 0;border-radius:4px}"
       ".bad{border-left-color:#c8272c;background:#fff6f6}.warn{border-left-color:#e08a00;background:#fffaf2}"
       ".ok{color:#2a8a2a}.imgs{display:flex;gap:10px;margin:6px 0}figure{margin:0}figcaption{font-size:11px;color:#666}"
       "img{max-width:170px;max-height:170px;border:1px solid #ccc}small{color:#555}")

def render_body(path, d):
    t, l, i = d['text'], d['links'], d['images']
    missing = [x for x in i if x['type'] == 'missing-in-wp']
    extra   = [x for x in i if x['type'] == 'extra-in-wp']
    altm    = [x for x in i if x['type'] == 'alt-missing']
    R = [f"<h1>Content compare — <code>{esc(path)}</code></h1>",
         f"<p>Text similarity <b>{d['text_ratio']}</b> &middot; text diffs <b>{len(t)}</b> &middot; "
         f"link issues <b>{len(l)}</b> &middot; images missing/extra <b>{len(missing)}/{len(extra)}</b> &middot; "
         f"images missing alt-text <b>{len(altm)}</b></p>",
         f"<p class=src>LIVE <code>{esc(d['live_url'])}</code> vs WP <code>{esc(d['wp_url'])}</code> "
         f"&middot; content imgs live/wp {d['counts']['live_imgs']}/{d['counts']['wp_imgs']} &middot; "
         f"links {d['counts']['live_links']}/{d['counts']['wp_links']}</p>",
         "<h2>Images — missing / wrong</h2>"]
    if not missing and not extra:
        R.append("<p class=ok>Every live content image has a matching image in the rebuild.</p>")
    for f in missing:
        R.append(f"<div class='card bad'><b>on LIVE but NOT in the rebuild</b>"
                 f"<div class=imgs><figure><figcaption>live</figcaption>"
                 f"<img loading=lazy src='{esc(public_url(f['live_src']))}'></figure></div>"
                 f"<small>alt: {esc(f.get('live_alt'))} &middot; <a href='{esc(public_url(f['live_src']))}'>open</a></small></div>")
    for f in extra:
        R.append(f"<div class='card warn'><b>in the rebuild but NOT on live</b> (look here for a swapped/wrong image)"
                 f"<div class=imgs><figure><figcaption>wp</figcaption>"
                 f"<img loading=lazy src='{esc(public_url(f['wp_src']))}'></figure></div>"
                 f"<small>alt: {esc(f.get('wp_alt'))} &middot; <a href='{esc(public_url(f['wp_src']))}'>open</a></small></div>")
    if altm:
        R.append(f"<h2>Images — missing alt text ({len(altm)})</h2>"
                 "<p>Same image is present, but the rebuild lost the alt text (accessibility / SEO):</p><ul>")
        for f in altm:
            R.append(f"<li>{esc(f['live_alt'])}</li>")
        R.append("</ul>")
    R.append("<h2>Links</h2>")
    if not l:
        R.append("<p class=ok>No link differences.</p>")
    else:
        R.append("<table><tr><th>issue</th><th>anchor text</th><th>live target</th><th>wp target</th></tr>")
        for f in l:
            R.append(f"<tr><td>{f['type']}</td><td>{esc(f.get('text'))}</td><td>{esc(f.get('live'))}</td><td>{esc(f.get('wp'))}</td></tr>")
        R.append("</table>")
    R.append("<h2>Text</h2>")
    if not t:
        R.append("<p class=ok>No text differences.</p>")
    else:
        R.append("<table><tr><th>op</th><th>live</th><th>wp (rebuild)</th></tr>")
        for c in t:
            R.append(f"<tr><td>{c['op']}</td><td>{esc(c['live'])}</td><td>{esc(c['wp'])}</td></tr>")
        R.append("</table>")
    return '\n'.join(R)

def render_html(path, d):
    return (f"<!doctype html><meta charset=utf-8><title>compare {esc(path)}</title>"
            f"<style>{CSS}</style>" + render_body(path, d))

# ----------------------------------------------------------------------------- run
def compare_page(path):
    res = {'path': path}
    wp_code, wp_html = fetch_page(WP, path)
    lv_code, lv_html = fetch_page(LIVE, path)
    res['wp_url'] = WP['base'] + path
    res['live_url'] = LIVE['base'] + path.rstrip('/')
    res['wp_status'], res['live_status'] = wp_code, lv_code
    wp_node = content_node(wp_html, WP)
    lv_node = content_node(lv_html, LIVE)
    if wp_node is None or lv_node is None:
        res['error'] = f"content region not found (wp={wp_node is not None}, live={lv_node is not None})"
        return res
    wp_text, wp_links, wp_imgs = extract(wp_node, WP['host'])
    lv_text, lv_links, lv_imgs = extract(lv_node, LIVE['host'])
    res['text_ratio'], res['text'] = diff_text(wp_text, lv_text)
    res['links'] = diff_links(wp_links, lv_links, wp_text, lv_text)
    res['images'] = diff_images(wp_imgs, lv_imgs)
    res['counts'] = {'wp_imgs': len(wp_imgs), 'live_imgs': len(lv_imgs),
                     'wp_links': len(wp_links), 'live_links': len(lv_links)}
    miss = sum(1 for x in res['images'] if x['type'] == 'missing-in-wp')
    xtra = sum(1 for x in res['images'] if x['type'] == 'extra-in-wp')
    res['score'] = miss * 3 + xtra * 2 + len(res['links']) + len(res['text']) + (10 if res['text_ratio'] < 0.4 else 0)
    return res

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('paths', nargs='*')
    ap.add_argument('--pages-file')
    ap.add_argument('--out', default='reports/run')
    a = ap.parse_args()
    paths = list(a.paths)
    if a.pages_file:
        paths += [l.strip() for l in open(a.pages_file) if l.strip() and not l.startswith('#')]
    os.makedirs(a.out, exist_ok=True)
    index = []
    for path in paths:
        slug = re.sub(r'[^a-z0-9]+', '-', path.lower()).strip('-') or 'home'
        print(f"  comparing {path} ...", flush=True)
        try:
            res = compare_page(path)
        except Exception as e:
            res = {'path': path, 'error': str(e), 'trace': traceback.format_exc()}
        json.dump(res, open(f"{a.out}/{slug}.json", 'w'), indent=2, default=str)
        if 'error' not in res:
            open(f"{a.out}/{slug}.html", 'w').write(render_html(path, res))
        index.append(res)
    index.sort(key=lambda r: r.get('score', -1), reverse=True)
    R = ["<!doctype html><meta charset=utf-8><title>content-compare index</title>",
         "<style>body{font:14px system-ui,Arial;margin:2rem}td,th{border:1px solid #ddd;padding:6px}"
         "table{border-collapse:collapse}a{color:#c8272c}h1{color:#222}</style>",
         "<h1>Content compare — summary (WP rebuild vs live)</h1>",
         "<p>Sorted worst-first. “⚑ review” = same caption but a different picture (likely wrong image). "
         "“missing/extra” = image present on only one side.</p>",
         "<table><tr><th>score</th><th>page</th><th>imgs missing</th><th>imgs extra</th>"
         "<th>imgs no-alt</th><th>link issues</th><th>text diffs</th><th>text sim</th></tr>"]
    for r in index:
        slug = re.sub(r'[^a-z0-9]+', '-', r['path'].lower()).strip('-') or 'home'
        if 'error' in r:
            R.append(f"<tr><td>ERR</td><td>{esc(r['path'])}</td><td colspan=6>{esc(r['error'])}</td></tr>")
            continue
        miss = len([x for x in r['images'] if x['type'] == 'missing-in-wp'])
        xtra = len([x for x in r['images'] if x['type'] == 'extra-in-wp'])
        alt = len([x for x in r['images'] if x['type'] == 'alt-missing'])
        R.append(f"<tr><td>{r['score']}</td><td><a href='{slug}.html'>{esc(r['path'])}</a></td>"
                 f"<td>{miss}</td><td>{xtra}</td><td>{alt}</td><td>{len(r['links'])}</td>"
                 f"<td>{len(r['text'])}</td><td>{r['text_ratio']}</td></tr>")
    R.append("</table>")
    open(f"{a.out}/index.html", 'w').write('\n'.join(R))
    # single combined file (TOC + every page inline; best for sharing / Drive)
    C = [f"<!doctype html><meta charset=utf-8><title>Brentwood content compare</title><style>{CSS}</style>",
         "<h1>Brentwood — content compare (WP rebuild vs live)</h1>",
         "<p>WP rebuild (<code>brentwooddev</code>) vs the live site, worst-first. Click a page to jump. "
         "Live images load from brentwood.ca; rebuild images from the gated dev site.</p>",
         "<table><tr><th>score</th><th>page</th><th>imgs miss/extra/no-alt</th><th>links</th><th>text diffs</th><th>text sim</th></tr>"]
    for r in index:
        if 'error' in r:
            continue
        slug = re.sub(r'[^a-z0-9]+', '-', r['path'].lower()).strip('-') or 'home'
        mi = len([x for x in r['images'] if x['type'] == 'missing-in-wp'])
        xt = len([x for x in r['images'] if x['type'] == 'extra-in-wp'])
        al = len([x for x in r['images'] if x['type'] == 'alt-missing'])
        C.append(f"<tr><td>{r['score']}</td><td><a href='#{slug}'>{esc(r['path'])}</a></td>"
                 f"<td>{mi}/{xt}/{al}</td><td>{len(r['links'])}</td><td>{len(r['text'])}</td><td>{r['text_ratio']}</td></tr>")
    C.append("</table>")
    for r in index:
        if 'error' in r:
            continue
        slug = re.sub(r'[^a-z0-9]+', '-', r['path'].lower()).strip('-') or 'home'
        C.append(f"<section id='{slug}' style='border-top:4px solid #c8272c;margin-top:2.5rem;padding-top:.3rem'>"
                 + render_body(r['path'], r) + "</section>")
    open(f"{a.out}/combined.html", 'w').write('\n'.join(C))
    print(f"\nDONE -> {a.out}/index.html + combined.html  ({len(index)} pages)")

if __name__ == '__main__':
    main()
