#!/usr/bin/env python3
"""Prepare and reveal a five-image double-blind remote-viewing test.

No third-party Python packages are required.

Examples:
    python rv_test.py prepare
    python rv_test.py prepare --local /path/to/candidate-images
    python rv_test.py reveal
"""

from __future__ import annotations

import argparse
import hashlib
import json
import mimetypes
import os
import secrets
import shutil
import string
import sys
import urllib.parse
import urllib.request
import webbrowser
from datetime import datetime, timezone
from pathlib import Path


IMAGE_COUNT = 5
STATE_VERSION = 1
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
USER_AGENT = "Joost-RV-double-blind-test/1.0 (educational experiment)"
ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
ALLOWED_MIMES = {
    "image/jpeg": ".jpg",
    "image/png": ".png",
    "image/webp": ".webp",
    "image/gif": ".gif",
    "image/bmp": ".bmp",
    "image/svg+xml": ".svg",
}


class RVError(RuntimeError):
    """A user-facing error."""


def canonical_bytes(value: object) -> bytes:
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def target_code(existing: set[str]) -> str:
    alphabet = string.ascii_uppercase
    while True:
        code = f"{''.join(secrets.choice(alphabet) for _ in range(2))}{secrets.randbelow(10)}-{secrets.randbelow(1000):03d}"
        if code not in existing:
            return code


def private_permissions(path: Path, directory: bool = False) -> None:
    """Best-effort owner-only permissions (effective on POSIX systems)."""
    try:
        path.chmod(0o700 if directory else 0o600)
    except OSError:
        pass


def load_local_candidates(folder: Path) -> list[dict[str, object]]:
    if not folder.is_dir():
        raise RVError(f"Lokale map bestaat niet of is geen map: {folder}")
    files = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in ALLOWED_SUFFIXES]
    if len(files) < IMAGE_COUNT:
        raise RVError(f"Minstens {IMAGE_COUNT} ondersteunde afbeeldingen nodig; gevonden: {len(files)}")
    chosen = secrets.SystemRandom().sample(files, IMAGE_COUNT)
    return [
        {
            "data": path.read_bytes(),
            "source_name": path.name,
            "source_url": None,
            "suffix": ".jpg" if path.suffix.lower() == ".jpeg" else path.suffix.lower(),
        }
        for path in chosen
    ]


def fetch_json(url: str) -> dict[str, object]:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)


def download_limited(url: str, max_bytes: int = 25 * 1024 * 1024) -> tuple[bytes, str]:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=45) as response:
        content_type = response.headers.get_content_type().lower()
        chunks: list[bytes] = []
        total = 0
        while True:
            chunk = response.read(1024 * 1024)
            if not chunk:
                break
            total += len(chunk)
            if total > max_bytes:
                raise RVError("Afbeelding is groter dan de limiet van 25 MB")
            chunks.append(chunk)
    return b"".join(chunks), content_type


def load_commons_candidates() -> list[dict[str, object]]:
    """Fetch five random, browser-viewable images from Wikimedia Commons."""
    found: list[dict[str, object]] = []
    seen: set[str] = set()
    params = {
        "action": "query",
        "generator": "random",
        "grnnamespace": "6",
        "grnlimit": "40",
        "prop": "imageinfo",
        "iiprop": "url|mime",
        "iiurlwidth": "1600",
        "format": "json",
        "formatversion": "2",
    }
    api_url = COMMONS_API + "?" + urllib.parse.urlencode(params)

    for _attempt in range(8):
        payload = fetch_json(api_url)
        pages = payload.get("query", {}).get("pages", [])  # type: ignore[union-attr]
        secrets.SystemRandom().shuffle(pages)
        for page in pages:
            title = str(page.get("title", ""))
            info_list = page.get("imageinfo") or []
            if title in seen or not info_list:
                continue
            info = info_list[0]
            mime = str(info.get("mime", "")).lower()
            if mime not in ALLOWED_MIMES:
                continue
            url = str(info.get("thumburl") or info.get("url") or "")
            if not url:
                continue
            seen.add(title)
            try:
                data, response_mime = download_limited(url)
            except (OSError, RVError):
                continue
            effective_mime = response_mime if response_mime in ALLOWED_MIMES else mime
            found.append(
                {
                    "data": data,
                    "source_name": title.removeprefix("File:"),
                    "source_url": str(info.get("descriptionurl") or info.get("url") or url),
                    "suffix": ALLOWED_MIMES[effective_mime],
                }
            )
            if len(found) == IMAGE_COUNT:
                return found
    raise RVError("Kon geen 5 geschikte Commons-afbeeldingen downloaden. Probeer opnieuw of gebruik --local MAP.")


def state_paths(state_dir: Path) -> tuple[Path, Path, Path]:
    return state_dir / "sealed", state_dir / ".mapping.json", state_dir / "commitment.txt"


def prepare(args: argparse.Namespace) -> None:
    state_dir = args.state_dir.resolve()
    sealed_dir, mapping_path, commitment_path = state_paths(state_dir)
    if state_dir.exists():
        raise RVError(
            f"Er staat al een test in {state_dir}. Reveal die eerst, of verplaats/verwijder de map bewust."
        )

    candidates = load_local_candidates(args.local.resolve()) if args.local else load_commons_candidates()
    # The candidate order is randomized independently from both selection and codes.
    secrets.SystemRandom().shuffle(candidates)
    codes: list[str] = []
    code_set: set[str] = set()
    for _ in range(IMAGE_COUNT):
        code = target_code(code_set)
        codes.append(code)
        code_set.add(code)

    state_dir.mkdir(parents=True)
    private_permissions(state_dir, directory=True)
    sealed_dir.mkdir()
    private_permissions(sealed_dir, directory=True)

    entries: list[dict[str, object]] = []
    try:
        for code, candidate in zip(codes, candidates):
            sealed_name = f"{secrets.token_hex(16)}.bin"
            sealed_path = sealed_dir / sealed_name
            data = candidate.pop("data")
            assert isinstance(data, bytes)
            sealed_path.write_bytes(data)
            private_permissions(sealed_path)
            entries.append(
                {
                    "code": code,
                    "sealed_name": sealed_name,
                    "source_name": candidate["source_name"],
                    "source_url": candidate["source_url"],
                    "suffix": candidate["suffix"],
                    "image_sha256": sha256_bytes(data),
                }
            )

        committed = {
            "version": STATE_VERSION,
            "test_id": secrets.token_hex(12),
            "created_utc": datetime.now(timezone.utc).isoformat(),
            "nonce": secrets.token_hex(32),
            "entries": entries,
        }
        commitment = sha256_bytes(canonical_bytes(committed))
        private_record = {"committed": committed, "commitment": commitment, "revealed": False}
        mapping_path.write_text(json.dumps(private_record, ensure_ascii=False, indent=2), encoding="utf-8")
        private_permissions(mapping_path)
        public_text = (
            "DOUBLE-BLIND RV TEST\n"
            + "\n".join(f"TARGET {index}: {code}" for index, code in enumerate(codes, 1))
            + f"\nSHA-256 COMMITMENT: {commitment}\n"
        )
        commitment_path.write_text(public_text, encoding="utf-8")
    except Exception:
        shutil.rmtree(state_dir, ignore_errors=True)
        raise

    print(public_text, end="")
    print("\nDe beelden en geheime mapping zijn verzegeld. Doe nu alle 5 sessies; reveal nog niets.")


def reveal(args: argparse.Namespace) -> None:
    state_dir = args.state_dir.resolve()
    sealed_dir, mapping_path, commitment_path = state_paths(state_dir)
    if not mapping_path.is_file() or not commitment_path.is_file():
        raise RVError(f"Geen voorbereide test gevonden in {state_dir}")

    record = json.loads(mapping_path.read_text(encoding="utf-8"))
    committed = record.get("committed")
    stored_commitment = str(record.get("commitment", ""))
    actual_commitment = sha256_bytes(canonical_bytes(committed))
    public_text = commitment_path.read_text(encoding="utf-8")
    if actual_commitment != stored_commitment or stored_commitment not in public_text:
        raise RVError("Commitmentcontrole mislukt: de mapping of commitment.txt is gewijzigd.")

    entries = committed.get("entries", [])
    if len(entries) != IMAGE_COUNT:
        raise RVError("Ongeldige mapping: er zijn niet precies 5 entries.")
    for entry in entries:
        path = sealed_dir / str(entry["sealed_name"])
        if not path.is_file() or sha256_file(path) != entry["image_sha256"]:
            raise RVError(f"Integriteitscontrole mislukt voor target {entry['code']}.")

    reveal_dir = state_dir / "revealed"
    reveal_dir.mkdir(exist_ok=True)
    private_permissions(reveal_dir, directory=True)
    opened: list[Path] = []
    print("COMMITMENT GELDIG:", actual_commitment)
    print("\nCODE -> AFBEELDING")
    for index, entry in enumerate(entries, 1):
        suffix = str(entry["suffix"])
        destination = reveal_dir / f"{index:02d}_{entry['code']}{suffix}"
        shutil.copyfile(sealed_dir / str(entry["sealed_name"]), destination)
        source = entry.get("source_url") or f"lokaal: {entry['source_name']}"
        print(f"{entry['code']} -> {destination.name} | bron: {source}")
        opened.append(destination)

    if not record.get("revealed"):
        record["revealed"] = True
        record["revealed_utc"] = datetime.now(timezone.utc).isoformat()
        mapping_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
        private_permissions(mapping_path)

    print(f"\nOnthulde kopieën staan in: {reveal_dir}")
    if not args.no_open:
        failures = sum(not webbrowser.open(path.as_uri(), new=2) for path in opened)
        if failures:
            print("Niet alle afbeeldingen konden automatisch worden geopend; open de revealed-map handmatig.")


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(description="Double-blind remote-viewing test met 5 afbeeldingen")
    result.add_argument(
        "--state-dir",
        type=Path,
        default=Path(__file__).resolve().parent / ".rv_test",
        help="verborgen testmap (standaard: .rv_test naast dit script)",
    )
    subparsers = result.add_subparsers(dest="command", required=True)
    prepare_parser = subparsers.add_parser("prepare", help="maak en verzegel een nieuwe test")
    prepare_parser.add_argument("--local", type=Path, metavar="MAP", help="gebruik kandidaatbeelden uit deze map")
    reveal_parser = subparsers.add_parser("reveal", help="controleer en onthul de test")
    reveal_parser.add_argument("--no-open", action="store_true", help="maak de beelden zichtbaar zonder browservensters")
    return result


def main() -> int:
    args = parser().parse_args()
    try:
        if args.command == "prepare":
            prepare(args)
        else:
            reveal(args)
        return 0
    except (RVError, OSError, json.JSONDecodeError) as exc:
        print(f"FOUT: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
