from __future__ import annotations

import copy
import sys
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path


ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "v3-operations"))

from check_v3_operations_gates import (  # noqa: E402
    GateError,
    _read_json,
    load_package,
    validate_package_data,
    validate_proposal_candidate,
)


NOW = datetime(2026, 8, 26, 21, 0, tzinfo=timezone.utc)


class V3OperationsGateTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.base = load_package(ROOT)

    def errors(self, mutate):
        candidate = copy.deepcopy(self.base)
        mutate(candidate)
        return validate_package_data(candidate, now=NOW)

    def test_canonical_package_is_valid_and_fail_closed(self):
        self.assertEqual(validate_package_data(copy.deepcopy(self.base), now=NOW), [])
        release = self.base["release"]
        self.assertFalse(release["deploymentAuthorized"])
        self.assertFalse(release["deployed"])
        self.assertFalse(release["activated"])
        self.assertIsNone(release.get("partnerReady"))

    def test_rejects_selector_scope_escalation(self):
        def mutate(data):
            rule = copy.deepcopy(data["selectors"]["allowlist"][0])
            rule["id"] = "ESCALATED"
            rule["targetModule"] = "UnsupportedModule"
            rule["maxValueWei"] = "1"
            rule["timelockSecondsMinimum"] = 0
            data["selectors"]["allowlist"].append(rule)

        text = "\n".join(self.errors(mutate))
        self.assertIn("outside supported-v3-modules.json", text)
        self.assertIn("native value must be zero", text)
        self.assertIn("timelock is below 24 hours", text)

    def test_rejects_role_overlap(self):
        def mutate(data):
            for role_id in ("guardian", "governance"):
                row = next(r for r in data["roles"]["roles"] if r["id"] == role_id)
                row["members"] = ["subject:overlap"]
                row["assignmentStatus"] = "evidenced"
                row["threshold"] = {"minimum": 1, "populationMinimum": 1}

        self.assertTrue(
            any("role overlap prohibited" in error for error in self.errors(mutate))
        )

    def test_rejects_missing_responders(self):
        def mutate(data):
            data["release"]["evidenceComplete"] = True

        text = "\n".join(self.errors(mutate))
        self.assertIn("missing responders: incident-responders", text)
        self.assertIn("missing responders: reconciliation-responders", text)

    def test_rejects_stale_evidence(self):
        def mutate(data):
            data["release"]["evidenceRecords"] = [{
                "id": "old",
                "observedAtUtc": "2026-08-20T00:00:00Z",
                "maximumAgeHours": 24,
                "evidenceRefs": ["evidence/old.json"]
            }]

        self.assertIn("stale evidence: old", self.errors(mutate))

    def test_rejects_readiness_overclaim(self):
        def mutate(data):
            data["release"]["deploymentAuthorized"] = True

        text = "\n".join(self.errors(mutate))
        self.assertIn("readiness overclaim", text)
        self.assertIn("requires recorded human authorization", text)

    def test_rejects_partner_readiness_overclaim(self):
        def mutate(data):
            data["release"]["partnerReady"] = True

        self.assertIn("partnerReady overclaim", "\n".join(self.errors(mutate)))

    def test_rejects_prohibited_inputs(self):
        def mutate(data):
            data["release"]["observedInputClasses"] = ["belief"]

        self.assertTrue(
            any("prohibited inputs observed" in error for error in self.errors(mutate))
        )

    def test_rejects_mainnet_activation_without_human_authorization(self):
        def mutate(data):
            for stage in (
                "sourceComplete",
                "evidenceComplete",
                "deploymentAuthorized",
                "deployed",
                "activated",
            ):
                data["release"][stage] = True
            data["release"]["humanAuthorization"]["recorded"] = False

        self.assertTrue(
            any(
                "mainnet activation without human authorization" in error
                for error in self.errors(mutate)
            )
        )

    def test_rejects_one_of_one_governance(self):
        def mutate(data):
            row = next(r for r in data["roles"]["roles"] if r["id"] == "governance")
            row["threshold"] = {"minimum": 1, "populationMinimum": 1}

        self.assertTrue(any("immutable minimum" in e for e in self.errors(mutate)))

    def test_rejects_removed_separation_rotation_and_recusal(self):
        def mutate(data):
            data["roles"]["separationRules"] = []
            data["roles"]["keyLifecycle"]["rotation"] = {}
            data["roles"]["recusalAndConflict"] = {}

        text = "\n".join(self.errors(mutate))
        self.assertIn("separation matrix changed", text)
        self.assertIn("rotation controls weakened", text)
        self.assertIn("recusal/conflict controls weakened", text)

    def test_rejects_weakened_responder_guardian_and_drills(self):
        def mutate(data):
            coverage = data["roles"]["responderCoverage"]
            coverage["minimumDistinctSubjectsPerRole"] = 1
            coverage["continuousCoverageEvidenceRequired"] = False
            data["operations"]["guardian"]["forbiddenActions"] = []
            for drill in data["operations"]["drills"]:
                drill["steps"] = []
                drill["passCriteria"] = []

        text = "\n".join(self.errors(mutate))
        self.assertIn("responder minima", text)
        self.assertIn("guardian forbidden-action", text)
        self.assertIn("steps or pass criteria weakened", text)

    def test_rejects_weakened_freshness_authority_and_manifest_binding(self):
        def mutate(data):
            data["operations"]["freshness"] = {}
            data["selectors"]["allowlist"][0]["requiredAuthority"] = "EOA"
            data["selectors"]["sourceAuthorities"]["supportedModules"]["requiredSchemaVersion"] = "9.9.9"

        text = "\n".join(self.errors(mutate))
        self.assertIn("freshness policy changed", text)
        self.assertIn("authority weakened", text)
        self.assertIn("requiredSchemaVersion", text)

    def test_rejects_duplicate_ids_empty_default_deny_and_missing_reference(self):
        def mutate(data):
            data["selectors"]["allowlist"][1]["id"] = data["selectors"]["allowlist"][0]["id"]
            deny = next(r for r in data["selectors"]["denylist"] if r["id"] == "DENY-UNKNOWN")
            deny["match"] = {}
            data["release"]["blockers"][0]["evidenceRefs"] = ["does/not/exist.json"]

        text = "\n".join(self.errors(mutate))
        self.assertIn("duplicate allowlist id", text)
        self.assertIn("empty match semantics", text)
        self.assertIn("evidence reference does not exist", text)

    def test_rejects_stale_canonical_status(self):
        def mutate(data):
            data["release"]["asOfUtc"] = "2020-01-01T00:00:00Z"

        self.assertTrue(any("canonical release status is stale" in e for e in self.errors(mutate)))

    def test_rejects_duplicate_json_keys(self):
        with tempfile.TemporaryDirectory() as directory:
            path = Path(directory) / "duplicate.json"
            path.write_text('{"security":true,"security":false}', encoding="utf-8")
            with self.assertRaises(GateError):
                _read_json(path)

    def test_accepts_inventory_of_nonprohibited_safe_input(self):
        candidate = copy.deepcopy(self.base)
        candidate["release"]["observedInputClasses"] = ["objective-signed-evidence"]
        errors = validate_package_data(candidate, now=NOW)
        self.assertFalse(any("prohibited inputs observed" in e for e in errors))

    def test_rejects_policy_commitment_or_artifact_drift(self):
        def mutate(data):
            data["commitment"]["candidateId"] = "0" * 64

        text = "\n".join(self.errors(mutate))
        self.assertIn("candidate commitment digest mismatch", text)
        self.assertIn("generated Solidity commitment is stale", text)

    def test_rejects_governance_source_binding_drift(self):
        def mutate(data):
            source = next(
                row
                for row in data["commitment"]["implementationBindings"]
                if row["path"].endswith("MultisigGovernanceV3.sol")
            )
            source["sha256"] = "0" * 64

        text = "\n".join(self.errors(mutate))
        self.assertIn("implementation digest mismatch", text)
        self.assertIn("candidate commitment digest mismatch", text)

    def test_rejects_governance_enforcement_drift(self):
        def mutate(data):
            assessment = data["enforcement"]["sourceAssessment"]
            assessment["exactGeneratedPolicyDigestConsumed"] = False
            assessment["perRuleCalldataLimitCheckedAtExecution"] = False
            data["enforcement"]["rawGovernancePolicyEnforced"] = False
            data["enforcement"]["integrationStatus"] = "partial"

        text = "\n".join(self.errors(mutate))
        self.assertIn("source assessment is stale or misstates enforcement", text)

    def test_rejects_release_progress_before_governance_integration(self):
        def mutate(data):
            data["enforcement"]["rawGovernancePolicyEnforced"] = False
            data["enforcement"]["integrationStatus"] = "partial"
            data["release"]["sourceComplete"] = True

        self.assertTrue(
            any("governance policy commitment is not integrated" in e for e in self.errors(mutate))
        )

    def test_rejects_base_snapshot_metadata_drift(self):
        def mutate(data):
            data["commitment"]["baseSnapshot"]["fileCount"] = 116

        text = "\n".join(self.errors(mutate))
        self.assertIn("immutable Base snapshot metadata is stale or incomplete", text)
        self.assertIn("candidate commitment digest mismatch", text)

    def test_rejects_canonical_ledger_binding_drift(self):
        def mutate(data):
            data["commitment"]["ledgerBindings"][0]["sha256"] = "0" * 64

        text = "\n".join(self.errors(mutate))
        self.assertIn("ledger binding digest mismatch", text)
        self.assertIn("candidate commitment digest mismatch", text)

    def test_rejects_current_authority_binding_drift(self):
        def mutate(data):
            data["commitment"]["authorityBindings"][0]["sha256"] = "0" * 64

        text = "\n".join(self.errors(mutate))
        self.assertIn("authority binding digest mismatch", text)
        self.assertIn("candidate commitment digest mismatch", text)

    def test_rejects_open_spec_and_partner_overclaims(self):
        def mutate(data):
            report = data["authorityFacts"]["openSpec"]
            report["specificationCompliant"] = True
            partner = data["authorityFacts"]["partnerPackage"]
            partner["partnerReady"] = True

        text = "\n".join(self.errors(mutate))
        self.assertIn("current authority facts digest mismatch", text)

    def test_proposal_validator_rejects_unknown_value_trailing_and_bad_bool(self):
        commitment = self.base["commitment"]["candidateId"]
        unknown = {
            "candidateId": commitment,
            "targetModule": "SafeBondFactoryV3",
            "valueWei": "0",
            "calldataHex": "0xdeadbeef",
        }
        self.assertIn(
            "not uniquely allowlisted",
            "\n".join(validate_proposal_candidate(self.base, unknown)),
        )
        selector = "3a281eb8"  # setDeployAllowed(bool)
        bad = {
            "candidateId": commitment,
            "targetModule": "SafeBondFactoryV3",
            "valueWei": "1",
            "calldataHex": "0x" + selector + (2).to_bytes(32, "big").hex() + "00" * 32,
        }
        text = "\n".join(validate_proposal_candidate(self.base, bad))
        self.assertIn("native value is prohibited", text)
        self.assertIn("exceeds policy limit", text)
        self.assertIn("noncanonical static calldata", text)

    def test_proposal_validator_accepts_canonical_structural_call(self):
        proposal = {
            "candidateId": self.base["commitment"]["candidateId"],
            "targetModule": "SafeBondFactoryV3",
            "valueWei": "0",
            "calldataHex": "0x3a281eb8" + (0).to_bytes(32, "big").hex(),
        }
        self.assertEqual(validate_proposal_candidate(self.base, proposal), [])


if __name__ == "__main__":
    unittest.main()
