#!/usr/bin/env python3
"""Record a final file's SHA-256 fingerprint with ClearedBadge, and check a file against a record.

    python record_file.py record report.pdf
    python record_file.py verify report.pdf <record_id>

record:
  1. Computes SHA-256 over the exact bytes of the file, locally. The file is never sent.
  2. Sends only {"sha256": "<64 hex>"} to POST /record-file-hash (public, no account, no key;
     small limits per connection and per day).
  3. On success saves the engine's answer unchanged as <file>.clrbadge-receipt.json and prints the
     record ID and the Verify link: https://clrbadge-web.vercel.app/verify?id=<record_id>

  Never records blindly twice. Before sending it writes <file>.clrbadge-attempt.json. If the
  outcome is unknown (timeout, dropped connection, 5xx, unreadable answer), a record may exist and
  later runs refuse to send again; the user decides. If a receipt for this exact file already
  exists, it is shown again and nothing is sent.

verify:
  Sends the file to POST /verify-file?id=<record_id>, which compares its SHA-256 with the record and
  stores nothing. (The Verify page does the same comparison inside the recipient's browser.)

What a match shows: the file has exactly the bytes whose fingerprint was recorded, at the recorded
time. Not that its content is true, who made or sent it, or that anyone received it. New records are
currently not anchored on Arweave: the recorded time is ClearedBadge's own.

Exit codes: 0 done (recorded, receipt shown again, or compared), 2 input problem (nothing sent),
3 not recorded (refused or no connection; nothing recorded), 4 outcome unknown (do not simply run
again), 5 an earlier attempt or receipt blocks sending.

Standard library only. Python 3.8+. Set CLRBADGE_API to use another engine (for local tests).
"""

import argparse
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone

API = os.environ.get("CLRBADGE_API", "https://clrbadge-seven.vercel.app").rstrip("/")
VERIFY_PAGE = "https://clrbadge-web.vercel.app/verify?id="
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
HASH_RE = re.compile(r"^[0-9a-f]{64}$")
TIMEOUT_SECONDS = 30


def sha256_of(path):
    digest = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            digest.update(chunk)
    return digest.hexdigest()


def write_json(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")


def record(path):
    if not os.path.isfile(path):
        print(f"Not a file: {path}. Nothing was sent.")
        return 2
    receipt_path = path + ".clrbadge-receipt.json"
    attempt_path = path + ".clrbadge-attempt.json"
    fingerprint = sha256_of(path)

    if os.path.exists(receipt_path):
        with open(receipt_path, "rb") as f:
            saved = json.loads(f.read())
        if saved.get("hash") == fingerprint:
            print(f"Already recorded: {saved.get('badge_id')}\nVerify link: {VERIFY_PAGE}{saved.get('badge_id')}")
            print("Nothing was sent.")
            return 0
        print(f"{receipt_path} belongs to other bytes: this file changed after it was recorded. Nothing was sent.\n"
              "Recording the changed version is a new record and the user's decision: move the old receipt aside first.")
        return 5
    if os.path.exists(attempt_path):
        with open(attempt_path, encoding="utf-8") as f:
            attempt = json.load(f)
        if attempt.get("outcome") in (None, "unknown"):
            print(f"An earlier attempt ({attempt.get('attempt_id')}) has an unknown outcome: a record may exist.\n"
                  "Nothing was sent. Do not retry blindly; check first, and let the user decide.")
            return 5

    attempt = {"attempt_id": str(uuid.uuid4()), "sha256": fingerprint, "started_at": now(), "outcome": None}
    write_json(attempt_path, attempt)
    body = json.dumps({"sha256": fingerprint}).encode("utf-8")
    request = urllib.request.Request(API + "/record-file-hash", data=body, method="POST",
                                     headers={"Content-Type": "text/plain;charset=UTF-8"})
    status, raw = None, b""
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
            status, raw = response.status, response.read()
    except urllib.error.HTTPError as err:
        status, raw = err.code, err.read()
    except urllib.error.URLError as err:
        # No connection was made (DNS, refused): the request did not reach the engine.
        if not isinstance(getattr(err, "reason", None), TimeoutError) and "timed out" not in str(err).lower():
            attempt.update(outcome="not_sent", detail=str(err)[:200], finished_at=now())
            write_json(attempt_path, attempt)
            print(f"No connection to {API}: {err}. Nothing was recorded.")
            return 3
    except Exception as err:  # noqa: BLE001  (timeout or dropped connection after sending)
        raw = str(err).encode()

    answer = None
    try:
        answer = json.loads(raw) if raw else None
    except ValueError:
        pass

    if status == 201 and isinstance(answer, dict) and UUID_RE.match(str(answer.get("badge_id", ""))) \
            and answer.get("hash") == fingerprint:
        with open(receipt_path, "wb") as f:
            f.write(raw)  # the engine's answer, byte for byte: the receipt
        attempt.update(outcome="recorded", record_id=answer["badge_id"], finished_at=now())
        write_json(attempt_path, attempt)
        print(f"Recorded: {answer['badge_id']} at {answer.get('created_at')}\n"
              f"SHA-256: {fingerprint}\nVerify link: {VERIFY_PAGE}{answer['badge_id']}\n"
              f"Receipt (the engine's answer, unchanged): {receipt_path}\n"
              "ClearedBadge registered this fingerprint; it never received the file. Keep the file unchanged.")
        return 0
    if status in (400, 403, 413, 429, 503) and isinstance(answer, dict):
        attempt.update(outcome="refused", http_status=status, detail=str(answer)[:200], finished_at=now())
        write_json(attempt_path, attempt)
        reason = {429: "limit reached; try again later", 403: "registration is paused",
                  503: "registration temporarily unavailable"}.get(status, answer.get("error", "refused"))
        print(f"Not recorded (HTTP {status}: {reason}). Nothing was recorded.")
        return 3
    attempt.update(outcome="unknown", http_status=status, finished_at=now())
    write_json(attempt_path, attempt)
    print("Outcome unknown" + (f" (HTTP {status})" if status else " (no complete answer)") +
          ": a record may exist. Nothing is sent again automatically; later runs refuse until the user decides.")
    return 4


def verify(path, record_id):
    if not os.path.isfile(path) or not UUID_RE.match(record_id):
        print("Give an existing file and a record ID (a UUID). Nothing was sent.")
        return 2
    boundary = "clrbadge" + uuid.uuid4().hex
    with open(path, "rb") as f:
        data = f.read()
    head = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
            "Content-Type: application/octet-stream\r\n\r\n").encode()
    body = head + data + f"\r\n--{boundary}--\r\n".encode()
    request = urllib.request.Request(f"{API}/verify-file?id={record_id}", data=body, method="POST",
                                     headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
            answer = json.loads(response.read())
    except urllib.error.HTTPError as err:
        print(f"Could not compare (HTTP {err.code}): {err.read()[:200]!r}")
        return 3
    result = answer.get("result")
    print(f"{result}: file SHA-256 {answer.get('hash')} vs record {record_id}")
    return 0 if result in ("match", "differs", "not_found") else 3


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("record").add_argument("file")
    v = sub.add_parser("verify")
    v.add_argument("file")
    v.add_argument("record_id")
    args = parser.parse_args(argv)
    return record(args.file) if args.command == "record" else verify(args.file, args.record_id)


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