#!/usr/bin/env python3
"""
compare.demoing.info — web UI for content-compare (WP rebuild vs live), multi-site.

Routes:
  /                         -> site list (currently just Brentwood)
  /<site>                   -> compact table of pages (Brentwood = /brentwood)
  /<site>/<slug>            -> per-page report (live+dev links, itemised diffs, ignore boxes)
  POST /<site>/<slug>/recheck   -> re-run the comparison for one page
  POST /<site>/<slug>/ignore    -> toggle an ignore flag (AJAX); persists across rechecks
  POST /<site>/_recheckall      -> re-run every page

CLI:  python app.py seed     # pull page list into the DB (run as rian; uses srv-gw)
      python app.py scan      # recheck every page
Engine functions are imported from compare.py. Alt-text diffs are intentionally excluded
(separate tool). Visible diffs only: text, links, images.
"""
import os, re, json, sqlite3, hashlib, datetime, subprocess, sys, difflib
from collections import Counter
from flask import Flask, request, redirect, jsonify, abort
import compare as E

HERE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(HERE, 'compare.db')

SITES = {
    'brentwood': {
        'name': 'Brentwood',
        'wp': E.WP, 'live': E.LIVE,
        'dev_pub': 'https://brentwooddev.demoing.info',
        'live_pub': 'https://brentwood.ca',
        'wp_project': 'brentwooddev',
    },
}
NOT_DONE_RATIO = 0.40
NOT_DONE_MINTEXT = 80
esc = E.esc
app = Flask(__name__)

# --------------------------------------------------------------------------- db
def db():
    c = sqlite3.connect(DB, timeout=15); c.row_factory = sqlite3.Row
    c.execute('PRAGMA busy_timeout=10000')
    return c

def init_db():
    c = db()
    c.executescript('''
      PRAGMA journal_mode=WAL;
      CREATE TABLE IF NOT EXISTS pages(
        site TEXT, slug TEXT, path TEXT, name TEXT, status TEXT,
        n_text INT, n_links INT, n_images INT, n_ignored INT,
        text_ratio REAL, checked_at TEXT, PRIMARY KEY(site,slug));
      CREATE TABLE IF NOT EXISTS findings(
        site TEXT, slug TEXT, fp TEXT, kind TEXT, detail TEXT, ignored INT DEFAULT 0,
        PRIMARY KEY(site,slug,fp));''')
    c.commit(); c.close()

def slugify(path):
    return re.sub(r'[^a-z0-9]+', '-', path.lower()).strip('-') or 'home'

def page_name(path):
    seg = path.strip('/').split('/')[-1] or 'home'
    return seg.replace('-', ' ').title()

def fp(kind, *parts):
    return hashlib.sha1(('|'.join([kind] + [str(x) for x in parts])).encode()).hexdigest()[:16]

# ----------------------------------------------------------------------- engine
def compute(site, path):
    cfg = SITES[site]
    import render as R
    wp_node = lv_node = None
    for _ in range(2):                       # one more whole-render retry if a side failed
        wp_html, lv_html = R.render_pair(cfg['dev_pub'] + path, cfg['live_pub'] + path.rstrip('/'))
        wp_node = E.content_node(wp_html, cfg['wp'])
        lv_node = E.content_node(lv_html, cfg['live'])
        if wp_node is not None and lv_node is not None:
            break
    if wp_node is None or lv_node is None:
        return None, None
    wp_text, wp_links, wp_imgs = E.extract(wp_node, cfg['wp']['host'])
    lv_text, lv_links, lv_imgs = E.extract(lv_node, cfg['live']['host'])
    # full-page bodies: a fragment found ANYWHERE on the other side is relocated/reordered,
    # not a content error — so we suppress it (precision over recall).
    wp_full, lv_full = E.body_text(wp_html), E.body_text(lv_html)
    ratio, tdiffs = E.diff_text(wp_text, lv_text, wp_full, lv_full)
    ldiffs = E.diff_links(wp_links, lv_links, wp_full, lv_full)
    idiffs = [x for x in E.diff_images(wp_imgs, lv_imgs) if x['type'] != 'alt-missing']
    # Galleries on the two sites use different photo SETS, so per-photo orphans aren't
    # content errors. Only a single isolated image discrepancy is worth a look; more than
    # that is photoset/gallery noise -> drop it.
    if len(idiffs) > 1:
        idiffs = []
    findings = []
    for c in tdiffs:
        findings.append({'kind': 'text', 'fp': fp('text', c['op'], c['live'][:60], c['wp'][:60]), 'detail': c})
    for l in ldiffs:
        findings.append({'kind': 'link', 'fp': fp('link', l['type'], l.get('text', ''), l.get('live', ''), l.get('wp', '')), 'detail': l})
    for im in idiffs:
        key = (im.get('live_src') or im.get('wp_src') or '').split('/')[-1]
        findings.append({'kind': 'image', 'fp': fp('image', im['type'], key), 'detail': im})
    meta = {'text_ratio': ratio, 'wp_text_len': len(wp_text)}
    return findings, meta

def recheck(site, path):
    slug = slugify(path)
    findings, meta = compute(site, path)
    now = datetime.datetime.now().isoformat(timespec='minutes')
    c = db()
    keep = {r['fp'] for r in c.execute('SELECT fp FROM findings WHERE site=? AND slug=? AND ignored=1', (site, slug))}
    c.execute('DELETE FROM findings WHERE site=? AND slug=?', (site, slug))
    if findings is None:
        c.execute('INSERT OR REPLACE INTO pages VALUES(?,?,?,?,?,?,?,?,?,?,?)',
                  (site, slug, path, page_name(path), 'not done', 0, 0, 0, 0, 0.0, now))
        c.commit(); c.close(); return
    for f in findings:
        c.execute('INSERT OR REPLACE INTO findings VALUES(?,?,?,?,?,?)',
                  (site, slug, f['fp'], f['kind'], json.dumps(f['detail']), 1 if f['fp'] in keep else 0))
    # 'not done' = the rebuild page is blank / unbuilt (NOT merely structurally different).
    blank = meta.get('wp_text_len', 0) < 200
    c.execute('INSERT OR REPLACE INTO pages VALUES(?,?,?,?,?,?,?,?,?,?,?)',
              (site, slug, path, page_name(path), 'not done' if blank else 'pending',
               0, 0, 0, 0, meta['text_ratio'], now))
    c.commit(); c.close()
    _refresh(site, slug, meta['text_ratio'])

def _refresh(site, slug, ratio=None):
    """Recompute counts + status from a page's findings. 'not done' (blank/unbuilt rebuild)
    is set only by a fresh recheck and preserved here; otherwise 0 findings => match."""
    c = db()
    cur = c.execute('SELECT status FROM pages WHERE site=? AND slug=?', (site, slug)).fetchone()
    rows = list(c.execute('SELECT kind,ignored FROM findings WHERE site=? AND slug=?', (site, slug)))
    nt = sum(1 for r in rows if r['kind'] == 'text' and not r['ignored'])
    nl = sum(1 for r in rows if r['kind'] == 'link' and not r['ignored'])
    ni = sum(1 for r in rows if r['kind'] == 'image' and not r['ignored'])
    nign = sum(1 for r in rows if r['ignored'])
    if cur and cur['status'] == 'not done':
        status = 'not done'
    elif nt + nl + ni == 0:
        status = 'match'
    else:
        status = 'differences'
    c.execute('UPDATE pages SET status=?,n_text=?,n_links=?,n_images=?,n_ignored=? WHERE site=? AND slug=?',
              (status, nt, nl, ni, nign, site, slug))
    c.commit(); c.close()

# ----------------------------------------------------------------------- render
CSS = ("body{font:14px/1.5 system-ui,Arial;margin:1.5rem;color:#222}a{color:#c8272c}"
       "h1{margin:.2rem 0}table{border-collapse:collapse;width:100%;margin-top:1rem}"
       "td,th{border:1px solid #ddd;padding:6px 8px;font-size:13px;text-align:left}th{background:#faf0f0;position:sticky;top:0}"
       ".st-match{color:#2a8a2a;font-weight:600}.st-diff{color:#c8272c;font-weight:600}.st-nd{color:#888;font-weight:600}"
       ".st-pending{color:#999}.num{text-align:center}button{font:inherit;padding:3px 8px;cursor:pointer}"
       ".card{border:1px solid #ddd;border-left-width:5px;padding:8px 10px;margin:7px 0;border-radius:4px}"
       ".c-image{border-left-color:#c8272c}.c-link{border-left-color:#e08a00}.c-text{border-left-color:#3a6ea5}"
       ".ign{opacity:.45}.imgs{display:flex;gap:10px;margin:5px 0}img{max-width:160px;max-height:160px;border:1px solid #ccc}"
       "figcaption{font-size:11px;color:#666}small{color:#555}.bar{margin:.4rem 0;color:#666;font-size:12px}"
       "label.ig{float:right;font-size:12px;color:#555;cursor:pointer}"
       "table.cmp{border-collapse:collapse;margin:6px 0 0;width:100%}"
       "table.cmp th{text-align:right;color:#999;font-weight:600;font-size:11px;padding:2px 8px 2px 0;"
       "vertical-align:top;white-space:nowrap;width:62px;border:0;background:none}"
       "table.cmp td{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;"
       "white-space:pre-wrap;word-break:break-word;padding:2px 0;vertical-align:top;border:0}"
       "mark{background:#ffe08a;color:#000;padding:0 1px;border-radius:2px}"
       "th.sortable{cursor:pointer;user-select:none}th.sortable:hover{background:#f3e0e0}.arr{color:#c8272c}"
       ".summary{margin:.7rem 0;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center}"
       ".summary .chip{padding:3px 11px;border-radius:13px;border:1px solid #ddd;font-size:13px;font-weight:600}"
       ".chip-match{background:#eaf7ea;border-color:#bfe0bf;color:#2a8a2a}"
       ".chip-diff{background:#fdeaea;border-color:#f0c4c4;color:#c8272c}"
       ".chip-nd{background:#f2f2f2;color:#777}.chip-pending{background:#fafafa;color:#999}"
       ".chip-total{background:#fff;color:#222}")

SORT_JS = """<script>
function sortTable(th){
  var table=th.closest('table'), tb=table.tBodies[0],
      hs=th.parentNode.children, idx=Array.prototype.indexOf.call(hs,th),
      dir=th.getAttribute('data-dir')==='asc'?'desc':'asc';
  for(var i=0;i<hs.length;i++){hs[i].removeAttribute('data-dir');var a=hs[i].querySelector('.arr');if(a)a.remove();}
  th.setAttribute('data-dir',dir);
  var rows=Array.prototype.slice.call(tb.rows);
  function v(tr){var c=tr.cells[idx];return c.hasAttribute('data-sort')?c.getAttribute('data-sort'):c.textContent.trim();}
  var num=rows.every(function(r){var x=v(r);return x!==''&&!isNaN(x);});
  rows.sort(function(a,b){var x=v(a),y=v(b);if(num){x=+x;y=+y;}return x<y?-1:x>y?1:0;});
  if(dir==='desc')rows.reverse();
  rows.forEach(function(r){tb.appendChild(r);});
  var s=document.createElement('span');s.className='arr';s.textContent=dir==='asc'?' ▲':' ▼';th.appendChild(s);
}
</script>"""

def hl(a, b):
    """Word-level diff of two strings -> (a_html, b_html) with changed spans wrapped in <mark>."""
    aw, bw = (a or '').split(' '), (b or '').split(' ')
    sm = difflib.SequenceMatcher(a=aw, b=bw, autojunk=False)
    oa, ob = [], []
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        ca, cb = ' '.join(aw[i1:i2]), ' '.join(bw[j1:j2])
        if tag == 'equal':
            oa.append(esc(ca)); ob.append(esc(cb))
        else:
            if ca:
                oa.append('<mark>' + esc(ca) + '</mark>')
            if cb:
                ob.append('<mark>' + esc(cb) + '</mark>')
    return ' '.join(x for x in oa if x), ' '.join(x for x in ob if x)

def status_cell(s):
    return {'match': '<span class=st-match>match</span>',
            'differences': '<span class=st-diff>differences</span>',
            'not done': '<span class=st-nd>not done</span>',
            'pending': '<span class=st-pending>not checked</span>'}.get(s, esc(s))

@app.route('/')
def home():
    links = ''.join(f"<li><a href='/{s}'>{esc(c['name'])}</a></li>" for s, c in SITES.items())
    return f"<!doctype html><meta charset=utf-8><title>content compare</title><style>{CSS}</style><h1>Content compare</h1><ul>{links}</ul>"

@app.route('/<site>')
def index(site):
    if site not in SITES:
        abort(404)
    cfg = SITES[site]
    c = db()
    rows = list(c.execute('SELECT * FROM pages WHERE site=? ORDER BY '
                          "CASE status WHEN 'differences' THEN 0 WHEN 'not done' THEN 1 WHEN 'pending' THEN 2 ELSE 3 END,"
                          ' (n_text+n_links+n_images) DESC, name', (site,)))
    c.close()
    n = Counter(r['status'] for r in rows)
    sort_order = {'differences': 0, 'not done': 1, 'pending': 2, 'match': 3}
    chips = [f"<span class='chip chip-total'>{len(rows)} pages</span>",
             f"<span class='chip chip-match'>matched: {n.get('match', 0)}</span>",
             f"<span class='chip chip-diff'>differences: {n.get('differences', 0)}</span>",
             f"<span class='chip chip-nd'>not done: {n.get('not done', 0)}</span>"]
    if n.get('pending'):
        chips.append(f"<span class='chip chip-pending'>not checked: {n['pending']}</span>")
    body = [f"<!doctype html><meta charset=utf-8><title>{esc(cfg['name'])} — content compare</title><style>{CSS}</style>",
            f"<h1>{esc(cfg['name'])} — content compare</h1>",
            f"<div class=summary>{''.join(chips)}</div>",
            f"<div class=bar>WP rebuild vs live &middot; click a column header to sort. "
            f"<form method=post action='/{site}/_recheckall' style='display:inline'>"
            f"<button onclick=\"this.innerText='checking… (may take a minute)'\">Recheck all</button></form></div>",
            "<table><thead><tr>"
            "<th class=sortable onclick=sortTable(this)>Page</th><th>Dev</th><th>Live</th>"
            "<th class=sortable onclick=sortTable(this)>Status</th>"
            "<th class='num sortable' onclick=sortTable(this)>Img</th>"
            "<th class='num sortable' onclick=sortTable(this)>Links</th>"
            "<th class='num sortable' onclick=sortTable(this)>Text</th>"
            "<th class='num sortable' onclick=sortTable(this)>Ignored</th>"
            "<th>Report</th><th>Recheck</th></tr></thead><tbody>"]
    for r in rows:
        na = r['status'] in ('not done', 'pending')
        cells = 'n/a' if na else None
        sval = -1 if na else 0       # n/a numeric cells sort below real counts
        body.append(
            f"<tr><td data-sort=\"{esc(r['name'].lower())}\">{esc(r['name'])}<br><small>{esc(r['path'])}</small></td>"
            f"<td><a href='{cfg['dev_pub']}{esc(r['path'])}' target=_blank>dev ↗</a></td>"
            f"<td><a href='{cfg['live_pub']}{esc(r['path']).rstrip('/')}' target=_blank>live ↗</a></td>"
            f"<td data-sort={sort_order.get(r['status'], 9)}>{status_cell(r['status'])}</td>"
            f"<td class=num data-sort={r['n_images'] if not na else sval}>{cells or r['n_images']}</td>"
            f"<td class=num data-sort={r['n_links'] if not na else sval}>{cells or r['n_links']}</td>"
            f"<td class=num data-sort={r['n_text'] if not na else sval}>{cells or r['n_text']}</td>"
            f"<td class=num data-sort={r['n_ignored']}>{r['n_ignored']}</td>"
            f"<td><a href='/{site}/{r['slug']}'>report</a></td>"
            f"<td><form method=post action='/{site}/{r['slug']}/recheck'><button>↻</button></form></td></tr>")
    body.append("</tbody></table>")
    body.append(SORT_JS)
    return '\n'.join(body)

@app.route('/<site>/<slug>')
def report(site, slug):
    if site not in SITES:
        abort(404)
    cfg = SITES[site]
    c = db()
    pg = c.execute('SELECT * FROM pages WHERE site=? AND slug=?', (site, slug)).fetchone()
    if not pg:
        c.close(); abort(404)
    fr = list(c.execute('SELECT * FROM findings WHERE site=? AND slug=? ORDER BY kind', (site, slug)))
    c.close()
    dev = cfg['dev_pub'] + pg['path']
    live = cfg['live_pub'] + pg['path'].rstrip('/')
    B = [f"<!doctype html><meta charset=utf-8><title>{esc(pg['name'])} — compare</title><style>{CSS}</style>",
         f"<p><a href='/{site}'>← all pages</a></p>",
         f"<h1>{esc(pg['name'])} <small>{esc(pg['path'])}</small></h1>",
         f"<div class=bar>Status: {status_cell(pg['status'])} &middot; "
         f"<a href='{live}' target=_blank>open LIVE ↗</a> &middot; <a href='{dev}' target=_blank>open DEV ↗</a> &middot; "
         f"checked {esc(pg['checked_at'])} "
         f"<form method=post action='/{site}/{slug}/recheck' style='display:inline'><button>↻ recheck</button></form></div>"]
    def card(f):
        d = json.loads(f['detail']); k = f['kind']; cls = 'ign' if f['ignored'] else ''
        chk = 'checked' if f['ignored'] else ''
        lab = f"<label class=ig><input type=checkbox class=ig data-fp='{f['fp']}' {chk}> ignore</label>"
        if k == 'image':
            if d['type'] == 'missing-in-wp':
                img = E.public_url(d['live_src'])
                inner = (f"<b>image on LIVE but NOT in rebuild</b>{lab}"
                         f"<div class=imgs><figure><figcaption>live</figcaption><img loading=lazy src='{esc(img)}'></figure></div>"
                         f"<small>{esc(d.get('live_alt'))}</small>")
            else:
                img = E.public_url(d['wp_src'])
                inner = (f"<b>image in rebuild but NOT on live</b> (possible wrong image){lab}"
                         f"<div class=imgs><figure><figcaption>rebuild</figcaption><img loading=lazy src='{esc(img)}'></figure></div>")
            return f"<div class='card c-image {cls}'>{inner}</div>"
        if k == 'link':
            lv, wp = hl(d.get('live') or '', d.get('wp') or '')
            return (f"<div class='card c-link {cls}'><b>link · {esc(d['type'])}</b>{lab}"
                    f"<table class=cmp><tr><th>text</th><td>{esc(d.get('text'))}</td></tr>"
                    f"<tr><th>live</th><td>{lv}</td></tr>"
                    f"<tr><th>rebuild</th><td>{wp}</td></tr></table></div>")
        lv, wp = hl(d['live'], d['wp'])
        return (f"<div class='card c-text {cls}'><b>text · {esc(d['op'])}</b>{lab}"
                f"<table class=cmp><tr><th>live</th><td>{lv}</td></tr>"
                f"<tr><th>rebuild</th><td>{wp}</td></tr></table></div>")
    for k, title in [('image', 'Images'), ('link', 'Links'), ('text', 'Text')]:
        items = [f for f in fr if f['kind'] == k]
        B.append(f"<h2>{title} ({sum(1 for f in items if not f['ignored'])})</h2>")
        if not items:
            B.append("<p class=st-match>none</p>")
        for f in items:
            B.append(card(f))
    B.append("<script>document.querySelectorAll('input.ig').forEach(cb=>cb.onchange=()=>{"
             "fetch(location.pathname+'/ignore',{method:'POST',headers:{'Content-Type':'application/json'},"
             "body:JSON.stringify({fp:cb.dataset.fp,ignored:cb.checked})}).then(()=>cb.closest('.card').classList.toggle('ign',cb.checked));});</script>")
    return '\n'.join(B)

@app.route('/<site>/<slug>/recheck', methods=['POST'])
def do_recheck(site, slug):
    if site not in SITES:
        abort(404)
    pg = db().execute('SELECT path FROM pages WHERE site=? AND slug=?', (site, slug)).fetchone()
    if pg:
        recheck(site, pg['path'])
    return redirect(request.referrer or f'/{site}/{slug}')

@app.route('/<site>/<slug>/ignore', methods=['POST'])
def do_ignore(site, slug):
    if site not in SITES:
        abort(404)
    j = request.get_json(force=True)
    c = db()
    c.execute('UPDATE findings SET ignored=? WHERE site=? AND slug=? AND fp=?',
              (1 if j.get('ignored') else 0, site, slug, j.get('fp')))
    c.commit(); c.close()
    _refresh(site, slug)
    return jsonify(ok=True)

@app.route('/<site>/_recheckall', methods=['POST'])
def do_recheckall(site):
    if site not in SITES:
        abort(404)
    for r in list(db().execute('SELECT path FROM pages WHERE site=?', (site,))):
        recheck(site, r['path'])
    return redirect(f'/{site}')

# ------------------------------------------------------------------------- cli
def seed(site):
    cfg = SITES[site]
    init_db()
    paths = []
    for pt in ('page', 'landing'):
        out = subprocess.run(['srv-gw', 'wp', '--project', cfg['wp_project'], '--',
                              'post', 'list', f'--post_type={pt}', '--post_status=publish', '--fields=url'],
                             capture_output=True, text=True).stdout
        for line in out.splitlines()[1:]:
            line = line.strip()
            if not line:
                continue
            path = re.sub(r'^https?://[^/]+', '', line)
            if re.search(r'/(test|template|sample|my-profile|placeholder)', path):
                continue
            paths.append(path)
    c = db()
    for p in sorted(set(paths)):
        s = slugify(p)
        c.execute('INSERT OR IGNORE INTO pages(site,slug,path,name,status,n_text,n_links,n_images,n_ignored,text_ratio,checked_at)'
                  ' VALUES(?,?,?,?,?,0,0,0,0,0.0,?)', (site, s, p, page_name(p), 'pending', ''))
    c.commit(); c.close()
    print(f"seeded {len(set(paths))} pages for {site}")

def scan(site):
    rows = list(db().execute('SELECT path FROM pages WHERE site=?', (site,)))
    for i, r in enumerate(rows, 1):
        print(f"  [{i}/{len(rows)}] {r['path']}", flush=True)
        try:
            recheck(site, r['path'])
        except Exception as e:
            print(f"    ERROR {e}")
    print("scan done")

init_db()  # ensure schema exists whether run directly or imported by waitress

if __name__ == '__main__':
    if len(sys.argv) >= 2 and sys.argv[1] == 'seed':
        seed(sys.argv[2] if len(sys.argv) > 2 else 'brentwood')
    elif len(sys.argv) >= 2 and sys.argv[1] == 'scan':
        scan(sys.argv[2] if len(sys.argv) > 2 else 'brentwood')
    else:
        app.run(host='127.0.0.1', port=5055, debug=True)
