#!/usr/bin/env python3
"""Re-runs the check behind dataset.json for "Is Email Safe for Sharing Photos?".

Two legs:

  pages  every quoted cell in dataset.json is fetched from its URL and the quote must
         still appear on the page, after whitespace, entity and curly-quote normalisation.
  lab    the test photos are rebuilt from the NASA public-domain original with macOS
         sips and exiftool (the steps of device-inputs/make_inputs.sh), their SHA-256
         compared with the recorded ones, then each is attached to a MIME message with
         the Python email package, serialised, parsed back and measured: file size,
         message size, how many fit under 25 MB, whether the file comes back byte for
         byte, and the GPS and camera tags read back from the JPEG.

usage: python3 check.py [--recheck] [--inputs DIR]
  --recheck     re-reads the pages; skips the lab when the rebuild runs longer than a minute
  --inputs DIR  measure the photos in DIR instead of rebuilding them

Exits 0 when everything holds, 1 otherwise. The lab is reported as skipped, not failed,
where sips or exiftool is missing.
"""

import gzip
import hashlib
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
import zlib
from email import message_from_bytes, policy
from email.message import EmailMessage

HERE = os.path.dirname(os.path.abspath(__file__))
DATASET = os.path.join(HERE, 'dataset.json')
UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
      '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
LIMIT_BYTES = 25_000_000
LAB_BUDGET_S = 60
MIME = {'.jpg': ('image', 'jpeg'), '.heic': ('image', 'heic')}


def normalise(text):
    text = re.sub(r'(?is)<(script|style|noscript)[^>]*>.*?</\1>', ' ', text)
    text = re.sub(r'(?s)<[^>]+>', ' ', text)
    text = html.unescape(text)
    for a, b in [('\u2019', "'"), ('\u2018', "'"), ('\u201c', '"'), ('\u201d', '"'),
                 ('\xa0', ' '), ('\u2212', '-'), ('\u2014', '-'), ('\u2013', '-')]:
        text = text.replace(a, b)
    text = re.sub('[\u200b\u200c\u200d\ufeff]', '', text)
    return re.sub(r'\s+', ' ', text).strip()


_cache = {}


def page_text(url):
    if url in _cache:
        return _cache[url]
    req = urllib.request.Request(url, headers={
        'User-Agent': UA,
        'Accept-Language': 'en-US,en;q=0.9',
        'Accept': 'text/html,application/xhtml+xml',
        'Accept-Encoding': 'gzip, deflate',
    })
    with urllib.request.urlopen(req, timeout=45) as resp:
        body = resp.read()
        encoding = (resp.headers.get('Content-Encoding') or '').lower()
    if encoding == 'gzip' or body[:2] == b'\x1f\x8b':
        body = gzip.decompress(body)
    elif encoding == 'deflate':
        body = zlib.decompress(body, -zlib.MAX_WBITS)
    _cache[url] = normalise(body.decode('utf-8', 'replace'))
    return _cache[url]


def quoted_cells(dataset):
    for name, entry in dataset['results']['providers'].items():
        for prop, cell in entry['properties'].items():
            if cell.get('quote'):
                yield f'{name} / {prop}', cell['quote'], cell.get('url') or entry['url']
    for label, cell in dataset['results']['context'].items():
        if cell.get('quote'):
            yield label, cell['quote'], cell['url']


def check_pages(dataset):
    failures = []
    rows = 0
    for label, quote, url in quoted_cells(dataset):
        rows += 1
        try:
            text = page_text(url)
        except Exception as exc:  # noqa: BLE001 - a page that will not load is a failed row
            failures.append(f'{label}: page did not load ({type(exc).__name__}: {exc}) {url}')
            print(f'FAIL {label}: page did not load ({exc})')
            continue
        if normalise(quote) in text:
            print(f'ok   {label}')
        else:
            failures.append(f'{label}: quote not found on {url}')
            print(f'FAIL {label}: quote not found on {url}')
    return rows, failures


def sha256_file(path):
    with open(path, 'rb') as f:
        return hashlib.sha256(f.read()).hexdigest()


def rebuild_inputs(dataset, workdir, deadline):
    def run(*args):
        if deadline and time.time() > deadline:
            raise TimeoutError('rebuild over the one-minute budget')
        subprocess.run(args, cwd=workdir, check=True, stdout=subprocess.DEVNULL)

    req = urllib.request.Request(dataset['inputs']['lab_files']['source'], headers={'User-Agent': UA})
    with urllib.request.urlopen(req, timeout=120) as resp, open(os.path.join(workdir, 'src.jpg'), 'wb') as f:
        shutil.copyfileobj(resp, f)
    run('sips', '-c', '5120', '3840', 'src.jpg', '--out', 'crop.jpg')
    run('sips', '-z', '4032', '3024', '-s', 'formatOptions', '90', 'crop.jpg', '--out', 'photo-12mp.jpg')
    run('sips', '-z', '8064', '6048', '-s', 'format', 'heic', 'crop.jpg', '--out', 'photo-48mp.heic')
    for name in ('photo-12mp.jpg', 'photo-48mp.heic'):
        run('exiftool', '-q', '-overwrite_original', '-all=', name)
        run('exiftool', '-q', '-overwrite_original',
            '-Make=Apple', '-Model=iPhone 15 Pro', '-Software=18.0',
            '-LensModel=iPhone 15 Pro back triple camera 6.765mm f/1.78',
            '-DateTimeOriginal=2026:10:25 09:30:00', '-CreateDate=2026:10:25 09:30:00',
            '-OffsetTimeOriginal=-04:00',
            '-GPSLatitude=28.586', '-GPSLatitudeRef=N', '-GPSLongitude=80.651', '-GPSLongitudeRef=W',
            '-GPSAltitude=3', '-GPSAltitudeRef=0',
            '-ExposureTime=1/120', '-FNumber=1.78', '-ISO=80', '-Orientation#=1', name)
    os.remove(os.path.join(workdir, 'crop.jpg'))


def dms(value):
    d, m, s = (float(x) for x in value)
    return f'{d:g} {m:g} {s:g}'


def measure(path):
    with open(path, 'rb') as f:
        data = f.read()
    name = os.path.basename(path)
    maintype, subtype = MIME[os.path.splitext(name)[1].lower()]
    msg = EmailMessage()
    msg['From'] = 'sender@example.com'
    msg['To'] = 'recipient@example.com'
    msg['Subject'] = 'photo'
    msg.set_content('photo attached')
    msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=name)
    raw = msg.as_bytes(policy=policy.SMTP)
    back = next(message_from_bytes(raw, policy=policy.default).iter_attachments()).get_content()
    out = {
        'file_bytes': len(data),
        'message_bytes': len(raw),
        'photos_per_25mb_by_file_size': LIMIT_BYTES // len(data),
        'photos_per_25mb_by_message_size': LIMIT_BYTES // len(raw),
        'identical_after_roundtrip': back == data,
    }
    if subtype == 'jpeg' and name != 'src.jpg':
        import io
        from PIL import Image
        img = Image.open(io.BytesIO(back))
        exif = img.getexif()
        gps = exif.get_ifd(0x8825)
        out['pixels'] = f'{img.size[0]}x{img.size[1]}'
        out['gps_after_roundtrip'] = f'{dms(gps[2])} {gps[1]}, {dms(gps[4])} {gps[3]}'
        out['camera_model_after_roundtrip'] = exif.get(0x0110)
    return out


def check_lab(dataset, recheck, inputs_dir):
    expected = dataset['results']['lab']['files']
    failures = []
    tmp = tempfile.mkdtemp(prefix='email-photo-safety-')
    try:
        if inputs_dir:
            src_dir = inputs_dir
        else:
            if not (shutil.which('sips') and shutil.which('exiftool')):
                print('skip lab: needs macOS sips and exiftool, or --inputs DIR')
                return failures, True
            deadline = time.time() + LAB_BUDGET_S if recheck else None
            try:
                rebuild_inputs(dataset, tmp, deadline)
            except TimeoutError as exc:
                print(f'skip lab: {exc}')
                return failures, True
            src_dir = tmp
        for name, want in expected.items():
            path = os.path.join(src_dir, name)
            if not os.path.exists(path):
                failures.append(f'lab {name}: input missing at {path}')
                print(f'FAIL lab {name}: input missing')
                continue
            digest = sha256_file(path)
            if digest != want['sha256']:
                failures.append(f'lab {name}: input sha256 {digest[:12]} differs from recorded {want["sha256"][:12]}')
                print(f'FAIL lab {name}: input differs from the recorded one')
                continue
            got = measure(path)
            for key, value in got.items():
                if key not in want:
                    continue
                if want[key] == value:
                    print(f'ok   lab {name} {key} = {value}')
                else:
                    failures.append(f'lab {name} {key}: dataset says {want[key]}, measured {value}')
                    print(f'FAIL lab {name} {key}: dataset says {want[key]}, measured {value}')
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    return failures, False


def main():
    args = sys.argv[1:]
    recheck = '--recheck' in args
    inputs_dir = args[args.index('--inputs') + 1] if '--inputs' in args else None
    with open(DATASET, encoding='utf-8') as f:
        dataset = json.load(f)
    print(f'checking {dataset["slug"]} (method {dataset["method"]}, checked {dataset["checkedAt"]})'
          + (' [recheck]' if recheck else ''))

    rows, failures = check_pages(dataset)
    lab_failures, lab_skipped = check_lab(dataset, recheck, inputs_dir)
    failures += lab_failures

    print()
    print(f'{rows} quoted cells checked, lab ' + ('skipped' if lab_skipped else 'run'))
    if failures:
        for line in failures:
            print(f'  - {line}')
        print(f'{len(failures)} checks failed')
        return 1
    print('all checks hold')
    return 0


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