#!/usr/bin/env python3
"""Closed exporter and verifier for an independent-witness candidate.

The candidate proves only that one operator key signed a closed package whose
two retained Bulla release receipts, inclusion records, checkpoints, and
adjacent consistency proof verify. External organizational control remains a
separate review determination. A valid candidate is never counted by this
module.
"""

from __future__ import annotations

import base64
import hashlib
import json
import os
import re
from pathlib import Path, PurePosixPath
from typing import Any, Mapping

from bulla.action_receipt import verify_receipt
from bulla.experimental.checkpoint import (
    WitnessCheckpoint,
    verify_checkpoint,
    verify_checkpoint_extension,
)
from bulla.identity import LocalEd25519Signer, pubkey_from_did_key
from bulla.receipt_parser import ReceiptParseLimits, parse_action_receipt_json
from bulla.registry import Deed, _b2h, verify_inclusion_record

CANDIDATE_PROFILE = "glyph.independent-witness-candidate/0.1"
PACKAGE_CONTEXT = "glyph-independent-witness-candidate"
PACKAGE_PURPOSE = "candidate-root"
MAX_FILES = 16
MAX_MEMBER_BYTES = 1 * 1024 * 1024
MAX_AGGREGATE_BYTES = 4 * 1024 * 1024
MAX_DEPTH = 32
REQUIRED_MEMBERS = (
    "package.json",
    "operator-manifest.json",
    "source-closure.json",
    "retention-policy.json",
    "privacy-policy.json",
    "control-disclosure.json",
    "receipt-a.json",
    "receipt-b.json",
    "intake-a.json",
    "intake-b.json",
    "checkpoint-a.json",
    "checkpoint-b.json",
    "inclusion-a.json",
    "inclusion-b.json",
    "consistency-a-b.json",
)
DATA_MEMBERS = REQUIRED_MEMBERS[1:]
_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
_TIMESTAMP_RE = re.compile(
    r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$"
)
_RECEIPT_LIMITS = ReceiptParseLimits(max_bytes=MAX_MEMBER_BYTES)
_RELEASE_CARGO = {
    "0.47.1": {
        "byte_sha256": "sha256:1f943d58dcf011faa0e8cd4b39fc898772e646f525174f68798337d6dffa2038",
        "attestation": "sha256:97a593c03741bd28001e2ed50169e404d7158e672debb92c9ffa638cbfd68fd7",
    },
    "0.48.0": {
        "byte_sha256": "sha256:01fb3662eddd273970af40890490a185b93c9db681e96adb675d3ba5d35e8cd9",
        "attestation": "sha256:ef06ae9e993c970da0a521bd1da7e0925213de103e8c9c6baa59522fb03f4732",
    },
}
_RELEASE_ISSUER = "did:key:z6Mksw5XAZtcqTQ8aCA9eRJCz47SrVxDnNWBjxHQhYcLU5HH"
_MANIFEST_TEMPORAL_CLAIMS = {
    "received_at": "asserted by this witness at intake",
    "claimed_at": "actor-supplied; not bound into the signed occurrence identity under wire v0.2/v0.3",
}
_INCLUSION_FIELDS = {
    "index",
    "tree_size",
    "leaf",
    "proof",
    "root",
    "attestation_hash",
    "received_at",
    "verified_to",
}
_CONSISTENCY_FIELDS = {"old_size", "new_size", "proof", "old_root", "new_root"}


class CandidateError(ValueError):
    pass


def _reject_float(_: str) -> None:
    raise CandidateError("floats are not permitted")


def _reject_constant(_: str) -> None:
    raise CandidateError("non-finite JSON values are not permitted")


def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for key, value in pairs:
        if key in result:
            raise CandidateError(f"duplicate JSON member: {key}")
        result[key] = value
    return result


def _depth(value: Any, level: int = 1) -> int:
    if level > MAX_DEPTH:
        raise CandidateError(f"JSON nesting exceeds {MAX_DEPTH}")
    if isinstance(value, dict):
        for item in value.values():
            _depth(item, level + 1)
    elif isinstance(value, list):
        for item in value:
            _depth(item, level + 1)
    return level


def strict_json(raw: bytes, label: str) -> Any:
    if len(raw) > MAX_MEMBER_BYTES:
        raise CandidateError(f"{label} exceeds {MAX_MEMBER_BYTES} bytes")
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        raise CandidateError(f"{label} is not UTF-8") from exc
    try:
        value = json.loads(
            text,
            object_pairs_hook=_pairs,
            parse_float=_reject_float,
            parse_constant=_reject_constant,
        )
    except (json.JSONDecodeError, UnicodeError) as exc:
        raise CandidateError(f"{label} is not strict JSON: {exc}") from exc
    _depth(value)
    return value


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


def sha256(raw: bytes) -> str:
    return "sha256:" + hashlib.sha256(raw).hexdigest()


def _exact(value: Mapping[str, Any], fields: set[str], label: str) -> None:
    if not isinstance(value, Mapping) or set(value) != fields:
        raise CandidateError(f"{label} fields must be exactly {sorted(fields)}")


def _safe_uint(value: Any, label: str) -> int:
    if type(value) is not int or value < 0 or value > (2**53 - 1):
        raise CandidateError(f"{label} must be a non-negative safe integer")
    return value


def _hash_list(value: Any, label: str) -> None:
    if not isinstance(value, list) or any(
        not isinstance(item, str) or not _HASH_RE.fullmatch(item) for item in value
    ):
        raise CandidateError(f"{label} must be an array of canonical sha256 digests")


def _safe_member(path: str) -> None:
    pure = PurePosixPath(path)
    if (
        not path
        or path != path.lower()
        or "\\" in path
        or pure.is_absolute()
        or any(part in {"", ".", ".."} for part in pure.parts)
        or len(pure.parts) > MAX_DEPTH
        or str(pure) != path
    ):
        raise CandidateError(f"noncanonical candidate member: {path}")


def _regular_members(root: Path) -> dict[str, bytes]:
    if root.is_symlink() or not root.is_dir():
        raise CandidateError("candidate root must be a real directory")
    members: dict[str, bytes] = {}
    aggregate = 0
    entries: list[tuple[str, os.DirEntry[str]]] = []
    with os.scandir(root) as iterator:
        for entry in iterator:
            relative = entry.name
            _safe_member(relative)
            if entry.is_symlink():
                raise CandidateError(f"candidate contains a symlink: {relative}")
            if entry.is_dir(follow_symlinks=False):
                raise CandidateError(f"candidate contains a directory: {relative}")
            if not entry.is_file(follow_symlinks=False):
                raise CandidateError(f"candidate contains a non-regular member: {relative}")
            entries.append((relative, entry))
            if len(entries) > MAX_FILES:
                raise CandidateError(f"candidate exceeds {MAX_FILES} files")
    for relative, entry in sorted(entries, key=lambda item: item[0]):
        path = Path(entry.path)
        _safe_member(relative)
        size = entry.stat(follow_symlinks=False).st_size
        if size > MAX_MEMBER_BYTES:
            raise CandidateError(f"{relative} exceeds {MAX_MEMBER_BYTES} bytes")
        aggregate += size
        if aggregate > MAX_AGGREGATE_BYTES:
            raise CandidateError(f"candidate exceeds {MAX_AGGREGATE_BYTES} bytes")
        members[relative] = path.read_bytes()
    if tuple(sorted(members)) != tuple(sorted(REQUIRED_MEMBERS)):
        raise CandidateError("candidate member set is not the exact 15-member wire")
    return members


def _package_preimage(root: str) -> bytes:
    return canonical(
        {
            "context": PACKAGE_CONTEXT,
            "digest": root,
            "purpose": PACKAGE_PURPOSE,
            "schema": "0.1",
        }
    ).rstrip(b"\n")


def _sign_package(root: str, signer: LocalEd25519Signer) -> dict[str, str]:
    from nacl.signing import SigningKey

    signature = SigningKey(signer.seed).sign(_package_preimage(root)).signature
    return {
        "type": "Ed25519Signature2026",
        "purpose": PACKAGE_PURPOSE,
        "verification_method": signer.verification_method,
        "proof_value": base64.b64encode(signature).decode("ascii"),
    }


def _verify_package(root: str, operator: str, proof: Mapping[str, Any]) -> None:
    from nacl.exceptions import BadSignatureError
    from nacl.signing import VerifyKey

    _exact(
        proof,
        {"type", "purpose", "verification_method", "proof_value"},
        "package proof",
    )
    if proof["type"] != "Ed25519Signature2026" or proof["purpose"] != PACKAGE_PURPOSE:
        raise CandidateError("package proof uses an unsupported type or purpose")
    if proof["verification_method"] != operator:
        raise CandidateError("package proof key does not match operator DID")
    try:
        signature = base64.b64decode(proof["proof_value"], validate=True)
        VerifyKey(pubkey_from_did_key(operator)).verify(_package_preimage(root), signature)
    except (BadSignatureError, ValueError, TypeError) as exc:
        raise CandidateError("package proof is not authentic") from exc


def write_candidate(
    destination: Path,
    *,
    signer: LocalEd25519Signer,
    documents: Mapping[str, bytes],
) -> str:
    """Write the exact candidate wire and return its signed candidate root."""
    destination = Path(destination)
    if destination.exists() and any(destination.iterdir()):
        raise CandidateError("candidate destination must be absent or empty")
    if set(documents) != set(DATA_MEMBERS):
        raise CandidateError("export documents do not match the exact candidate wire")
    seed_encodings = {
        signer.seed.hex().encode("ascii"),
        base64.b64encode(signer.seed),
        base64.urlsafe_b64encode(signer.seed),
    }
    private_sentinels = (b'"secret_key_b64"', b'"private_key"', b'"seed"', b'"operator-key"')
    for path, raw in documents.items():
        if any(secret in raw for secret in seed_encodings) or any(
            sentinel in raw for sentinel in private_sentinels
        ):
            raise CandidateError(f"{path} contains operator private-key material")
    destination.mkdir(parents=True, exist_ok=True)
    for path in DATA_MEMBERS:
        _safe_member(path)
        raw = documents[path]
        strict_json(raw, path)
        (destination / path).write_bytes(raw)
    descriptors = [
        {"path": path, "byte_length": len(documents[path]), "sha256": sha256(documents[path])}
        for path in sorted(DATA_MEMBERS)
    ]
    root = sha256(canonical({"profile": CANDIDATE_PROFILE, "members": descriptors}))
    package = {
        "profile": CANDIDATE_PROFILE,
        "candidate_root": root,
        "operator_did": signer.issuer,
        "counter_status": "NOT_COUNTED",
        "candidate_for": "independent_witnesses",
        "never_counts_toward": [
            "independent_implementations",
            "external_reliance_decisions",
            "external_replays",
        ],
        "member_count": len(REQUIRED_MEMBERS),
        "members": descriptors,
        "proof": _sign_package(root, signer),
    }
    (destination / "package.json").write_bytes(canonical(package))
    check_candidate(destination)
    return root


def _canonical_member(members: Mapping[str, bytes], path: str) -> Mapping[str, Any]:
    value = strict_json(members[path], path)
    if not isinstance(value, Mapping):
        raise CandidateError(f"{path} must contain a JSON object")
    if members[path] != canonical(value):
        raise CandidateError(f"{path} is not in its exact canonical presentation")
    return value


def _timestamp(value: Any, label: str) -> None:
    if not isinstance(value, str) or not _TIMESTAMP_RE.fullmatch(value):
        raise CandidateError(f"{label} must be a canonical UTC timestamp")


def check_candidate(root: Path) -> dict[str, Any]:
    """Verify a candidate. Success is ``VALID_CANDIDATE · NOT_COUNTED``."""
    members = _regular_members(Path(root))
    package = _canonical_member(members, "package.json")
    _exact(
        package,
        {
            "profile",
            "candidate_root",
            "operator_did",
            "counter_status",
            "candidate_for",
            "never_counts_toward",
            "member_count",
            "members",
            "proof",
        },
        "package",
    )
    if package["profile"] != CANDIDATE_PROFILE:
        raise CandidateError("candidate uses an unsupported profile")
    if package["counter_status"] != "NOT_COUNTED" or package["candidate_for"] != "independent_witnesses":
        raise CandidateError("candidate must remain NOT_COUNTED for independent_witnesses")
    if package["never_counts_toward"] != [
        "independent_implementations",
        "external_reliance_decisions",
        "external_replays",
    ]:
        raise CandidateError("candidate counter exclusions changed")
    if package["member_count"] != len(REQUIRED_MEMBERS):
        raise CandidateError("candidate member count changed")
    expected_descriptors = [
        {"path": path, "byte_length": len(members[path]), "sha256": sha256(members[path])}
        for path in sorted(DATA_MEMBERS)
    ]
    if package["members"] != expected_descriptors:
        raise CandidateError("package member descriptors do not bind the exact bytes")
    expected_root = sha256(canonical({"profile": CANDIDATE_PROFILE, "members": expected_descriptors}))
    if package["candidate_root"] != expected_root:
        raise CandidateError("candidate root does not match its exact members")
    _verify_package(expected_root, package["operator_did"], package["proof"])

    manifest = _canonical_member(members, "operator-manifest.json")
    _exact(
        manifest,
        {
            "profile",
            "schema_version",
            "operator_did",
            "log_id",
            "endpoint",
            "source_kit_root",
            "retention_policy_sha256",
            "privacy_policy_sha256",
            "control_disclosure_sha256",
            "supported_receipt_profiles",
            "resource_limits",
            "externality_status",
            "temporal_claims",
        },
        "operator manifest",
    )
    if manifest["profile"] != "bulla.receipt-witness/0.1-experimental":
        raise CandidateError("operator manifest profile changed")
    if manifest["schema_version"] != "0.1-experimental":
        raise CandidateError("operator manifest schema version changed")
    if manifest["operator_did"] != package["operator_did"]:
        raise CandidateError("operator manifest and package use different keys")
    if manifest["externality_status"] != "NOT_REVIEWED":
        raise CandidateError("operator may not self-assert independence")
    if not isinstance(manifest["endpoint"], str) or not manifest["endpoint"].startswith("https://"):
        raise CandidateError("submitted public endpoint must use HTTPS")
    if manifest["supported_receipt_profiles"] != ["0.2", "0.3", "0.4"]:
        raise CandidateError("supported receipt profile declaration changed")
    if manifest["resource_limits"] != {"max_receipt_bytes": MAX_MEMBER_BYTES}:
        raise CandidateError("operator resource limits changed")
    if manifest["temporal_claims"] != _MANIFEST_TEMPORAL_CLAIMS:
        raise CandidateError("operator temporal claims changed")

    source = _canonical_member(members, "source-closure.json")
    _exact(source, {"profile", "root", "members"}, "source closure")
    if source["profile"] != "bulla.receipt-witness-source-closure/0.1":
        raise CandidateError("source closure profile changed")
    if manifest["source_kit_root"] != source["root"] or not _HASH_RE.fullmatch(source["root"]):
        raise CandidateError("manifest does not bind the exact source-kit root")
    published_source = Path(__file__).with_name("source-closure.json")
    if not published_source.is_file():
        raise CandidateError("accepted source-kit closure is required beside the checker")
    if members["source-closure.json"] != published_source.read_bytes():
        raise CandidateError("candidate does not bind the published source-kit closure")

    policies = {
        "retention-policy.json": "retention_policy_sha256",
        "privacy-policy.json": "privacy_policy_sha256",
        "control-disclosure.json": "control_disclosure_sha256",
    }
    for path, field in policies.items():
        _canonical_member(members, path)
        if manifest[field] != sha256(members[path]):
            raise CandidateError(f"manifest does not bind {path}")

    receipts: list[tuple[Mapping[str, Any], str, str]] = []
    expected_versions = ("0.47.1", "0.48.0")
    for label, version in zip(("a", "b"), expected_versions, strict=True):
        raw = members[f"receipt-{label}.json"]
        if sha256(raw) != _RELEASE_CARGO[version]["byte_sha256"]:
            raise CandidateError(f"receipt-{label}.json is not the exact published Bulla {version} receipt")
        document = parse_action_receipt_json(raw, limits=_RECEIPT_LIMITS).to_dict()
        verdict = verify_receipt(document)
        if not verdict.ok or verdict.verified_to != "attestation":
            raise CandidateError(f"receipt-{label}.json did not verify to attestation")
        subject = (document.get("action") or {}).get("subject") or {}
        if (
            (document.get("action") or {}).get("type") != "package.release"
            or subject.get("package") != "bulla"
            or subject.get("version") != version
        ):
            raise CandidateError(f"receipt-{label}.json is not the exact Bulla {version} release class")
        attestation = document["hashes"]["attestation"]
        if (
            attestation != _RELEASE_CARGO[version]["attestation"]
            or (document.get("signature") or {}).get("issuer") != _RELEASE_ISSUER
        ):
            raise CandidateError(f"receipt-{label}.json changed release attestation or issuer")
        expected_leaf = _b2h(
            Deed(
                (document.get("signature") or {}).get("issuer") or "unsigned",
                document["hashes"]["content"],
                attestation,
            ).leaf()
        )
        receipts.append((document, attestation, expected_leaf))

    intakes = []
    checkpoints = []
    for index, label in enumerate(("a", "b")):
        intake = _canonical_member(members, f"intake-{label}.json")
        _exact(intake, {"attestation_hash", "index", "received_at", "verified_to"}, f"intake {label}")
        _safe_uint(intake["index"], f"intake {label} index")
        _timestamp(intake["received_at"], f"intake {label} received_at")
        if (
            intake["attestation_hash"] != receipts[index][1]
            or intake["index"] != index
            or intake["verified_to"] != "attestation"
        ):
            raise CandidateError(f"intake {label} does not bind its release receipt and log position")
        intakes.append(intake)
        checkpoint_doc = _canonical_member(members, f"checkpoint-{label}.json")
        _safe_uint(checkpoint_doc.get("tree_size"), f"checkpoint {label} tree_size")
        _safe_uint(checkpoint_doc.get("position"), f"checkpoint {label} position")
        checkpoint = WitnessCheckpoint.from_dict(checkpoint_doc)
        if not verify_checkpoint(checkpoint).ok:
            raise CandidateError(f"checkpoint {label} is not operator-authentic")
        if checkpoint.operator != package["operator_did"] or checkpoint.log_id != manifest["log_id"]:
            raise CandidateError(f"checkpoint {label} uses a different operator or log")
        if checkpoint.tree_size != index + 1:
            raise CandidateError(f"checkpoint {label} has the wrong tree size")
        _timestamp(checkpoint.issued_at, f"checkpoint {label} issued_at")
        checkpoints.append(checkpoint)
        inclusion = _canonical_member(members, f"inclusion-{label}.json")
        _exact(inclusion, _INCLUSION_FIELDS, f"inclusion {label}")
        _safe_uint(inclusion["index"], f"inclusion {label} index")
        _safe_uint(inclusion["tree_size"], f"inclusion {label} tree_size")
        _hash_list(inclusion["proof"], f"inclusion {label} proof")
        for field in ("leaf", "root", "attestation_hash"):
            if not isinstance(inclusion[field], str) or not _HASH_RE.fullmatch(inclusion[field]):
                raise CandidateError(f"inclusion {label} {field} is not a canonical sha256 digest")
        _timestamp(inclusion["received_at"], f"inclusion {label} received_at")
        if inclusion["verified_to"] != "attestation":
            raise CandidateError(f"inclusion {label} verification level changed")
        for field in ("index", "attestation_hash", "received_at", "verified_to"):
            if inclusion[field] != intake[field]:
                raise CandidateError(
                    f"inclusion {label} {field} contradicts its exact intake record"
                )
        if inclusion.get("attestation_hash") != receipts[index][1] or not verify_inclusion_record(
            dict(inclusion),
            trusted_root=checkpoint.root,
            expected_leaf=receipts[index][2],
        ):
            raise CandidateError(f"inclusion {label} does not bind the receipt to its checkpoint")

    consistency = _canonical_member(members, "consistency-a-b.json")
    _exact(consistency, _CONSISTENCY_FIELDS, "adjacent consistency")
    _safe_uint(consistency["old_size"], "adjacent consistency old_size")
    _safe_uint(consistency["new_size"], "adjacent consistency new_size")
    _hash_list(consistency["proof"], "adjacent consistency proof")
    for field in ("old_root", "new_root"):
        if not isinstance(consistency[field], str) or not _HASH_RE.fullmatch(consistency[field]):
            raise CandidateError(f"adjacent consistency {field} is not a canonical sha256 digest")
    if not verify_checkpoint_extension(checkpoints[0], checkpoints[1], consistency).ok:
        raise CandidateError("adjacent consistency does not prove append-only checkpoint history")

    return {
        "profile": CANDIDATE_PROFILE,
        "candidate_root": expected_root,
        "candidate_status": "VALID_CANDIDATE",
        "counter_status": "NOT_COUNTED",
        "candidate_for": "independent_witnesses",
        "operator_did": package["operator_did"],
        "log_id": manifest["log_id"],
        "endpoint": manifest["endpoint"],
        "source_kit_root": manifest["source_kit_root"],
        "receipt_attestations": [item[1] for item in receipts],
        "checkpoint_attestations": [item.checkpoint_hash for item in checkpoints],
    }


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("candidate", type=Path)
    args = parser.parse_args()
    print(json.dumps(check_candidate(args.candidate), indent=2))
