#!/usr/bin/env python3
"""Fail-closed structural and release validation for worker-image evidence."""
from __future__ import annotations

import argparse
import datetime as dt
import json
import math
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
OCI = re.compile(r"^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$")
SPDX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*(?: WITH [A-Za-z0-9.+-]+)?(?: (?:AND|OR) [A-Za-z0-9][A-Za-z0-9.+-]*(?: WITH [A-Za-z0-9.+-]+)?)*$")


def load(relative: str):
    with (ROOT / relative).open(encoding="utf-8") as handle:
        return json.load(handle)


def require(condition: bool, message: str, errors: list[str]) -> None:
    if not condition:
        errors.append(message)


def timestamp(value, label: str, errors: list[str]):
    try:
        parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
        require(parsed.tzinfo is not None, f"{label} must include a timezone", errors)
        return parsed if parsed.tzinfo else None
    except (AttributeError, TypeError, ValueError):
        errors.append(f"{label} is missing or invalid")
        return None


def component_ref(component):
    return component.get("bom-ref") or component.get("purl") or component.get("name")


def release_checks(records: dict, errors: list[str]) -> None:
    inputs = records["build-inputs.json"]
    provenance = records["provenance.json"]
    sbom = records["sbom.cdx.json"]
    licenses = records["licenses/licenses.json"]
    vpolicy = records["vulnerabilities/policy.json"]
    assessment = records["vulnerabilities/vulnerability-assessment.json"]
    measurements = records["evidence/engine-measurements.json"]
    adr3 = records["decisions/ADR-0003-engine-selection.json"]
    now = dt.datetime.now(dt.timezone.utc)

    image = inputs.get("production_image")
    require(isinstance(image, str) and bool(OCI.fullmatch(image)), "production image must be OCI digest-pinned", errors)
    digest = "sha256:" + image.rsplit("@sha256:", 1)[1] if isinstance(image, str) and "@sha256:" in image else None
    require(inputs.get("status") == "selected", "production build inputs are not selected", errors)
    require(bool(inputs.get("declared_inputs")), "production build has no declared inputs", errors)

    subjects = provenance.get("subject", [])
    subject_hashes = [s.get("digest", {}).get("sha256") for s in subjects if isinstance(s, dict)]
    require(len(subjects) == 1 and digest and digest.removeprefix("sha256:") in subject_hashes,
            "provenance must have exactly one subject matching the production image", errors)
    predicate = provenance.get("predicate", {})
    definition = predicate.get("buildDefinition", {})
    run = predicate.get("runDetails", {})
    deps = definition.get("resolvedDependencies")
    require(isinstance(deps, list) and len(deps) == len(inputs.get("declared_inputs", [])),
            "provenance resolved dependencies must completely cover declared inputs", errors)
    require(bool(run.get("builder", {}).get("id")), "provenance builder is missing", errors)
    started = timestamp(run.get("metadata", {}).get("startedOn"), "provenance startedOn", errors)
    finished = timestamp(run.get("metadata", {}).get("finishedOn"), "provenance finishedOn", errors)
    require(started is not None and finished is not None and started <= finished, "provenance build times are incomplete or reversed", errors)

    sbom_digest = sbom.get("metadata", {}).get("component", {}).get("version")
    require(sbom_digest == digest, "SBOM does not identify image digest", errors)
    sbom_time = timestamp(sbom.get("metadata", {}).get("timestamp"), "SBOM timestamp", errors)
    require(sbom_time is not None and sbom_time <= now, "SBOM timestamp is in the future", errors)
    components = sbom.get("components")
    require(isinstance(components, list) and bool(components), "SBOM component inventory is empty", errors)
    refs = []
    for index, component in enumerate(components if isinstance(components, list) else []):
        ref = component_ref(component) if isinstance(component, dict) else None
        require(bool(ref), f"SBOM component {index} lacks a stable identity", errors)
        require(bool(component.get("name")) and bool(component.get("version")), f"SBOM component {index} is incomplete", errors)
        refs.append(ref)
    require(len(refs) == len(set(refs)), "SBOM component identities are not unique", errors)
    compositions = sbom.get("compositions", [])
    require(bool(compositions) and all(c.get("aggregate") == "complete" for c in compositions if isinstance(c, dict)),
            "SBOM composition must declare a complete inventory", errors)

    require(licenses.get("subject_digest") == digest, "license review subject does not match image", errors)
    require(licenses.get("status") in {"reviewed", "complete", "concluded"}, "license review is not concluded", errors)
    reviewed = licenses.get("components")
    require(isinstance(reviewed, list) and len(reviewed) == len(components or []), "license inventory must cover every SBOM component", errors)
    reviewed_refs = set()
    forbidden = {str(x).lower() for x in licenses.get("policy", {}).get("forbidden_without_approved_exception", [])}
    for index, item in enumerate(reviewed if isinstance(reviewed, list) else []):
        ref = item.get("bom_ref") or item.get("bom-ref") or item.get("component") or item.get("purl")
        conclusion = item.get("spdx_expression") or item.get("license_concluded") or item.get("concluded_license")
        require(bool(ref) and ref in refs, f"license component {index} does not match an SBOM component", errors)
        require(isinstance(conclusion, str) and bool(SPDX.fullmatch(conclusion)), f"license component {index} lacks a valid concluded SPDX expression", errors)
        lowered = str(conclusion).lower()
        require(lowered not in forbidden and not any(token in lowered for token in ("unknown", "unlicensed", "noassertion")),
                f"license component {index} has a forbidden or inconclusive license", errors)
        reviewed_refs.add(ref)
    require(reviewed_refs == set(refs), "license inventory and SBOM inventory differ", errors)

    require(assessment.get("subject_digest") == digest, "vulnerability assessment subject does not match image", errors)
    require(assessment.get("status") in {"complete", "scanned"}, "vulnerability scan is incomplete", errors)
    require(bool(assessment.get("scanner")) and bool(assessment.get("scanner_version")), "vulnerability scanner identity/version is missing", errors)
    database_at = timestamp(assessment.get("database_updated_at"), "vulnerability database timestamp", errors)
    scanned_at = timestamp(assessment.get("scanned_at"), "vulnerability scan timestamp", errors)
    max_age = dt.timedelta(hours=vpolicy.get("scanner_requirements", {}).get("database_max_age_hours", 0))
    require(database_at is not None and scanned_at is not None and database_at <= scanned_at <= now,
            "vulnerability timestamps are inconsistent or in the future", errors)
    require(database_at is not None and now - database_at <= max_age, "vulnerability database is stale", errors)
    require(assessment.get("scan_complete") is True and assessment.get("inventory_complete") is True,
            "vulnerability scan/inventory completeness is not attested", errors)
    require(assessment.get("os_components_scanned") is True and assessment.get("language_components_scanned") is True,
            "vulnerability scan did not cover OS and language components", errors)
    findings = assessment.get("findings")
    require(isinstance(findings, list), "vulnerability findings must be an array", errors)
    counts = {"critical": 0, "high": 0, "known_exploited": 0, "malware": 0}
    for finding in findings if isinstance(findings, list) else []:
        severity = str(finding.get("severity", "")).lower()
        if severity in counts:
            counts[severity] += 1
        counts["known_exploited"] += int(finding.get("known_exploited") is True or finding.get("kev") is True)
        counts["malware"] += int(finding.get("malware") is True or finding.get("type") == "malware")
    declared_counts = assessment.get("finding_counts", assessment.get("counts"))
    require(isinstance(declared_counts, dict), "vulnerability finding counts are missing", errors)
    for key, threshold in vpolicy.get("release_thresholds", {}).items():
        if key in counts:
            declared = declared_counts.get(key) if isinstance(declared_counts, dict) else None
            require(isinstance(declared, int) and declared == counts[key], f"vulnerability {key} count is missing or inconsistent", errors)
            require(counts[key] <= threshold, f"vulnerability {key} threshold exceeded", errors)

    require(measurements.get("measurement_status") == "complete", "engine measurements are incomplete", errors)
    selected = measurements.get("decision", {}).get("selected")
    require(selected in {"pdf2htmlEX", "mupdf-custom"}, "engine decision is not selected", errors)
    require(adr3.get("status") == "accepted", "engine decision record is not accepted", errors)
    require(adr3.get("current_outcome") == selected, "ADR outcome disagrees with measured selection", errors)
    require(bool(measurements.get("corpus_revision")) and bool(measurements.get("threshold_revision")), "measurement corpus/threshold revision is missing", errors)
    env = measurements.get("environment", {})
    require(bool(env.get("renderer_versions")) and env.get("viewport") is not None and env.get("reference_dpi") is not None,
            "measurement environment is incomplete", errors)
    thresholds = measurements.get("thresholds")
    required_metrics = set(adr3.get("prerequisites", {}).get("required_metrics", []))
    require(isinstance(thresholds, dict) and set(thresholds) == required_metrics, "measurement thresholds do not exactly cover required metrics", errors)
    for metric, rule in thresholds.items() if isinstance(thresholds, dict) else []:
        require(isinstance(rule, dict) and set(rule) in ({"minimum"}, {"maximum"}),
                f"threshold {metric} must define exactly one numeric minimum or maximum", errors)
        if isinstance(rule, dict):
            bound = rule.get("minimum", rule.get("maximum"))
            require(isinstance(bound, (int, float)) and not isinstance(bound, bool) and math.isfinite(bound),
                    f"threshold {metric} bound is invalid", errors)
    fixture_classes = set(adr3.get("prerequisites", {}).get("minimum_fixture_classes", []))
    candidates = measurements.get("candidates", {})
    for candidate in ("pdf2htmlEX", "mupdf-custom"):
        evidence = candidates.get(candidate, {})
        candidate_digest = evidence.get("image_digest")
        require(isinstance(candidate_digest, str) and bool(DIGEST.fullmatch(candidate_digest)), f"{candidate} candidate digest is invalid", errors)
        fixtures = evidence.get("fixtures", [])
        classes = {f.get("fixture_class") or f.get("class") for f in fixtures if isinstance(f, dict)}
        require(classes >= fixture_classes, f"{candidate} does not cover every required fixture class", errors)
        for index, fixture in enumerate(fixtures if isinstance(fixtures, list) else []):
            require(fixture.get("corpus_revision") == measurements.get("corpus_revision"), f"{candidate} fixture {index} is from a different corpus revision", errors)
            metrics = fixture.get("metrics")
            require(isinstance(metrics, dict) and set(metrics) == required_metrics, f"{candidate} fixture {index} has incomplete metrics", errors)
            require(fixture.get("hard_gates_passed") is True and fixture.get("failure_classification") in {"supported", "unsupported"},
                    f"{candidate} fixture {index} has an unpassed/unclassified hard gate", errors)
            for metric, value in metrics.items() if isinstance(metrics, dict) else []:
                valid_value = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
                require(valid_value, f"{candidate} fixture {index} metric {metric} is invalid", errors)
                rule = thresholds.get(metric) if isinstance(thresholds, dict) else None
                if valid_value and isinstance(rule, dict):
                    minimum, maximum = rule.get("minimum"), rule.get("maximum")
                    if isinstance(minimum, (int, float)) and not isinstance(minimum, bool):
                        require(value >= minimum, f"{candidate} fixture {index} metric {metric} is below threshold", errors)
                    if isinstance(maximum, (int, float)) and not isinstance(maximum, bool):
                        require(value <= maximum, f"{candidate} fixture {index} metric {metric} exceeds threshold", errors)
    selected_digest = candidates.get(selected, {}).get("image_digest") if selected else None
    require(selected_digest == digest, "selected candidate digest does not match production image", errors)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--release", action="store_true", help="require production-release evidence")
    args = parser.parse_args()
    errors: list[str] = []
    files = ["policy.json", "build-inputs.json", "provenance.json", "sbom.cdx.json", "licenses/licenses.json", "vulnerabilities/policy.json", "vulnerabilities/vulnerability-assessment.json", "vulnerabilities/exceptions.json", "evidence/engine-measurements.json", "decisions/ADR-0001-poc-image.json", "decisions/ADR-0002-build-evidence.json", "decisions/ADR-0003-engine-selection.json"]
    records = {}
    for name in files:
        try:
            records[name] = load(name)
        except (OSError, json.JSONDecodeError) as exc:
            errors.append(f"{name}: {exc}")
    if errors:
        return report(errors)

    policy, inputs, provenance, sbom = (records[x] for x in ("policy.json", "build-inputs.json", "provenance.json", "sbom.cdx.json"))
    licenses, vpolicy = records["licenses/licenses.json"], records["vulnerabilities/policy.json"]
    exceptions, measurements = records["vulnerabilities/exceptions.json"], records["evidence/engine-measurements.json"]
    adr3 = records["decisions/ADR-0003-engine-selection.json"]
    require(policy.get("production", {}).get("mutable_tags_allowed") is False, "mutable production tags must be forbidden", errors)
    poc = inputs.get("poc_baseline", {})
    require(poc.get("production_eligible") is False and poc.get("digest") is None, "POC image must remain production-ineligible", errors)
    for item in inputs.get("declared_inputs", []):
        kind = item.get("kind")
        if kind == "vcs":
            require(bool(re.fullmatch(r"[0-9a-f]{40,64}", item.get("commit", ""))), "VCS input lacks full commit", errors)
        elif kind in {"artifact", "base-image"}:
            require(bool(DIGEST.fullmatch(item.get("digest", ""))), "remote input lacks SHA-256 digest", errors)
        else:
            errors.append(f"unknown declared input kind: {kind!r}")
    require(provenance.get("_type") == "https://in-toto.io/Statement/v1", "provenance statement type is invalid", errors)
    require(provenance.get("predicateType") == "https://slsa.dev/provenance/v1", "provenance predicate type is invalid", errors)
    require(sbom.get("bomFormat") == "CycloneDX" and sbom.get("specVersion") == "1.5", "SBOM must be CycloneDX 1.5", errors)
    require(licenses.get("policy", {}).get("require_spdx_expression_per_component") is True, "license SPDX review is not required", errors)
    thresholds = vpolicy.get("release_thresholds", {})
    for key in ("critical", "high", "known_exploited", "malware"):
        require(thresholds.get(key) == 0, f"{key} vulnerability threshold must be zero", errors)
    required_fields = set(vpolicy.get("exception_requirements", {}).get("fields", []))
    today = dt.datetime.now(dt.timezone.utc)
    for exception in exceptions.get("exceptions", []):
        require(required_fields <= exception.keys(), f"exception missing fields: {exception.get('id')}", errors)
        require(bool(DIGEST.fullmatch(exception.get("subject_digest", ""))), f"exception digest invalid: {exception.get('id')}", errors)
        approved = timestamp(exception.get("approved_at"), f"exception approved_at ({exception.get('id')})", errors)
        expires = timestamp(exception.get("expires_at"), f"exception expires_at ({exception.get('id')})", errors)
        require(approved is not None and expires is not None and expires > today and expires - approved <= dt.timedelta(days=30), f"exception expired or exceeds 30 days: {exception.get('id')}", errors)
    required_metrics = set(adr3.get("prerequisites", {}).get("required_metrics", []))
    require("page_raster_text_violations" in required_metrics, "raster-text hard evidence missing", errors)
    if measurements.get("measurement_status") != "complete":
        require(measurements.get("decision", {}).get("selected") is None, "incomplete measurements cannot select an engine", errors)
        require(adr3.get("current_outcome") == "blocked_pending_measurement", "ADR must remain blocked", errors)
    if args.release:
        release_checks(records, errors)
    return report(errors, args.release)


def report(errors: list[str], release: bool = False) -> int:
    if errors:
        for error in errors:
            print(f"ERROR: {error}", file=sys.stderr)
        print(f"image-policy validation failed ({len(errors)} error(s))", file=sys.stderr)
        return 1
    suffix = "release evidence valid" if release else "structural evidence valid; production selection remains blocked"
    print(f"image-policy validation passed ({suffix})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
