"""Independent verifier for the Needset Markets public ledger.

This file depends only on the Python standard library and the `cryptography` package, so anyone
can download it on its own and check the ledger without trusting the server that published it:

    pip install cryptography
    python verify.py https://needset.ai/markets/api/ledger.jsonl \\
        --public-key https://needset.ai/keys/markets-ledger.json

`--public-key` takes the key Needset publishes in the needset.ai site files (the Markets server
does not serve that copy), a downloaded copy of it, or the bare base64 key. `--expect-key-id`
additionally pins the key id.

Every ledger entry is a JSON object with the fields `seq`, `at`, `mode`, `kind`, `cycle_id`,
`prev_hash`, `body`, `hash` and `signature`. For each entry the verifier checks that:

1. `seq` counts up from 1 with no gaps;
2. `prev_hash` equals the previous entry's `hash` (the first entry links to 64 zeros);
3. `hash` is the SHA-256 of the canonical JSON of the seven fields before it;
4. `signature` is a valid Ed25519 signature, by the published key, over the canonical JSON of the
   entry without its `signature` field.

Canonical JSON is UTF-8 with keys sorted, no insignificant whitespace and non-ASCII characters
kept as they are. Any edited, removed, inserted or reordered entry fails one of these checks.
The signature format is the one Needset uses for signed plans and proofs.
"""

from __future__ import annotations

import argparse
import base64
import hashlib
import json
import sys
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Mapping

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

ALGORITHM = "Ed25519"
SIGNATURE_FIELD = "signature"
GENESIS_HASH = "0" * 64
HASHED_FIELDS = ("seq", "at", "mode", "kind", "cycle_id", "prev_hash", "body")


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


def b64encode(raw: bytes) -> str:
    return base64.b64encode(raw).decode("ascii")


def b64decode(text: str) -> bytes:
    return base64.b64decode(text.encode("ascii"), validate=True)


def key_id_for(public_key_raw: bytes) -> str:
    return "ed25519:" + hashlib.sha256(public_key_raw).hexdigest()[:16]


def entry_hash(entry: Mapping[str, Any]) -> str:
    return hashlib.sha256(canonical({name: entry[name] for name in HASHED_FIELDS})).hexdigest()


def unsigned(document: Mapping[str, Any]) -> dict[str, Any]:
    return {k: v for k, v in document.items() if k != SIGNATURE_FIELD}


def load_public_key(public_key: Mapping[str, Any] | str) -> tuple[Ed25519PublicKey, bytes]:
    """Accepts the JSON object the ledger publishes ({"public_key": "<base64>", ...}) or the bare
    base64 string of the 32-byte raw key."""
    raw_text = public_key["public_key"] if isinstance(public_key, Mapping) else public_key
    raw = b64decode(str(raw_text).strip())
    return Ed25519PublicKey.from_public_bytes(raw), raw


def signature_errors(document: Mapping[str, Any], key: Ed25519PublicKey, key_id: str) -> list[str]:
    signature = document.get(SIGNATURE_FIELD)
    if not isinstance(signature, Mapping):
        return ["entry carries no signature"]
    errors: list[str] = []
    if signature.get("alg") != ALGORITHM:
        errors.append(f"unsupported signature algorithm {signature.get('alg')!r}")
    if signature.get("key_id") != key_id:
        errors.append("signature key_id does not match the public key")
    try:
        key.verify(b64decode(str(signature.get("value", ""))), canonical(unsigned(document)))
    except (InvalidSignature, ValueError, TypeError):
        errors.append("signature does not verify over the entry")
    return errors


@dataclass
class VerificationReport:
    valid: bool
    entries: int
    key_id: str
    head_hash: str
    first_bad_seq: int | None = None
    errors: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {"valid": self.valid, "entries": self.entries, "key_id": self.key_id, "head_hash": self.head_hash,
                "first_bad_seq": self.first_bad_seq, "errors": list(self.errors)}


def verify_entries(entries: Iterable[Mapping[str, Any]], public_key: Mapping[str, Any] | str,
                   start_seq: int = 1, start_prev_hash: str = GENESIS_HASH) -> VerificationReport:
    """Verify entries in order. `start_seq` and `start_prev_hash` let a verifier that already
    checked a prefix continue from where it stopped."""
    try:
        key, raw = load_public_key(public_key)
    except (ValueError, TypeError, KeyError) as exc:
        return VerificationReport(False, 0, "", start_prev_hash, None, [f"public key unusable: {exc}"])
    key_id = key_id_for(raw)
    expected_seq = start_seq
    prev = start_prev_hash
    count = 0
    for document in entries:
        count += 1
        seq = document.get("seq")
        missing = [name for name in (*HASHED_FIELDS, "hash") if name not in document]
        if missing:
            return VerificationReport(False, count, key_id, prev, seq if isinstance(seq, int) else None,
                                      [f"entry is missing {', '.join(missing)}"])
        problems: list[str] = []
        if seq != expected_seq:
            problems.append(f"expected seq {expected_seq}, found {seq}")
        if document["prev_hash"] != prev:
            problems.append("prev_hash does not link to the previous entry")
        if entry_hash(document) != document["hash"]:
            problems.append("hash does not match the entry contents")
        problems.extend(signature_errors(document, key, key_id))
        if problems:
            return VerificationReport(False, count, key_id, prev, seq if isinstance(seq, int) else None, problems)
        prev = str(document["hash"])
        expected_seq += 1
    return VerificationReport(True, count, key_id, prev)


def _read_source(source: str) -> str:
    if source.startswith(("https://", "http://")):
        request = urllib.request.Request(source, headers={"User-Agent": "needset-markets-verify/1"})
        with urllib.request.urlopen(request, timeout=60) as response:  # noqa: S310 - caller-chosen URL
            return response.read().decode("utf-8")
    return Path(source).read_text(encoding="utf-8")


def parse_jsonl(text: str) -> list[dict[str, Any]]:
    return [json.loads(line) for line in text.splitlines() if line.strip()]


def parse_public_key(text: str) -> Mapping[str, Any] | str:
    stripped = text.strip()
    if stripped.startswith("{"):
        return json.loads(stripped)
    return stripped


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Verify the Needset Markets signed ledger.")
    parser.add_argument("ledger", help="Path or URL of the ledger export (one JSON entry per line)")
    parser.add_argument("--public-key", required=True,
                        help="Path or URL of the public key JSON, or the base64 key itself")
    parser.add_argument("--expect-key-id", help="Fail unless the public key has this key id")
    args = parser.parse_args(argv)

    source = args.public_key
    from_location = source.startswith(("https://", "http://")) or Path(source).is_file()
    public_key = parse_public_key(_read_source(source) if from_location else source)
    entries = parse_jsonl(_read_source(args.ledger))
    report = verify_entries(entries, public_key)
    if args.expect_key_id and report.key_id != args.expect_key_id:
        report.valid = False
        report.errors.append(f"public key id {report.key_id} is not the expected {args.expect_key_id}")
    print(json.dumps(report.to_dict(), indent=2))
    if report.valid:
        print(f"OK: {report.entries} entries verified; chain head {report.head_hash}")
        return 0
    print(f"FAILED at seq {report.first_bad_seq}: {'; '.join(report.errors)}", file=sys.stderr)
    return 1


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