#!/usr/bin/env python3
"""
livediff — compare the local tree against what is actually published.

WHY THIS EXISTS
---------------
The check this replaces compared Content-Length. On 2026-08-21 three files
were edited from "4:36" to "4:49" — same byte count — and the length check
reported all three IDENTICAL to live. It was not a sloppy check. It was
incapable of producing the finding: its passing state and its failing state
were the same state for any edit that does not change length.

This one hashes the bytes. It can fail. That is what makes it worth running.

Netlify throttles parallel sweeps; a throttled reply is a short body that
reads as a difference. So: serial, slow, and it retries before it accuses.

    python3 tools/livediff.py            # text + json + a sample of assets
    python3 tools/livediff.py --all      # every deployable file
"""
import hashlib, os, sys, time, urllib.request, urllib.error

BASE = "https://www.avenlost.pet"
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKIP = ('.bak', '~', '.orig', '.DS_Store')
SKIPDIR = {'__pycache__', '.git', 'tools'}
# An allowlist is blind to every type it forgot, and its silence looks exactly
# like a clean result. This one omitted .mjs, .py and .sh, so a just-edited
# instrument reported as nothing at all — not as DIFFERS, not as MISSING, just
# absent from the count. Found 2026-08-23 when a published instrument stayed
# stale through a deploy that reported "47 identical, 0 differs".
TEXT = ('.html', '.txt', '.json', '.css', '.js', '.mjs', '.xml', '.webmanifest',
        '.py', '.sh', '.md', '.svg', '_headers', '_redirects')

def local_files(all_files):
    out = []
    for dirpath, dirnames, filenames in os.walk(ROOT):
        dirnames[:] = [d for d in dirnames if d not in SKIPDIR and not d.startswith('_source')]
        for fn in filenames:
            if any(fn.endswith(s) for s in SKIP) or '_preview' in fn or '_source' in fn:
                continue
            rel = os.path.relpath(os.path.join(dirpath, fn), ROOT).replace(os.sep, '/')
            if not all_files and not rel.endswith(TEXT):
                continue
            out.append(rel)
    return sorted(out)

def url_for(rel):
    if rel.endswith('/index.html'):
        return BASE + '/' + rel[:-len('index.html')]
    if rel == 'index.html':
        return BASE + '/'
    return BASE + '/' + rel

def fetch(url, tries=3):
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'avenlost-livediff/1'})
            with urllib.request.urlopen(req, timeout=60) as r:
                return r.status, r.read()
        except urllib.error.HTTPError as e:
            if e.code in (429, 500, 502, 503) and i < tries - 1:
                time.sleep(6 * (i + 1)); continue
            return e.code, b''
        except Exception:
            if i < tries - 1:
                time.sleep(4 * (i + 1)); continue
            return 0, b''
    return 0, b''

def main():
    all_files = '--all' in sys.argv
    files = local_files(all_files)
    same = diff = missing = err = 0
    print(f"livediff — {len(files)} files against {BASE}\n")
    for rel in files:
        # _headers / _redirects are Netlify build inputs, never served
        if rel in ('_headers', '_redirects'):
            continue
        with open(os.path.join(ROOT, rel), 'rb') as f:
            lh = hashlib.md5(f.read()).hexdigest()
        status, body = fetch(url_for(rel))
        if status == 404:
            print(f"  MISSING  {rel}"); missing += 1
        elif status != 200:
            print(f"  ERR {status}  {rel}"); err += 1
        elif hashlib.md5(body).hexdigest() != lh:
            print(f"  DIFFERS  {rel}   local {lh[:8]} / live {hashlib.md5(body).hexdigest()[:8]}")
            diff += 1
        else:
            same += 1
        time.sleep(0.35)
    print(f"\n  identical {same} · differs {diff} · missing {missing} · errors {err}")
    return 1 if (diff or missing or err) else 0

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