#!/usr/bin/env python3
"""Re-runs the evidence check for the Viallo post instagram-photo-compression.

Every row of dataset.json is verified against the page or the tool output it cites.
Exit 0 when every row still holds, 1 otherwise.

    python3 check.py            full run
    python3 check.py --recheck  same, but a lab step that ran over a minute last
                                time is skipped and reported instead
"""

import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
DATASET = os.path.join(HERE, 'dataset.json')

BROWSER_UA = (
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
    'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
)
# help.instagram.com and faq.whatsapp.com answer a plain client with a shell that
# carries no article body; the crawler UA gets the same page with the text in it.
CRAWLER_UA = 'Googlebot/2.1 (+http://www.google.com/bot.html)'
CRAWLER_HOSTS = ('help.instagram.com', 'faq.whatsapp.com')

# The script ships at apps/web/public/blog/<slug>/ inside the Viallo repository.
VIALLO_REPO = os.environ.get(
    'VIALLO_REPO', os.path.abspath(os.path.join(HERE, '..', '..', '..', '..', '..')))


def user_agent_for(url):
    for host in CRAWLER_HOSTS:
        if host in url:
            return CRAWLER_UA
    return BROWSER_UA


def fetch(url, timeout=60):
    req = urllib.request.Request(url, headers={'User-Agent': user_agent_for(url)})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read().decode('utf-8', 'replace')


def normalise(text):
    """Collapse the differences a page may legitimately carry between runs."""
    text = text.replace('\u2019', "'").replace('\u2018', "'")
    text = text.replace('\u201c', '"').replace('\u201d', '"')
    text = text.replace('\u2013', '-').replace('\u2014', '-')
    text = text.replace('\u00a0', ' ')
    return re.sub(r'\s+', ' ', text)


def page_text(raw):
    """The visible words of a page, including text parked inside its JSON payload.

    Both Meta help centres ship the article body as escaped JSON inside a script
    tag, so the escapes are resolved before the tags are dropped.
    """
    text = html.unescape(raw)
    # Meta double-escapes part of the payload, so one pass leaves a stray backslash
    # in front of the character it just decoded.
    for _ in range(3):
        text = re.sub(r'\\u([0-9a-fA-F]{4})', lambda m: chr(int(m.group(1), 16)), text)
        text = text.replace('\\"', '"').replace("\\'", "'").replace('\\/', '/')
        text = text.replace('\\n', ' ')
    text = re.sub(r'<[^>]+>', ' ', text)
    text = text.replace('`', '').replace('**', '')
    text = re.sub(r'(?m)^\s*[-*]\s+', ' ', text)
    text = normalise(text).replace("\\'", "'").replace('\\"', '"')
    return text


def read_repo_file(path):
    if not os.path.isfile(os.path.join(VIALLO_REPO, 'packages', 'shared', 'package.json')):
        return None
    full = os.path.join(VIALLO_REPO, path)
    if not os.path.isfile(full):
        raise RuntimeError('%s is missing from %s' % (path, VIALLO_REPO))
    with open(full, encoding='utf-8') as fh:
        return fh.read()


def apply_instagram_rule(width, height):
    """What Instagram's help article says happens to a photo of this size."""
    ratio = width / height
    if ratio < 3 / 4 or ratio > 1.91:
        return 'outside 1.91:1 to 3:4, so cropped to a supported ratio the page does not name'
    if width > 1080:
        out_w, out_h = 1080, round(height * 1080 / width)
        verb = 'sized down to'
    elif width < 320:
        out_w, out_h = 320, round(height * 320 / width)
        verb = 'enlarged to'
    else:
        out_w, out_h = width, height
        verb = 'kept at'
    kept = 100 * out_w * out_h / (width * height)
    return '%s %dx%d px, %s%% of the pixels' % (verb, out_w, out_h, ('%.1f' % kept).rstrip('0').rstrip('.'))


def run(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, **kw)


def probe_image(path):
    out = run(['ffprobe', '-v', 'error', '-select_streams', 'v:0',
               '-show_entries', 'stream=width,height', '-of', 'csv=p=0', path])
    width, height = out.stdout.strip().split(',')[:2]
    return int(width), int(height)


def ssim(reference, candidate):
    out = run(['ffmpeg', '-hide_banner', '-i', candidate, '-i', reference,
               '-lavfi', 'ssim=stats_file=-', '-f', 'null', '-'])
    match = re.search(r'All:([0-9.]+)', out.stderr)
    if not match:
        raise RuntimeError('ssim produced no value: %s' % out.stderr[-400:])
    return float(match.group(1))


def run_lab(rows, workdir):
    """Rebuild the reference file and re-measure every lab row."""
    results = []
    lab_rows = [r for r in rows if r.get('type') == 'lab']
    if not lab_rows:
        return results
    source = next(r for r in lab_rows if r['property'] == 'source image')['lab']['url']
    src_path = os.path.join(workdir, 'source.jpg')
    with urllib.request.urlopen(
        urllib.request.Request(source, headers={'User-Agent': BROWSER_UA}), timeout=60
    ) as resp, open(src_path, 'wb') as fh:
        fh.write(resp.read())

    width, height = probe_image(src_path)
    source_bytes = os.path.getsize(src_path)
    reference = os.path.join(workdir, 'reference.png')
    run(['ffmpeg', '-v', 'error', '-y', '-i', src_path, '-vf', 'scale=1080:-2',
         '-update', '1', reference])
    if os.path.getsize(reference) == 0:
        raise RuntimeError('ffmpeg wrote an empty reference image')
    ref_w, ref_h = probe_image(reference)

    measured = {
        'source image': (width, height, source_bytes),
        'scale source to the documented 1080 px width': (ref_w, ref_h, os.path.getsize(reference)),
    }
    for row in lab_rows:
        if row['property'] in measured:
            w, h, b = measured[row['property']]
            results.append((row, {'width': w, 'height': h, 'bytes': b, 'ssim': 1.0}))
            continue
        q = row['lab']['q']
        candidate = os.path.join(workdir, 'q%s.jpg' % q)
        run(['ffmpeg', '-v', 'error', '-y', '-i', src_path, '-vf', 'scale=1080:-2',
             '-q:v', str(q), '-f', 'mjpeg', candidate])
        w, h = probe_image(candidate)
        results.append((row, {
            'width': w, 'height': h, 'bytes': os.path.getsize(candidate),
            'ssim': round(ssim(reference, candidate), 6),
        }))
    return results


def compare_lab(row, got):
    want = row['lab']['expected']
    problems = []
    for key in ('width', 'height', 'bytes'):
        if key not in want:
            continue
        if got[key] != want[key]:
            problems.append('%s recorded %s, measured %s' % (key, want[key], got[key]))
    if 'ssim' in want and abs(got['ssim'] - want['ssim']) > 0.0005:
        problems.append('ssim recorded %s, measured %s' % (want['ssim'], got['ssim']))
    return problems


def main(argv):
    recheck = '--recheck' in argv
    with open(DATASET, encoding='utf-8') as fh:
        dataset = json.load(fh)

    rows = dataset['results']
    failures = []
    skipped = []
    pages = {}
    code_cache = {}
    lab_seconds = dataset.get('labSeconds')

    for row in rows:
        label = '%s / %s' % (row['option'], row['property'])
        if row.get('type') == 'lab':
            continue
        if row.get('type') == 'derived':
            got = apply_instagram_rule(row['derived']['width'], row['derived']['height'])
            if got != row['value']:
                failures.append('%s: recorded %r, the rule gives %r' % (label, row['value'], got))
            continue
        if row.get('type') == 'code':
            path = row['code']['path']
            try:
                if path not in code_cache:
                    code_cache[path] = read_repo_file(path)
            except Exception as exc:                       # noqa: BLE001
                failures.append('%s: %s' % (label, exc))
                continue
            content = code_cache[path]
            if content is None:
                skipped.append('%s: repository not present at %s' % (label, VIALLO_REPO))
                continue
            body = normalise(content)
            if normalise(row['quote']) not in body:
                failures.append('%s: quote gone from %s' % (label, path))
            for token in row.get('absent', []):
                if normalise(token) in body:
                    failures.append('%s: %r is now in %s' % (label, token, path))
            continue

        url = row['url']
        if url not in pages:
            try:
                pages[url] = page_text(fetch(url))
            except Exception as exc:                       # noqa: BLE001
                pages[url] = None
                failures.append('%s: page not reachable (%s)' % (label, exc))
        text = pages[url]
        if text is None:
            continue
        if normalise(row['quote']) not in text:
            failures.append('%s: quote gone from %s' % (label, url))
        for token in row.get('absent', []):
            if normalise(token) in text:
                failures.append('%s: %r now appears on %s' % (label, token, url))

    workdir = tempfile.mkdtemp(prefix='viallo-ig-check-')
    try:
        if recheck and lab_seconds and lab_seconds > 60:
            skipped.append('lab steps: took %ss last run, skipped under --recheck' % lab_seconds)
        else:
            started = time.time()
            try:
                lab_rows = [r for r in rows if r.get('type') == 'lab']
                for row, got in run_lab(rows, workdir):
                    for problem in compare_lab(row, got):
                        failures.append('%s / %s: %s' % (row['option'], row['property'], problem))
                if lab_rows:
                    print('lab: %d rows re-measured in %.1fs' % (len(lab_rows), time.time() - started))
            except Exception as exc:                       # noqa: BLE001
                failures.append('lab: %s' % exc)
    finally:
        shutil.rmtree(workdir, ignore_errors=True)

    for line in skipped:
        print('SKIP  ' + line)
    for line in failures:
        print('FAIL  ' + line)
    checked = len(rows) - len(skipped)
    print('%d rows checked, %d skipped, %d failed' % (checked, len(skipped), len(failures)))
    return 0 if not failures else 1


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
