#!/usr/bin/env python3
"""Check behind /blog/password-protected-bank-statement-pdf.

1. make_pdfs.py writes one synthetic statement page as a PDF eight ways: unencrypted, encrypted
   with an empty open password under five handlers (RC4 40-bit, RC4 128-bit, RC4 128-bit through a
   crypt filter, AES 128-bit, AES 256-bit), and twice with an open password. It runs under
   `uv run --with pikepdf==10.17.0` (qpdf 12.4.2 inside). Every encrypted file with no open
   password is also written back with no encryption, which is what `qpdf --decrypt` does; the two
   with an open password are tried without it, then written back with it.
2. read-locked.harness.ts, through vitest and harness.vitest.config.mjs, puts every file through
   the check Evidilo's upload runs on a PDF (pdfRequiresPassword) and the production reader
   (readPdfPages), and opens it with pdf.js 6.4.299 with no password.
3. Confirms the upload path still refuses a locked PDF with the sentence the post quotes, and
   still runs that check before it takes the day's allowance.
4. Re-reads the qpdf manual and the pdf.js README and confirms the quoted sentences.

Exit 0: every result matches dataset.csv and every quote is found. Exit 1: a result differs or a
quote is gone. Exit 2: a cited page could not be reached at all (nothing else failed).
`--recheck` runs the same steps (none takes over a minute). `--write` rewrites dataset.csv.
"""

import csv
import html
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
REPO = HERE.parents[4]  # apps/web/public/blog/<slug> -> repository root
PIKEPDF = "pikepdf==10.17.0"
# Some hosts stall a request that claims to be a browser from a datacenter address; this one says
# what it is.
UA = "evidilo-blog-check/1.0 (+https://evidilo.com/blog/password-protected-bank-statement-pdf)"
QUOTES = {
    "https://qpdf.readthedocs.io/en/stable/encryption.html": [
        "A PDF file can be encrypted without being password-protected.",
        "This is done by using the empty string as the user password and some secret string as the "
        "owner password.",
    ],
    "https://qpdf.readthedocs.io/en/stable/cli.html": [
        "Create an output file with no encryption even if the input file is encrypted.",
        "This option shows document encryption parameters.",
        "This functionality is not intended to be used for bypassing copyright restrictions or other "
        "restrictions placed on files by their producers.",
    ],
    "https://raw.githubusercontent.com/mozilla/pdf.js/master/README.md": [
        "PDF.js is built into version 19+ of Firefox.",
    ],
}
LOCKED = 'const LOCKED_PDF_MESSAGE = "This PDF is locked with a password.";'
UPLOADS = "apps/api/src/uploads/uploads.service.ts"


def measure():
    with tempfile.TemporaryDirectory() as tmp:
        pdfs = Path(tmp) / "pdfs"
        subprocess.run(
            ["uv", "run", "--no-project", "--with", PIKEPDF, "python3", "-I", str(HERE / "make_pdfs.py"), str(pdfs)],
            check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
        variants = {v["id"]: v for v in json.loads((pdfs / "variants.json").read_text())["variants"]}
        out = Path(tmp) / "locked.json"
        env = dict(os.environ, LOCKED_DIR=str(pdfs), LOCKED_OUT=str(out))
        subprocess.run(
            ["npx", "vitest", "run", "--config", str(HERE / "harness.vitest.config.mjs")],
            cwd=REPO, env=env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
        data = json.loads(out.read_text())

    def yes(value):
        return "yes" if value else "no"

    rows = []
    for row in data["rows"]:
        variant = variants[row["id"]]
        decrypted = row["decrypted"]
        rows.append({
            "id": row["id"],
            "encryption": variant["label"],
            "open_password": "yes" if variant["user"] else "no",
            "pdfjs_opens_without_password": yes(row["pdfjs"]["opens"]),
            "pdfjs_reads_text": yes(row["pdfjs"]["readsLine"]),
            "evidilo_upload": "refused as locked" if row["evidilo"]["refusedAtUpload"] else "accepted",
            "evidilo_reads_text": yes(row["evidilo"]["readsLine"]),
            "after_decrypt": (
                "not encrypted" if variant["encryption"] is None
                else ("accepted, text read" if not decrypted["refusedAtUpload"] and decrypted["readsLine"]
                      else "still refused")
                + (", with the open password" if variant["encryption"] is not None and variant["decryptNeededPassword"] else "")
                if decrypted is not None else "not encrypted"
            ),
            "decrypt_needs_open_password": (
                "" if variant["encryption"] is None else yes(variant["decryptNeededPassword"])
            ),
        })
    return rows


def page_text(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=30) as resp:
        raw = resp.read().decode("utf-8", "replace")
    raw = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", " ", raw)
    text = html.unescape(re.sub(r"<[^>]+>", " ", raw))
    text = re.sub(r"\s+", " ", text).replace("’", "'")
    return re.sub(r" ([.,;:!?])", r"\1", text)


def check_quotes():
    """0 when every quote is found, 1 when one is gone, 2 when a page could not be read."""
    status = 0
    for url, quotes in QUOTES.items():
        try:
            text = page_text(url)
        except (urllib.error.URLError, TimeoutError, OSError) as error:
            print(f"UNREACHABLE {url}: {error}")
            status = max(status, 2)
            continue
        for quote in quotes:
            if quote not in text:
                print(f"FAIL quote gone from {url}: {quote!r}")
                status = 1
    return status


def check_upload_path():
    source = (REPO / UPLOADS).read_text()
    ok = True
    if LOCKED not in source:
        print("FAIL the upload path no longer carries the locked-PDF sentence")
        ok = False
    accept = source.find("async accept(")
    gate = source.find("const format = formatOf(part);", accept)
    charge = source.find("let charge: StatementCharge;", accept)
    if accept == -1 or gate == -1 or charge == -1 or not gate < charge:
        print("FAIL the PDF check no longer runs before the allowance is taken")
        ok = False
    return ok


def main():
    rows = measure()
    fields = list(rows[0].keys())
    target = HERE / "dataset.csv"
    if "--write" in sys.argv:
        with target.open("w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=fields)
            w.writeheader()
            w.writerows(rows)

    failed = not check_upload_path()
    with target.open(newline="") as f:
        stored = list(csv.DictReader(f))
    if stored != rows:
        failed = True
        stored_by_id = {r["id"]: r for r in stored}
        for row in rows:
            if stored_by_id.get(row["id"]) != row:
                print(f'FAIL {row["id"]}: now {row}, stored {stored_by_id.get(row["id"])}')
        if len(stored) != len(rows):
            print(f"FAIL {len(rows)} rows measured, {len(stored)} stored")

    quotes = check_quotes()
    if failed or quotes == 1:
        return 1
    if quotes == 2:
        return 2
    print(f"ok: {len(rows)} rows match dataset.csv, every quote found")
    return 0


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