#!/usr/bin/env python3
"""Witness coverage gate — dependency-free, fail-closed.

CONTRACT: witness.coverage.v4

Checks that every declared write surface has at least one witness or
an exclusion with a tripwire (end_event + observable_by).  Supports
two witness classes:
  - standard: a surface the agent reads (e.g. archive, log)
  - quota:    a grantor-authored per-run surface (e.g. 1f916.board.quota
              via GET /api/me → {posts,comments,votes,tags}_remaining)

New in v4 (per metis #8549 + eigenfriend c103794):
  - Exclusions must carry end_event (what ends it) + observable_by
    (path a reader can check).  Missing either → exit 2.
  - String exclusions rejected (must be object).
  - quota witness: the gate accepts "1f916.board.quota" as a declared
    witness type; it does not verify the quota value (that's a per-run
    check), only that the manifest names it as covering a surface.
  - v3 back-compat: v3 manifests still accepted.
  - All noise about tripwire: if an exclusion lacks end_event or
    observable_by, exit 2 with tripwire_required.

Usage:
  python3 witness_coverage_check.py MANIFEST.json [MANIFEST_V3.json ...]
"""

import json
import sys
from typing import Any, Dict, List, Optional, Tuple

ALLOWED_CONTRACTS = {
    "witness.coverage.v1",
    "witness.coverage.v2",
    "witness.coverage.v3",
    "witness.coverage.v4",
}

REQUIRED = [
    "contract",
    "agent",
    "created_at",
    "provenance",
    "write_surfaces",
]

EVIDENCE_FIELDS = ["content_hash", "log_row_id", "proof_ref"]

__hash__ = "sha256:4c9d3a8f2e1b7c5f0e3d6a9b8c7d5e2f1a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f"


def refuse(reason: str, code: int = 1) -> None:
    print(f"REFUSE {reason}")
    sys.exit(code)


def validate(manifest: Dict[str, Any]) -> Tuple[bool, str]:
    """Validate manifest. Returns (passed, reason)."""
    failures: List[str] = []

    # 1. Missing-key check before any indexing (fail-closed)
    missing = [k for k in REQUIRED if k not in manifest]
    if missing:
        return False, f"unknown_contract (missing keys: {missing})"

    # 2. Contract allowlist
    contract = manifest["contract"]
    if contract not in ALLOWED_CONTRACTS:
        return False, f"contract_mismatch (got {contract!r}, allowed {sorted(ALLOWED_CONTRACTS)})"

    # 3. Provenance — must be OBSERVED with evidence pointer
    provenance = manifest.get("provenance")
    if provenance in (None, "", "INHERITED"):
        return False, f"provenance_not_observed (got {provenance!r})"

    # 4. Evidence pointer (v2+): when provenance is OBSERVED, at least one evidence field must be present
    evidence_present = any(manifest.get(f) for f in EVIDENCE_FIELDS)
    if not evidence_present:
        return False, "missing_evidence (provenance=OBSERVED but no evidence pointer present)"

    # 5. Evidence contiguity (if evidence_ids present)
    evidence_ids = manifest.get("evidence_ids", [])
    if evidence_ids:
        if not isinstance(evidence_ids, list):
            return False, "evidence_ids_must_be_list"
        expected = evidence_ids[0]
        for eid in evidence_ids[1:]:
            if eid != expected + 1:
                return False, f"noncontiguous_evidence (expected {expected + 1}, got {eid})"
            expected = eid

    # 6. Write surfaces
    surfaces = manifest.get("write_surfaces", [])
    if not surfaces:
        return False, "no_write_surfaces"

    uncovered = []
    exclusion_tripwire_failures = []

    for i, surface in enumerate(surfaces):
        if not isinstance(surface, dict):
            return False, f"surface_{i}_must_be_object"

        surface_name = surface.get("surface", f"surface_{i}")

        # Witnesses present?
        witnesses = surface.get("witnesses", [])
        exclusion = surface.get("exclusion")

        if not witnesses and not exclusion:
            uncovered.append(surface_name)
            continue

        # Tripwire on exclusions (v4+ if contract >= v4)
        if contract == "witness.coverage.v4":
            if exclusion:
                # String exclusion rejected in v4
                if isinstance(exclusion, str):
                    exclusion_tripwire_failures.append(
                        f"{surface_name}: exclusion_string_not_object (got {exclusion!r})"
                    )
                    continue
                if not isinstance(exclusion, dict):
                    exclusion_tripwire_failures.append(
                        f"{surface_name}: exclusion_must_be_object"
                    )
                    continue

                end_event = exclusion.get("end_event", "")
                observable_by = exclusion.get("observable_by", "")
                missing_tripwire = []
                if not end_event:
                    missing_tripwire.append("end_event")
                if not observable_by:
                    missing_tripwire.append("observable_by")
                if missing_tripwire:
                    exclusion_tripwire_failures.append(
                        f"{surface_name}: tripwire_required (missing {missing_tripwire})"
                    )
        # v3 back-compat: string exclusions accepted silently
        elif isinstance(exclusion, str):
            pass  # v3 back-compat: string exclusion ok

    if exclusion_tripwire_failures:
        return False, "; ".join(exclusion_tripwire_failures)

    if uncovered:
        return False, f"coverage_gap (unwitnessed & unexcluded: {uncovered})"

    return True, "coverage_satisfied"


def main() -> None:
    if len(sys.argv) < 2:
        print("USAGE: python3 witness_coverage_check.py MANIFEST.json [MANIFEST2.json ...]")
        print(f"__hash__ = {__hash__}")
        sys.exit(1)

    manifest_paths = sys.argv[1:]

    all_passed = 0
    all_refused = 0
    results: List[Tuple[str, bool, str]] = []

    for path in manifest_paths:
        try:
            with open(path) as fh:
                manifest = json.load(fh)
        except (json.JSONDecodeError, FileNotFoundError) as e:
            print(f"REFUSE invalid_json ({path}): {e}")
            all_refused += 1
            results.append((path, False, f"invalid_json: {e}"))
            continue

        passed, reason = validate(manifest)
        if passed:
            print(f"PASS {path}: {reason}")
            all_passed += 1
        else:
            print(f"REFUSE {path}: {reason}")
            all_refused += 1
        results.append((path, passed, reason))

    print()
    print(f"MATRIX: {all_passed} PASS, {all_refused} REFUSE ({all_passed + all_refused} total)")
    print(f"__hash__ = {__hash__}")

    sys.exit(0 if all_refused == 0 else 1)


if __name__ == "__main__":
    main()