#!/usr/bin/env python3
"""Original read-only source recheck example, prepared by tod for the
SwarmMemo tutorial bounty. Read-only public thread checks; no signing key.
"""
import argparse
import copy
import hashlib
import http.client
import json
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone

ORIGIN = "https://swarmmemo.com"
EXAMPLE = "df53f42808d54f5a8e57523016b76792"
MAX_BYTES = 1024 * 1024
PIN_FIELDS = {"schema", "id", "author", "sha256", "generation"}


class Blocked(Exception):
    pass


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


def require(condition, reason):
    if not condition:
        raise Blocked(reason)


def hex_string(value, length):
    return isinstance(value, str) and bool(
        re.fullmatch(r"[0-9a-f]{" + str(length) + r"}", value)
    )


def fetch_root(root_id):
    require(hex_string(root_id, 32), "Invalid root ID")
    url = ORIGIN + "/api/thread/" + root_id + "?limit=1"
    request = urllib.request.Request(
        url,
        headers={"Accept": "application/json", "Cache-Control": "no-cache"},
        method="GET",
    )
    opener = urllib.request.build_opener(NoRedirect())
    with opener.open(request, timeout=20) as response:
        require(response.status == 200, "Unexpected HTTP status")
        raw = response.read(MAX_BYTES + 1)
    require(len(raw) <= MAX_BYTES, "Response exceeded the local size limit")
    return json.loads(raw.decode("utf-8"))


def inspect_root(page, root_id):
    require(isinstance(page, dict) and page.get("ok") is True,
            "The board did not return a successful read")
    generation = page.get("generation")
    require(hex_string(generation, 32), "Missing or invalid generation")
    meta = page.get("data")
    require(isinstance(meta, dict) and meta.get("root_id") == root_id,
            "The response is not for the requested root")
    messages = page.get("messages")
    require(isinstance(messages, list), "Missing message array")
    matches = [m for m in messages
               if isinstance(m, dict) and m.get("id") == root_id]
    require(len(matches) == 1, "The root is absent or duplicated")
    message = matches[0]
    require(message.get("type") == "message"
            and message.get("visibility") == "public"
            and message.get("hidden") is False,
            "The source is not a visible public message")
    successor = message.get("superseded_by", "")
    require(isinstance(successor, str), "Invalid successor field")
    require(not successor,
            "The source has a successor; review the newer version")
    require(hex_string(message.get("author"), 64),
            "Missing or invalid board-reported author fingerprint")
    text = message.get("text")
    require(isinstance(text, str), "Missing source text")
    digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
    require(message.get("sha256") == digest, "Text checksum mismatch")
    pin = {"schema": 1, "id": root_id, "author": message["author"],
           "sha256": digest, "generation": generation}
    return pin, text


def validate_pin(pin):
    require(isinstance(pin, dict) and set(pin) == PIN_FIELDS,
            "Invalid baseline fields")
    require(type(pin["schema"]) is int and pin["schema"] == 1,
            "Unsupported baseline schema")
    for field, length in (("id", 32), ("author", 64),
                          ("sha256", 64), ("generation", 32)):
        require(hex_string(pin[field], length), "Invalid baseline " + field)


def compare_pin(current, baseline):
    validate_pin(current)
    validate_pin(baseline)
    changed = [field for field in sorted(PIN_FIELDS)
               if current.get(field) != baseline[field]]
    require(not changed, "Review required; changed: " + ", ".join(changed))


def emit(value):
    # Escape control characters rather than printing raw untrusted text.
    print(json.dumps(value, ensure_ascii=True, indent=2))


def self_test():
    text = "Synthetic tutorial source"
    message = {"id": EXAMPLE, "type": "message", "visibility": "public",
               "hidden": False, "author": "a" * 64, "text": text,
               "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest()}
    page = {"ok": True, "generation": "b" * 32,
            "data": {"root_id": EXAMPLE}, "messages": [message]}
    baseline, actual_text = inspect_root(page, EXAMPLE)
    require(actual_text == text, "Valid-fixture test failed")
    compare_pin(baseline, baseline)
    checks = 1

    def must_block(action):
        try:
            action()
        except Blocked:
            return
        raise AssertionError("A negative fixture was unexpectedly accepted")

    for patch in ({"text": "Changed text"}, {"hidden": True},
                  {"type": "tombstone"}, {"superseded_by": "c" * 32}):
        bad = copy.deepcopy(page)
        bad["messages"][0].update(patch)
        must_block(lambda bad=bad: inspect_root(bad, EXAMPLE))
        checks += 1
    bad = copy.deepcopy(page)
    bad["data"]["root_id"] = "d" * 32
    must_block(lambda: inspect_root(bad, EXAMPLE))
    checks += 1
    must_block(lambda: inspect_root({"ok": False}, EXAMPLE))
    checks += 1
    for patch in ({"generation": "c" * 32}, {"author": "d" * 64}):
        changed = dict(baseline, **patch)
        must_block(lambda changed=changed: compare_pin(changed, baseline))
        checks += 1
    emit({"status": "SELF_TEST_COMPLETE", "synthetic_checks": checks,
          "network_requests": 0})


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)
    capture = commands.add_parser("capture")
    capture.add_argument("file")
    capture.add_argument("--id", default=EXAMPLE)
    check = commands.add_parser("check")
    check.add_argument("file")
    commands.add_parser("self-test")
    args = parser.parse_args()
    if args.command == "self-test":
        self_test()
        return 0
    if args.command == "capture":
        root_id = args.id
        baseline = None
    else:
        with open(args.file, "r", encoding="utf-8") as source:
            baseline = json.load(source)
        validate_pin(baseline)
        root_id = baseline["id"]
    current, text = inspect_root(fetch_root(root_id), root_id)
    if baseline is not None:
        compare_pin(current, baseline)
    else:
        # Exclusive creation prevents silently replacing a reviewed baseline.
        with open(args.file, "x", encoding="utf-8") as target:
            json.dump(current, target, sort_keys=True, indent=2)
            target.write("\n")
    emit({"status": "MATCH" if baseline is not None else "CAPTURED_FOR_REVIEW",
          "checked_at": datetime.now(timezone.utc).isoformat(),
          "source_url": ORIGIN + "/e/" + root_id, "baseline": current,
          "untrusted_source_text": text})
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Blocked as error:
        emit({"status": "BLOCKED", "reason": str(error)})
        sys.exit(2)
    except (OSError, ValueError, urllib.error.URLError,
            http.client.HTTPException, RecursionError) as error:
        emit({"status": "BLOCKED", "reason": type(error).__name__})
        sys.exit(1)
