#!/usr/bin/env python3
"""Check Bouraq evidence without Bouraq: standard library only, no network.

    python3 bouraq_verify.py audit bouraq-audit.jsonl
    python3 bouraq_verify.py receipt bouraq-receipt-<id>.json --key <base64> [--file the-file]
    python3 bouraq_verify.py prism bouraq-prism-<account>.html --release bouraq-prism.json --key <base64>

`audit` recomputes every entry's hash from its fields and checks each link to
the previous entry. A full export (from the administrator) must be one
unbroken chain; an account's own export ("my activity") has gaps where other
accounts' entries were, so each entry is checked on its own and every link
that can be checked is.

`receipt` checks the Ed25519 signature against the instance's public key (from
https://<instance>/.well-known/bouraq-receipt-key; keep a copy with your
evidence), and with --file that the file's size and sha256 match the receipt.

`prism` checks the release statement's signature, removes the copy's one
account line and compares the rest with the signed template's sha256: the copy
is the released code, byte for byte. It prints whose copy it is, never its key.

Exit status 0 when everything checks, 1 otherwise.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import sys
from pathlib import Path

AUDIT_FIELDS = ("seq", "at", "event", "user_id", "username", "session_id", "filename", "size",
                "sha256", "pages", "key_version", "detail", "prev_hash")

# ── Ed25519 verification (RFC 8032, section 6 reference) ──

_P = 2 ** 255 - 19
_Q = 2 ** 252 + 27742317777372353535851937790883648493


def _inv(x: int) -> int:
    return pow(x, _P - 2, _P)


_D = -121665 * _inv(121666) % _P
_SQRT_M1 = pow(2, (_P - 1) // 4, _P)


def _add(a, b):
    A = (a[1] - a[0]) * (b[1] - b[0]) % _P
    B = (a[1] + a[0]) * (b[1] + b[0]) % _P
    C = 2 * a[3] * b[3] * _D % _P
    D = 2 * a[2] * b[2] % _P
    E, F, G, H = B - A, D - C, D + C, B + A
    return (E * F, G * H, F * G, E * H)


def _mul(s: int, point):
    result = (0, 1, 1, 0)
    while s > 0:
        if s & 1:
            result = _add(result, point)
        point = _add(point, point)
        s >>= 1
    return result


def _equal(a, b) -> bool:
    return (a[0] * b[2] - b[0] * a[2]) % _P == 0 and (a[1] * b[2] - b[1] * a[2]) % _P == 0


def _recover_x(y: int, sign: int):
    if y >= _P:
        return None
    x2 = (y * y - 1) * _inv(_D * y * y + 1)
    if x2 == 0:
        return None if sign else 0
    x = pow(x2, (_P + 3) // 8, _P)
    if (x * x - x2) % _P:
        x = x * _SQRT_M1 % _P
    if (x * x - x2) % _P:
        return None
    return _P - x if (x & 1) != sign else x


_GY = 4 * _inv(5) % _P
_GX = _recover_x(_GY, 0)
_G = (_GX, _GY, 1, _GX * _GY % _P)


def _decompress(s: bytes):
    if len(s) != 32:
        return None
    y = int.from_bytes(s, "little")
    sign, y = y >> 255, y & ((1 << 255) - 1)
    x = _recover_x(y, sign)
    return None if x is None else (x, y, 1, x * y % _P)


def ed25519_verify(public: bytes, message: bytes, signature: bytes) -> bool:
    if len(public) != 32 or len(signature) != 64:
        return False
    a, r = _decompress(public), _decompress(signature[:32])
    if a is None or r is None:
        return False
    s = int.from_bytes(signature[32:], "little")
    if s >= _Q:
        return False
    h = int.from_bytes(hashlib.sha512(signature[:32] + public + message).digest(), "little") % _Q
    return _equal(_mul(s, _G), _add(r, _mul(h, a)))


# ── Bouraq formats ──

def canonical(obj: dict) -> bytes:
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def entry_hash(entry: dict) -> str:
    return hashlib.sha256(canonical({k: entry[k] for k in AUDIT_FIELDS})).hexdigest()


def load_entries(path: Path) -> list[dict]:
    text = path.read_text(encoding="utf-8")
    if text.lstrip().startswith("{") and '"entries"' in text[:2000]:
        return json.loads(text)["entries"]          # the admin JSON view
    return [json.loads(line) for line in text.splitlines() if line.strip()]


def check_audit(entries: list[dict]) -> tuple[bool, list[str]]:
    report, ok = [], True
    if not entries:
        return False, ["no entries"]
    links = gaps = 0
    previous = None
    for e in entries:
        if entry_hash(e) != e.get("hash"):
            ok = False
            report.append(f"entry {e.get('seq')}: hash does not match its fields (edited)")
        if previous is not None:
            if e["seq"] == previous["seq"] + 1:
                links += 1
                if e["prev_hash"] != previous["hash"]:
                    ok = False
                    report.append(f"entry {e['seq']}: does not follow entry {previous['seq']} (removed or reordered)")
            elif e["seq"] <= previous["seq"]:
                ok = False
                report.append(f"entry {e['seq']}: out of order after {previous['seq']}")
            else:
                gaps += 1
        previous = e
    report.append(f"{len(entries)} entries, seq {entries[0]['seq']} to {entries[-1]['seq']}: "
                  f"{links} links checked, {gaps} gaps (other accounts' entries, or a filtered export)")
    return ok, report


def check_receipt(receipt: dict, public_key_b64: str, file: Path | None = None) -> tuple[bool, list[str]]:
    report, ok = [], True
    try:
        public = base64.b64decode(public_key_b64, validate=True)
        signature = base64.b64decode(receipt.get("signature", ""), validate=True)
    except ValueError:
        return False, ["key or signature is not valid base64"]
    body = {k: v for k, v in receipt.items() if k != "signature"}
    if ed25519_verify(public, canonical(body), signature):
        report.append("signature valid: the receipt is unchanged since the instance signed it")
    else:
        ok = False
        report.append("signature INVALID: the receipt was changed or signed by another key")
    if file is not None:
        data = file.read_bytes()
        expected = receipt.get("file", {})
        if len(data) == expected.get("size") and hashlib.sha256(data).hexdigest() == expected.get("sha256"):
            report.append(f"file matches: {len(data)} bytes, sha256 {expected.get('sha256')}")
        else:
            ok = False
            report.append("file does NOT match the receipt's size and sha256")
    return ok, report


def unpersonalize(data: bytes) -> tuple[bytes, dict]:
    """A personalized Prism copy minus its one account line: the template bytes.
    Mirrors webapp/encoder_issue.unpersonalize (JSON parsed, not pattern-matched)."""
    head = b"window.BOURAQ_ACCOUNT = "
    start = data.find(head)
    if start < 0 or data.find(head, start + 1) >= 0:
        raise ValueError("not a personalized Prism copy")
    tail = data[start + len(head):]
    account, end = json.JSONDecoder().raw_decode(tail.decode("latin-1"))
    if tail[end:end + 1] != b";" or not isinstance(account, dict):
        raise ValueError("not a personalized Prism copy")
    return data[:start] + b"/*__BOURAQ_ACCOUNT__*/" + tail[end + 1:], account


def check_prism(copy: bytes, release: dict, public_key_b64: str) -> tuple[bool, list[str]]:
    """The copy is the signed release's code, byte for byte, plus one account line."""
    ok, report = check_receipt(release, public_key_b64)
    report = [line.replace("the receipt", "the release statement") for line in report]
    try:
        template, account = unpersonalize(copy)
    except ValueError as exc:
        return False, report + [str(exc)]
    sha = hashlib.sha256(template).hexdigest()
    if sha == release.get("template_sha256"):
        report.append(f"code matches Prism {release.get('version')} (template sha256 {sha})")
    else:
        ok = False
        report.append("code does NOT match the signed release: this copy was modified")
    # Never print the account key itself.
    report.append(f"personalized for {account.get('username')} · key v{account.get('key_version')} · "
                  f"{account.get('server')}")
    return ok, report


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    sub = parser.add_subparsers(dest="cmd", required=True)
    a = sub.add_parser("audit", help="check an audit export (JSON Lines, or the admin JSON view)")
    a.add_argument("path", type=Path)
    r = sub.add_parser("receipt", help="check a signed transfer receipt")
    r.add_argument("path", type=Path)
    r.add_argument("--key", required=True, help="public_key from /.well-known/bouraq-receipt-key (base64)")
    r.add_argument("--file", type=Path, help="the delivered file, to compare size and sha256")
    pr = sub.add_parser("prism", help="check a Prism copy against the signed release statement")
    pr.add_argument("path", type=Path)
    pr.add_argument("--release", type=Path, required=True,
                    help="the saved https://<instance>/.well-known/bouraq-prism.json")
    pr.add_argument("--key", required=True, help="public_key from /.well-known/bouraq-receipt-key (base64)")
    args = parser.parse_args(argv)
    if args.cmd == "prism":
        ok, report = check_prism(args.path.read_bytes(), json.loads(args.release.read_text(encoding="utf-8")), args.key)
        print("\n".join(report))
        print("OK" if ok else "FAILED")
        return 0 if ok else 1
    if args.cmd == "audit":
        ok, report = check_audit(load_entries(args.path))
    else:
        ok, report = check_receipt(json.loads(args.path.read_text(encoding="utf-8")), args.key, args.file)
    print("\n".join(report))
    print("OK" if ok else "FAILED")
    return 0 if ok else 1


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