"""
ScirDom evidence-package replay verifier v1.

This module verifies a downloaded evidence package from artefacts alone:
`manifest.json`, `checksums.sha256`, `participant-list-locked.txt`, and
`entropy-portion.bin`, plus the public ASD v1 Python implementation.

The replay check is intentionally separate from manifest-signature
authentication. Current evidence packs can be replayed mathematically and,
when supplied with ScirDom's ECDSA evidence-signing public key, can also
authenticate the manifest signature. Historical beta packs may replay but
remain signature-blocked if they do not record the canonicalisation contract.
"""

from __future__ import annotations

import argparse
import base64
import hashlib
import json
import sys
import tempfile
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable

try:  # pragma: no cover - exercised by direct script execution.
    from .scirdom_asd_v1 import execute_draw
except ImportError:  # pragma: no cover - exercised when run from ASD/.
    from scirdom_asd_v1 import execute_draw


ALGORITHM_ID = "scirdom.alg.v1.0"
ALGORITHM_VERSION = "1.0"
ALGORITHM_MODE = "exact-direct-selection-without-replacement"
CHECKSUMS_FILENAME = "checksums.sha256"
ENTROPY_FILENAME = "entropy-portion.bin"
LOCKED_LIST_FILENAME = "participant-list-locked.txt"
MANIFEST_FILENAME = "manifest.json"
SUPPORTED_MANIFEST_CANONICALISATION = "json-sorted-compact-excluding-signature"


class EvidenceReplayError(Exception):
    """Raised when evidence-package replay verification fails."""


@dataclass(frozen=True)
class ReplayCheck:
    """A single replay verification check."""

    name: str
    ok: bool
    detail: str = ""


@dataclass(frozen=True)
class SignatureCheckResult:
    """Manifest signature authentication status."""

    status: str
    detail: str

    @property
    def ok(self) -> bool:
        return self.status == "verified"


@dataclass(frozen=True)
class EvidenceReplayResult:
    """Evidence replay result suitable for tests and human summaries."""

    draw_id: str
    replay_status: str
    checks: tuple[ReplayCheck, ...]
    signature: SignatureCheckResult
    participant_count: int
    winners_requested: int
    bytes_consumed: int
    winner_indices_selection_order: tuple[int, ...]
    participant_commitment_sha256: str
    notes: tuple[str, ...] = field(default_factory=tuple)

    @property
    def ok(self) -> bool:
        return self.replay_status == "passed" and all(check.ok for check in self.checks)

    def summary_lines(self) -> list[str]:
        """Return a concise British-English human summary."""
        failed = [check for check in self.checks if not check.ok]
        lines = [
            f"Replay status: {self.replay_status}",
            f"Draw ID: {self.draw_id}",
            f"Participants: {self.participant_count}",
            f"Winners requested: {self.winners_requested}",
            f"Bytes consumed: {self.bytes_consumed}",
            f"Winner indices in selection order: {list(self.winner_indices_selection_order)}",
            f"Signature authentication: {self.signature.status} - {self.signature.detail}",
        ]
        if failed:
            lines.append("Failed checks:")
            lines.extend(f"- {check.name}: {check.detail}" for check in failed)
        if self.notes:
            lines.append("Notes:")
            lines.extend(f"- {note}" for note in self.notes)
        return lines


def sha256_bytes(data: bytes) -> str:
    """Return the SHA-256 hex digest of `data`."""
    return hashlib.sha256(data).hexdigest()


def _safe_extract_zip(zip_path: Path, destination: Path) -> Path:
    """Extract a ZIP package without allowing path traversal."""
    with zipfile.ZipFile(zip_path, "r") as archive:
        for member in archive.infolist():
            member_path = destination / member.filename
            resolved_member = member_path.resolve()
            resolved_destination = destination.resolve()
            if resolved_destination not in resolved_member.parents and resolved_member != resolved_destination:
                raise EvidenceReplayError(f"Unsafe ZIP member path: {member.filename}")
        archive.extractall(destination)

    top_level_files = [path for path in destination.iterdir() if path.is_file()]
    if (destination / MANIFEST_FILENAME).exists():
        return destination
    top_level_dirs = [path for path in destination.iterdir() if path.is_dir()]
    for candidate in top_level_dirs:
        if (candidate / MANIFEST_FILENAME).exists():
            return candidate
    if top_level_files:
        raise EvidenceReplayError("Extracted ZIP does not contain manifest.json at a supported location.")
    raise EvidenceReplayError("Evidence ZIP is empty.")


def _evidence_directory(evidence_path: Path, temp_dir: Path | None = None) -> Path:
    """Return an extracted evidence directory for either a directory or ZIP path."""
    if evidence_path.is_dir():
        return evidence_path
    if evidence_path.is_file() and zipfile.is_zipfile(evidence_path):
        if temp_dir is None:
            raise EvidenceReplayError("A temporary directory is required for ZIP evidence packages.")
        return _safe_extract_zip(evidence_path, temp_dir)
    raise EvidenceReplayError(f"Evidence package path is not a directory or ZIP file: {evidence_path}")


def _read_json(path: Path) -> dict[str, Any]:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise EvidenceReplayError(f"Missing required file: {path.name}") from exc
    except json.JSONDecodeError as exc:
        raise EvidenceReplayError(f"Invalid JSON in {path.name}: {exc}") from exc
    if not isinstance(value, dict):
        raise EvidenceReplayError(f"{path.name} must contain a JSON object.")
    return value


def _parse_checksums(path: Path) -> dict[str, str]:
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except FileNotFoundError as exc:
        raise EvidenceReplayError(f"Missing required file: {CHECKSUMS_FILENAME}") from exc

    checksums: dict[str, str] = {}
    for line_number, line in enumerate(lines, start=1):
        if not line.strip():
            continue
        parts = line.split("  ", 1)
        if len(parts) != 2:
            raise EvidenceReplayError(f"Malformed checksum line {line_number} in {CHECKSUMS_FILENAME}.")
        digest, filename = parts
        if "/" in filename or "\\" in filename or filename in {"", ".", ".."}:
            raise EvidenceReplayError(f"Unsupported checksum filename on line {line_number}: {filename}")
        if len(digest) != 64 or any(char not in "0123456789abcdefABCDEF" for char in digest):
            raise EvidenceReplayError(f"Invalid SHA-256 digest on checksum line {line_number}.")
        checksums[filename] = digest.lower()
    if not checksums:
        raise EvidenceReplayError(f"{CHECKSUMS_FILENAME} contains no file hashes.")
    return checksums


def verify_checksums(evidence_dir: Path) -> tuple[ReplayCheck, ...]:
    """Verify every hash listed in `checksums.sha256`."""
    checksums = _parse_checksums(evidence_dir / CHECKSUMS_FILENAME)
    checks: list[ReplayCheck] = []
    for filename, expected in checksums.items():
        path = evidence_dir / filename
        if not path.exists():
            checks.append(ReplayCheck(f"checksum:{filename}", False, "listed file is missing"))
            continue
        actual = sha256_bytes(path.read_bytes())
        checks.append(
            ReplayCheck(
                f"checksum:{filename}",
                actual == expected,
                "" if actual == expected else f"expected {expected}, got {actual}",
            )
        )
    return tuple(checks)


def _require_manifest_sections(manifest: dict[str, Any]) -> None:
    for key in ("algorithm", "participants", "entropy", "result", "winners_requested", "draw_id"):
        if key not in manifest:
            raise EvidenceReplayError(f"manifest.json missing required field: {key}")


def _validate_algorithm(manifest: dict[str, Any]) -> tuple[ReplayCheck, ...]:
    algorithm = manifest["algorithm"]
    checks = [
        ReplayCheck(
            "algorithm_id",
            algorithm.get("id") == ALGORITHM_ID,
            "" if algorithm.get("id") == ALGORITHM_ID else f"expected {ALGORITHM_ID}, got {algorithm.get('id')!r}",
        ),
        ReplayCheck(
            "algorithm_version",
            algorithm.get("version") == ALGORITHM_VERSION,
            "" if algorithm.get("version") == ALGORITHM_VERSION else f"expected {ALGORITHM_VERSION}, got {algorithm.get('version')!r}",
        ),
        ReplayCheck(
            "algorithm_mode",
            algorithm.get("mode") == ALGORITHM_MODE,
            "" if algorithm.get("mode") == ALGORITHM_MODE else f"expected {ALGORITHM_MODE}, got {algorithm.get('mode')!r}",
        ),
    ]
    return tuple(checks)


def _load_locked_participants(path: Path) -> list[dict[str, str]]:
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except FileNotFoundError as exc:
        raise EvidenceReplayError(f"Missing required file: {LOCKED_LIST_FILENAME}") from exc

    records: list[dict[str, str]] = []
    for line_number, line in enumerate(lines, start=1):
        if "\t" not in line:
            raise EvidenceReplayError(
                f"{LOCKED_LIST_FILENAME} line {line_number} is not participant_id<TAB>name format."
            )
        participant_id, name = line.split("\t", 1)
        if participant_id == "" or name == "":
            raise EvidenceReplayError(
                f"{LOCKED_LIST_FILENAME} line {line_number} contains an empty participant ID or name."
            )
        records.append({"participant_id": participant_id, "name": name})
    if not records:
        raise EvidenceReplayError(f"{LOCKED_LIST_FILENAME} contains no locked participants.")
    return records


def _check_equal(name: str, actual: Any, expected: Any) -> ReplayCheck:
    return ReplayCheck(name, actual == expected, "" if actual == expected else f"expected {expected!r}, got {actual!r}")


def _signature_payload_from_manifest(manifest: dict[str, Any]) -> bytes:
    payload = {key: value for key, value in manifest.items() if key != "signature"}
    return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def check_manifest_signature(
    manifest: dict[str, Any],
    public_key_bytes: bytes | None = None,
) -> SignatureCheckResult:
    """
    Verify the manifest signature when enough public information is available.

    Historical beta packages return `blocked` unless a PEM ECDSA public key
    and an explicit supported canonicalisation contract are supplied.
    """
    signature = manifest.get("signature")
    if not isinstance(signature, dict):
        return SignatureCheckResult("blocked", "manifest.json does not contain a signature object.")

    algorithm = signature.get("algorithm")
    if algorithm != "ECDSA-P256-SHA256":
        return SignatureCheckResult("blocked", f"unsupported manifest signature algorithm: {algorithm!r}.")

    if public_key_bytes is None:
        return SignatureCheckResult("blocked", "no ECDSA public key material was supplied for manifest verification.")

    stripped_key = public_key_bytes.strip()
    if stripped_key.startswith(b"-----BEGIN PGP PUBLIC KEY BLOCK-----"):
        return SignatureCheckResult(
            "blocked",
            "supplied public key material is a PGP key, but manifest.json declares an ECDSA-P256-SHA256 signature.",
        )

    canonicalisation = signature.get("canonicalisation")
    if canonicalisation != SUPPORTED_MANIFEST_CANONICALISATION:
        return SignatureCheckResult(
            "blocked",
            "manifest signature block does not record the supported canonicalisation contract.",
        )

    try:
        from cryptography.exceptions import InvalidSignature
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import ec
    except ImportError:
        return SignatureCheckResult("blocked", "cryptography package is not available for ECDSA verification.")

    try:
        public_key = serialization.load_pem_public_key(stripped_key)
    except ValueError:
        return SignatureCheckResult("blocked", "supplied public key is not a loadable PEM public key.")

    curve = getattr(public_key, "curve", None)
    if curve is None or curve.name != "secp256r1":
        return SignatureCheckResult("blocked", "supplied public key is not an ECDSA P-256 public key.")

    signature_value = signature.get("value")
    if not isinstance(signature_value, str) or not signature_value:
        return SignatureCheckResult("blocked", "manifest signature value is missing.")

    try:
        signature_bytes = base64.b64decode(signature_value, validate=True)
    except ValueError:
        return SignatureCheckResult("blocked", "manifest signature value is not valid base64.")

    try:
        public_key.verify(
            signature_bytes,
            _signature_payload_from_manifest(manifest),
            ec.ECDSA(hashes.SHA256()),
        )
    except InvalidSignature:
        return SignatureCheckResult("failed", "manifest signature did not verify against the supplied ECDSA key.")

    return SignatureCheckResult("verified", "manifest signature verified against the supplied ECDSA P-256 key.")


def verify_evidence_package(
    evidence_path: str | Path,
    public_key_path: str | Path | None = None,
) -> EvidenceReplayResult:
    """Verify replay of an evidence package directory or ZIP archive."""
    evidence_path = Path(evidence_path)
    public_key_bytes = Path(public_key_path).read_bytes() if public_key_path is not None else None

    with tempfile.TemporaryDirectory(prefix="scirdom-evidence-replay-") as temp_name:
        evidence_dir = _evidence_directory(evidence_path, Path(temp_name))

        required_files = {
            CHECKSUMS_FILENAME,
            ENTROPY_FILENAME,
            LOCKED_LIST_FILENAME,
            MANIFEST_FILENAME,
        }
        missing = sorted(filename for filename in required_files if not (evidence_dir / filename).exists())
        if missing:
            raise EvidenceReplayError(f"Evidence package missing required file(s): {', '.join(missing)}")

        checksum_checks = list(verify_checksums(evidence_dir))
        failed_checksums = [check for check in checksum_checks if not check.ok]
        if failed_checksums:
            raise EvidenceReplayError(
                "Evidence package checksum verification failed: "
                + "; ".join(f"{check.name} {check.detail}".strip() for check in failed_checksums)
            )

        manifest = _read_json(evidence_dir / MANIFEST_FILENAME)
        _require_manifest_sections(manifest)

        locked_path = evidence_dir / LOCKED_LIST_FILENAME
        locked_participants = _load_locked_participants(locked_path)
        entropy_bytes = (evidence_dir / ENTROPY_FILENAME).read_bytes()
        replay = execute_draw(
            locked_participants,
            entropy_bytes,
            0,
            int(manifest["winners_requested"]),
        )

        checks: list[ReplayCheck] = []
        checks.extend(checksum_checks)
        checks.extend(_validate_algorithm(manifest))
        checks.extend(
            [
                _check_equal("participant_count", replay["locked_count"], manifest["participants"]["count"]),
                _check_equal("winner_count", replay["k"], manifest["winners_requested"]),
                _check_equal("locked_list_sha256", replay["locked_list_sha256"], manifest["participants"]["locked_list_sha256"]),
                _check_equal("participant_fingerprint_sha256", replay["locked_list_sha256"], manifest["participants"]["fingerprint_sha256"]),
                _check_equal("entropy_portion_sha256", sha256_bytes(entropy_bytes), manifest["entropy"]["portion_sha256"]),
                _check_equal("entropy_bytes_consumed", replay["bytes_consumed"], manifest["entropy"]["bytes_consumed"]),
                _check_equal("entropy_portion_length", len(entropy_bytes), manifest["entropy"]["bytes_consumed"]),
                _check_equal("replay_offset_start", replay["offset_start"], 0),
                _check_equal("winner_indices_selection_order", replay["winner_indices"], manifest["result"]["winner_indices_selection_order"]),
                _check_equal("winner_entries_selection_order", replay["winner_entries"], manifest["result"]["winner_entries_selection_order"]),
                _check_equal("winner_indices_locked_order_derived", replay["winner_indices_locked_order"], manifest["result"]["winner_indices_locked_order_derived"]),
                _check_equal("winner_entries_locked_order_derived", replay["winner_entries_locked_order"], manifest["result"]["winner_entries_locked_order_derived"]),
            ]
        )

        failed = [check for check in checks if not check.ok]
        if failed:
            raise EvidenceReplayError(
                "Evidence replay failed: "
                + "; ".join(f"{check.name} {check.detail}".strip() for check in failed)
            )

        signature = check_manifest_signature(manifest, public_key_bytes=public_key_bytes)
        notes: list[str] = []
        if signature.status != "verified":
            notes.append(
                "Replay passed, but manifest-signature authentication is separate and did not verify in this run."
            )

        return EvidenceReplayResult(
            draw_id=str(manifest["draw_id"]),
            replay_status="passed",
            checks=tuple(checks),
            signature=signature,
            participant_count=int(replay["locked_count"]),
            winners_requested=int(replay["k"]),
            bytes_consumed=int(replay["bytes_consumed"]),
            winner_indices_selection_order=tuple(int(index) for index in replay["winner_indices"]),
            participant_commitment_sha256=str(replay["participant_commitment_sha256"]),
            notes=tuple(notes),
        )


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Replay a ScirDom evidence package using the public ASD v1 Python implementation.",
    )
    parser.add_argument("evidence_path", help="Path to an extracted evidence package directory or evidence ZIP.")
    parser.add_argument(
        "--public-key",
        dest="public_key_path",
        help="Optional PEM ECDSA public key path for manifest-signature verification.",
    )
    return parser


def main(argv: Iterable[str] | None = None) -> int:
    """Run the verifier from Python or as a script."""
    parser = _build_parser()
    args = parser.parse_args(list(argv) if argv is not None else None)
    try:
        result = verify_evidence_package(args.evidence_path, public_key_path=args.public_key_path)
    except EvidenceReplayError as exc:
        print(f"Replay status: failed\nReason: {exc}", file=sys.stderr)
        return 1
    print("\n".join(result.summary_lines()))
    return 0 if result.ok else 1


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
