#!/usr/bin/env python3
"""Verify a public receipt against the AER-1 Internet-Draft using Python built-ins."""

import argparse
import base64
import binascii
import hashlib
import json
import re
import sys
import uuid
from datetime import datetime
from html.parser import HTMLParser
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen


SITE = "https://zambo.dev"
PROVENANCE_CLASSES = {
    "EXECUTED BY ZAMBO",
    "OBSERVED VIA GATEWAY",
    "LOGGED BY AGENT",
}


class AuditPageParser(HTMLParser):
    """Collect the receipt fields rendered in the public /run/ page."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.fields = {}
        self.pending_label = None
        self.capture = None
        self.buffer = []
        self.in_receipt_proof = False
        self.provenance_capture = False
        self.provenance_buffer = []
        self.provenance_class = None

    def handle_starttag(self, tag, attrs):
        attributes = dict(attrs)
        if tag == "section" and attributes.get("aria-labelledby") == "receipt-proof":
            self.in_receipt_proof = True
        if tag == "dt":
            self.capture = "dt"
            self.buffer = []
        elif tag == "dd":
            self.capture = "dd"
            self.buffer = []
        if self.in_receipt_proof and tag == "span":
            classes = attributes.get("class", "").split()
            if "provenance-badge" in classes:
                self.provenance_capture = True
                self.provenance_buffer = []

    def handle_data(self, data):
        if self.capture:
            self.buffer.append(data)
        if self.provenance_capture:
            self.provenance_buffer.append(data)

    def handle_endtag(self, tag):
        if tag in ("dt", "dd") and self.capture == tag:
            value = " ".join("".join(self.buffer).split())
            if tag == "dt":
                self.pending_label = value
            elif self.pending_label:
                self.fields[self.pending_label] = value
                self.pending_label = None
            self.capture = None
            self.buffer = []
        if tag == "span" and self.provenance_capture:
            self.provenance_class = " ".join("".join(self.provenance_buffer).split())
            self.provenance_capture = False
            self.provenance_buffer = []
        if tag == "section" and self.in_receipt_proof:
            self.in_receipt_proof = False


def check_uuid(value):
    if not isinstance(value, str):
        return False
    try:
        parsed = uuid.UUID(value)
    except (ValueError, AttributeError, TypeError):
        return False
    return str(parsed) == value.lower()


def validate_timestamp(value):
    if not isinstance(value, str) or not value:
        return False, "missing timestamp"
    if not re.fullmatch(
        r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
        value,
    ):
        return False, "invalid timestamp"
    try:
        parsed = datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value)
    except ValueError:
        return False, "invalid timestamp"
    if parsed.tzinfo is None:
        return False, "invalid timestamp"
    return True, "timestamp valid"


def validate_receipt(receipt, expected_id=None):
    if not isinstance(receipt, dict):
        return False, "receipt is not a JSON object"

    receipt_id = receipt.get("id")
    if not check_uuid(receipt_id):
        return False, "UUID-format"
    if expected_id and receipt_id.lower() != expected_id.lower():
        return False, "receipt id does not match the requested UUID"

    schema_version = receipt.get("receipt_schema_version")
    if not isinstance(schema_version, str) or not schema_version.strip():
        return False, "missing receipt_schema_version"

    timestamp_ok, timestamp_reason = validate_timestamp(receipt.get("created_at"))
    if not timestamp_ok:
        return False, timestamp_reason

    tool = receipt.get("tool")
    if not isinstance(tool, dict) or any(
        not isinstance(tool.get(key), str) or not tool.get(key).strip()
        for key in ("name", "version", "scope")
    ):
        return False, "missing tool name, version, or caller scope"

    provenance = receipt.get("provenance_class")
    if provenance not in PROVENANCE_CLASSES:
        return False, "invalid provenance_class"

    encoded = receipt.get("canonical_bytes")
    if not isinstance(encoded, str) or not encoded:
        return False, "missing canonical_bytes"
    try:
        canonical_bytes = base64.b64decode(encoded, validate=True)
    except (binascii.Error, ValueError):
        return False, "invalid Base64 canonical_bytes"

    output_hash = receipt.get("output_hash")
    if not isinstance(output_hash, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", output_hash):
        return False, "invalid SHA-256 output_hash format"
    calculated = "sha256:" + hashlib.sha256(canonical_bytes).hexdigest()
    if calculated != output_hash:
        return False, "SHA-256 mismatch"

    if receipt.get("verification_status") != "verified":
        return False, "verification_status is not verified"
    return True, "verified"


def fetch_url(url):
    request = Request(url, headers={"User-Agent": "aer1-stdlib-verifier/1.0", "Accept": "*/*"})
    try:
        with urlopen(request, timeout=20) as response:
            final_host = urlsplit(response.geturl()).hostname
            if final_host != "zambo.dev":
                return None, "redirect left zambo.dev"
            return (response.status, response.read()), None
    except HTTPError as error:
        return (error.code, error.read()), None
    except (URLError, TimeoutError, OSError) as error:
        return None, "network error: " + str(error)


def parse_run_page(html, expected_id):
    parser = AuditPageParser()
    try:
        parser.feed(html.decode("utf-8"))
        parser.close()
    except (UnicodeDecodeError, ValueError) as error:
        return None, "could not parse public audit page: " + str(error)

    labels = {
        "Receipt ID": "id",
        "SHA-256 output hash": "output_hash",
        "Canonical bytes": "canonical_bytes",
        "Timestamp": "created_at",
        "Receipt schema": "receipt_schema_version",
        "Tool name": "tool_name",
        "Tool version": "tool_version",
        "Permission scope": "tool_scope",
    }
    extracted = {}
    for label, field in labels.items():
        value = parser.fields.get(label)
        if not value:
            return None, "audit page is missing field: " + label
        extracted[field] = value

    extracted["canonical_bytes"] = re.sub(r"\s+", "", extracted["canonical_bytes"])
    if not parser.provenance_class:
        return None, "audit page is missing provenance_class"
    extracted["provenance_class"] = parser.provenance_class
    extracted["tool"] = {
        "name": extracted.pop("tool_name"),
        "version": extracted.pop("tool_version"),
        "scope": extracted.pop("tool_scope"),
    }
    if extracted["id"].lower() != expected_id.lower():
        return None, "receipt id does not match the requested UUID"
    return extracted, None


def verify_live(target):
    candidate = target.strip()
    if "://" not in candidate:
        candidate = SITE + "/run/" + candidate
    parsed = urlsplit(candidate)
    if (
        parsed.scheme != "https"
        or parsed.hostname != "zambo.dev"
        or parsed.username
        or parsed.password
        or parsed.query
        or parsed.fragment
    ):
        return False, "use an HTTPS zambo.dev /run/<uuid> URL"
    match = re.fullmatch(r"/run/([^/]+)/?", parsed.path)
    if not match:
        return False, "URL path must be /run/<uuid>"
    requested_id = match.group(1)
    if not check_uuid(requested_id):
        return False, "UUID-format"

    run_result, error = fetch_url(SITE + "/run/" + requested_id)
    if error:
        return False, error
    status, run_body = run_result
    if status == 404:
        return False, "not-found"
    if status != 200:
        return False, "public audit page returned HTTP " + str(status)
    page_fields, error = parse_run_page(run_body, requested_id)
    if error:
        return False, error

    api_result, error = fetch_url(SITE + "/api/receipt/" + requested_id)
    if error:
        return False, error
    api_status, api_body = api_result
    if api_status == 404:
        return False, "not-found"
    if api_status != 200:
        return False, "public receipt projection returned HTTP " + str(api_status)
    try:
        receipt = json.loads(api_body.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as error:
        return False, "public receipt projection is invalid JSON: " + str(error)

    for key in ("id", "receipt_schema_version", "created_at", "output_hash", "canonical_bytes", "provenance_class"):
        if receipt.get(key) != page_fields.get(key):
            return False, "audit page and public projection disagree on " + key
    if receipt.get("tool") != page_fields.get("tool"):
        return False, "audit page and public projection disagree on tool"
    valid, reason = validate_receipt(receipt, requested_id)
    return valid, reason


def verify_fixture(path):
    try:
        fixture = json.loads(Path(path).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        print("FAIL: fixture could not be read: " + str(error))
        return False

    expected = fixture.get("expected_verdict")
    expected_reason = fixture.get("expected_reason")
    receipt = fixture.get("public_receipt")
    if receipt is None and fixture.get("lookup_http_status") == 404:
        valid, reason = False, "not-found"
    else:
        valid, reason = validate_receipt(receipt)
    observed = "PASS" if valid else "FAIL"
    matches = observed == expected and reason == expected_reason
    label = "PASS" if matches else "FAIL"
    print(
        f"{label}: {Path(path).name}: expected {expected} ({expected_reason}), "
        f"observed {observed} ({reason})"
    )
    return matches


def main():
    parser = argparse.ArgumentParser(
        description="Verify a public receipt URL or check the supplied JSON conformance fixtures."
    )
    parser.add_argument("target", nargs="?", help="https://zambo.dev/run/<uuid> or a UUID")
    parser.add_argument("--fixture", help="verify one local JSON fixture")
    parser.add_argument("--fixtures-dir", help="verify every *.json fixture in a directory")
    args = parser.parse_args()

    if args.fixture and args.fixtures_dir:
        parser.error("choose --fixture or --fixtures-dir, not both")
    if args.fixture:
        return 0 if verify_fixture(args.fixture) else 1
    if args.fixtures_dir:
        fixture_paths = sorted(Path(args.fixtures_dir).glob("*.json"))
        if not fixture_paths:
            print("FAIL: no JSON fixtures found in " + args.fixtures_dir)
            return 1
        results = [verify_fixture(path) for path in fixture_paths]
        return 0 if all(results) else 1
    if not args.target:
        parser.error("provide a receipt URL/UUID or a fixture option")

    valid, reason = verify_live(args.target)
    if valid:
        print("PASS: public receipt fields and SHA-256 output commitment verified.")
        return 0
    print("FAIL: " + reason)
    return 1


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