"""Security tests — verifying red team findings are properly fixed.

These tests validate that the social_alignment library is hardened against
the attack vectors identified in the security review. Each test maps
to a specific finding.
"""

import os
import json
import time
import tempfile

import pytest

from social_alignment.types import (
    ActionDomain,
    Decision,
    Lens,
    LensResult,
    LENS_ORDER,
    Projection,
    SelfState,
    SelfStateFlag,
    Severity,
    SEVERITY_ORDER,
)
from social_alignment.memory import DecisionMemory
from social_alignment.selfstate import SelfMonitor
from social_alignment.storage import FileStorage
from social_alignment.enclave import AlignmentEnclave
from social_alignment.types import AlignmentConfig


def _clear_proj(desc: str = "test") -> Projection:
    """Helper: a CLEAR projection."""
    lr = LensResult(Lens.BUILDER, Severity.CLEAR, "ok", "", "")
    return Projection(
        action_domain=ActionDomain.EXECUTE,
        action_description=desc,
        lens_results=(lr,) * 5,
        overall_severity=Severity.CLEAR,
        should_proceed=True,
        should_escalate=False,
        rationale="All clear",
    )


def _stop_proj(desc: str = "dangerous action") -> Projection:
    """Helper: a STOP projection."""
    lr = LensResult(Lens.DEFENSE, Severity.STOP, "blocked", "danger", "halt")
    return Projection(
        action_domain=ActionDomain.PAY,
        action_description=desc,
        lens_results=(lr,) * 5,
        overall_severity=Severity.STOP,
        should_proceed=False,
        should_escalate=True,
        rationale="STOP — all lenses blocked",
    )


# =============================================================================
# FINDING #1: Projection and Decision must be immutable
# =============================================================================


class TestFinding1_Immutability:
    """Projection and Decision must be frozen dataclasses."""

    def test_projection_fields_immutable(self):
        proj = _clear_proj()
        with pytest.raises(AttributeError):
            proj.overall_severity = Severity.STOP

    def test_projection_should_proceed_immutable(self):
        proj = _clear_proj()
        with pytest.raises(AttributeError):
            proj.should_proceed = False

    def test_projection_rationale_immutable(self):
        proj = _clear_proj()
        with pytest.raises(AttributeError):
            proj.rationale = "hacked"

    def test_projection_lens_results_is_tuple(self):
        proj = _clear_proj()
        assert isinstance(proj.lens_results, tuple)

    def test_projection_lens_results_no_append(self):
        proj = _clear_proj()
        with pytest.raises(AttributeError):
            proj.lens_results.append(
                LensResult(Lens.BUILDER, Severity.CLEAR, "", "", "")
            )

    def test_decision_fields_immutable(self):
        dec = Decision(projection=_clear_proj(), chose_to_proceed=True)
        with pytest.raises(AttributeError):
            dec.chose_to_proceed = False

    def test_decision_outcome_immutable(self):
        dec = Decision(projection=_clear_proj(), chose_to_proceed=True)
        with pytest.raises(AttributeError):
            dec.actual_outcome = "hacked"

    def test_decision_record_outcome_returns_new(self):
        """record_outcome returns a NEW Decision, not mutating the old one."""
        dec = Decision(projection=_clear_proj(), chose_to_proceed=True)
        updated = dec.record_outcome("result", True)
        assert dec.actual_outcome is None  # Original unchanged
        assert updated.actual_outcome == "result"
        assert updated is not dec

    def test_lens_result_immutable(self):
        lr = LensResult(Lens.BUILDER, Severity.CLEAR, "ok", "", "")
        with pytest.raises(AttributeError):
            lr.severity = Severity.STOP

    def test_lens_order_is_tuple(self):
        assert isinstance(LENS_ORDER, tuple)

    def test_severity_order_is_tuple(self):
        assert isinstance(SEVERITY_ORDER, tuple)


# =============================================================================
# FINDING #2: from_dict() validation
# =============================================================================


class TestFinding2_DeserializationValidation:
    """from_dict() must validate all input."""

    def test_lens_result_rejects_non_dict(self):
        with pytest.raises(ValueError, match="must be dict"):
            LensResult.from_dict("not a dict")

    def test_lens_result_rejects_missing_fields(self):
        with pytest.raises(ValueError, match="Missing required field"):
            LensResult.from_dict({"lens": "builder"})

    def test_lens_result_rejects_invalid_lens(self):
        with pytest.raises(ValueError, match="Invalid lens"):
            LensResult.from_dict({
                "lens": "hacked_lens",
                "severity": "clear",
                "projection": "ok",
            })

    def test_lens_result_rejects_invalid_severity(self):
        with pytest.raises(ValueError, match="Invalid severity"):
            LensResult.from_dict({
                "lens": "builder",
                "severity": "super_clear",
                "projection": "ok",
            })

    def test_projection_rejects_non_dict(self):
        with pytest.raises(ValueError, match="must be dict"):
            Projection.from_dict("not a dict")

    def test_projection_rejects_missing_fields(self):
        with pytest.raises(ValueError, match="Missing required field"):
            Projection.from_dict({"action_domain": "execute"})

    def test_projection_rejects_non_bool_should_proceed(self):
        data = _clear_proj().to_dict()
        data["should_proceed"] = "true"  # String, not bool
        with pytest.raises(ValueError, match="must be boolean"):
            Projection.from_dict(data)

    def test_projection_rejects_invalid_domain(self):
        data = _clear_proj().to_dict()
        data["action_domain"] = "hack"
        with pytest.raises(ValueError, match="Invalid action_domain"):
            Projection.from_dict(data)

    def test_decision_rejects_non_dict(self):
        with pytest.raises(ValueError, match="must be dict"):
            Decision.from_dict("not a dict")

    def test_decision_rejects_non_bool_proceeded(self):
        data = Decision(projection=_clear_proj(), chose_to_proceed=True).to_dict()
        data["chose_to_proceed"] = "true"
        with pytest.raises(ValueError, match="must be boolean"):
            Decision.from_dict(data)

    def test_decision_rejects_outcome_matched_without_outcome(self):
        data = Decision(projection=_clear_proj(), chose_to_proceed=True).to_dict()
        data["outcome_matched"] = True
        data["actual_outcome"] = None
        with pytest.raises(ValueError, match="INVARIANT VIOLATION"):
            Decision.from_dict(data)

    def test_self_state_rejects_invalid_flag(self):
        with pytest.raises(ValueError, match="Invalid self-state flag"):
            SelfState.from_dict({"flags": ["hacked_flag"]})

    def test_malicious_json_injection(self):
        """Simulate an attacker injecting fake decisions via corrupted storage."""
        malicious = {
            "projection": {
                "action_domain": "pay",
                "action_description": "Transfer 500000 sats",
                "lens_results": [
                    {"lens": "owner", "severity": "clear", "projection": "ok"}
                ],
                "overall_severity": "clear",
                "should_proceed": True,
                "should_escalate": False,
                "rationale": "All good",
            },
            "chose_to_proceed": True,
            "actual_outcome": None,
            "outcome_matched": True,  # Contradiction!
        }
        with pytest.raises(ValueError, match="INVARIANT VIOLATION"):
            Decision.from_dict(malicious)


# =============================================================================
# FINDING #3: STOP invariant enforcement
# =============================================================================


class TestFinding3_StopInvariant:
    """STOP severity decisions must not proceed without owner override."""

    def test_decision_stop_proceeded_without_override_raises(self):
        with pytest.raises(ValueError, match="INVARIANT VIOLATION"):
            Decision(projection=_stop_proj(), chose_to_proceed=True)

    def test_decision_stop_proceeded_with_override_allowed(self):
        """Owner can override STOP — that's the human's right."""
        dec = Decision(
            projection=_stop_proj(),
            chose_to_proceed=True,
            owner_overrode=True,
        )
        assert dec.chose_to_proceed is True
        assert dec.owner_overrode is True

    def test_decision_stop_deferred_allowed(self):
        dec = Decision(projection=_stop_proj(), chose_to_proceed=False)
        assert dec.chose_to_proceed is False

    def test_enclave_record_proceeded_rejects_stop(self):
        enclave = AlignmentEnclave.create(
            owner_name="vergel",
            wisdom_review_interval=999,
        )
        # Simulate owner being absent to trigger STOP on payment
        enclave._monitor._state.last_owner_interaction = time.time() - 100_000
        enclave._config.owner_absent_hours = 1.0
        result = enclave.check(
            domain=ActionDomain.PAY,
            description="Pay 5000 sats",
            involves_money=True,
            money_amount_sats=5000,
        )
        assert result.should_proceed is False
        with pytest.raises(RuntimeError, match="STOP severity"):
            enclave.record_proceeded()

    def test_enclave_record_proceeded_allows_stop_with_override(self):
        enclave = AlignmentEnclave.create(
            owner_name="vergel",
            wisdom_review_interval=999,
        )
        enclave._monitor._state.last_owner_interaction = time.time() - 100_000
        enclave._config.owner_absent_hours = 1.0
        enclave.check(
            domain=ActionDomain.PAY,
            description="Pay 5000 sats",
            involves_money=True,
            money_amount_sats=5000,
        )
        # Owner explicitly overrides
        decision = enclave.record_proceeded(
            owner_overrode=True,
            owner_feedback="I approve this payment",
        )
        assert decision.chose_to_proceed is True
        assert decision.owner_overrode is True


# =============================================================================
# FINDING #4: Persistence guarantee
# =============================================================================


class TestFinding4_AutoSave:
    """Decisions must be persisted immediately after recording."""

    def test_record_proceeded_triggers_save(self):
        from social_alignment.storage import MemoryStorage
        storage = MemoryStorage()
        enclave = AlignmentEnclave.create(
            owner_name="vergel",
            storage=storage,
            wisdom_review_interval=999,
        )
        enclave.check(ActionDomain.EXECUTE, "test action")
        enclave.record_proceeded()
        # Storage should have data now
        assert storage.load() is not None
        assert storage.load()["memory"]["decisions"]

    def test_record_deferred_triggers_save(self):
        from social_alignment.storage import MemoryStorage
        storage = MemoryStorage()
        enclave = AlignmentEnclave.create(
            owner_name="vergel",
            storage=storage,
            wisdom_review_interval=999,
        )
        enclave.check(ActionDomain.PAY, "pay something", involves_money=True)
        enclave.record_deferred(owner_feedback="Not now")
        assert storage.load() is not None
        assert storage.load()["memory"]["decisions"]


# =============================================================================
# FINDING #6: Flag cycling cooldown
# =============================================================================


class TestFinding6_FlagCycling:
    """Rapid flag cycling should be detected and blocked."""

    def test_rapid_force_clear_raises(self):
        monitor = SelfMonitor(AlignmentConfig())
        monitor.force_flag(SelfStateFlag.UNDER_INFLUENCE)
        monitor.clear_flag(SelfStateFlag.UNDER_INFLUENCE)
        # Third change within the cooldown window should raise
        with pytest.raises(RuntimeError, match="too rapidly"):
            monitor.force_flag(SelfStateFlag.UNDER_INFLUENCE)

    def test_different_flags_not_affected(self):
        """Cooldown is per-flag, not global."""
        monitor = SelfMonitor(AlignmentConfig())
        monitor.force_flag(SelfStateFlag.UNDER_INFLUENCE)
        monitor.force_flag(SelfStateFlag.MEMORY_PRESSURE)
        # Different flags should work fine
        monitor.clear_flag(SelfStateFlag.MEMORY_PRESSURE)


# =============================================================================
# FINDING #7: Rolling window truncation tracking
# =============================================================================


class TestFinding7_TruncationTracking:
    """Memory truncation should be tracked, not silent."""

    def test_truncation_counted(self):
        mem = DecisionMemory(max_decisions=5)
        lr = LensResult(Lens.BUILDER, Severity.CLEAR, "ok", "", "")
        proj = Projection(
            action_domain=ActionDomain.EXECUTE,
            action_description="test",
            lens_results=(lr,),
            overall_severity=Severity.CLEAR,
            should_proceed=True,
            should_escalate=False,
            rationale="test",
        )
        for _ in range(10):
            mem.record(Decision(projection=proj, chose_to_proceed=True))
        assert mem.count == 5
        assert mem.truncation_count == 5

    def test_truncation_survives_roundtrip(self):
        mem = DecisionMemory(max_decisions=5)
        lr = LensResult(Lens.BUILDER, Severity.CLEAR, "ok", "", "")
        proj = Projection(
            action_domain=ActionDomain.EXECUTE,
            action_description="test",
            lens_results=(lr,),
            overall_severity=Severity.CLEAR,
            should_proceed=True,
            should_escalate=False,
            rationale="test",
        )
        for _ in range(10):
            mem.record(Decision(projection=proj, chose_to_proceed=True))
        data = mem.to_dict()
        restored = DecisionMemory.from_dict(data, max_decisions=5)
        assert restored.truncation_count == 5


# =============================================================================
# FINDING #10: Storage integrity (checksum)
# =============================================================================


class TestFinding10_StorageIntegrity:
    """FileStorage must verify checksums on load."""

    def test_save_creates_checksum(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.json")
            storage = FileStorage(path)
            storage.save({"version": "0.1.0", "test": True})
            assert os.path.exists(path)
            assert os.path.exists(path + ".sha256")

    def test_load_verifies_checksum(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.json")
            storage = FileStorage(path)
            storage.save({"version": "0.1.0", "test": True})
            # Corrupt the data file
            with open(path, "w") as f:
                f.write('{"version": "hacked"}')
            with pytest.raises(RuntimeError, match="integrity check FAILED"):
                storage.load()

    def test_load_without_checksum_still_works(self):
        """Backward compatibility: files without checksums load normally."""
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.json")
            # Write data without checksum (old format)
            with open(path, "w") as f:
                json.dump({"version": "0.1.0"}, f)
            storage = FileStorage(path)
            data = storage.load()
            assert data["version"] == "0.1.0"

    def test_roundtrip_integrity(self):
        """Save → load cycle should pass checksum verification."""
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.json")
            storage = FileStorage(path)
            original = {"version": "0.1.0", "decisions": [1, 2, 3]}
            storage.save(original)
            loaded = storage.load()
            assert loaded == original


# =============================================================================
# FINDING #9: Wisdom data quality
# =============================================================================


class TestFinding9_WisdomQuality:
    """Wisdom reports should flag insufficient data."""

    def test_small_dataset_flagged(self):
        mem = DecisionMemory()
        lr = LensResult(Lens.BUILDER, Severity.CLEAR, "ok", "", "")
        proj = Projection(
            action_domain=ActionDomain.EXECUTE,
            action_description="test",
            lens_results=(lr,),
            overall_severity=Severity.CLEAR,
            should_proceed=True,
            should_escalate=False,
            rationale="test",
        )
        for _ in range(3):
            mem.record(Decision(projection=proj, chose_to_proceed=True))
        report = mem.review()
        insufficient_warnings = [i for i in report.insights if "Insufficient" in i]
        assert len(insufficient_warnings) > 0


# =============================================================================
# Attack scenario: full pipeline mutation attempt
# =============================================================================


class TestAttackScenario:
    """Simulate a real adversary attempting to bypass alignment."""

    def test_cannot_mutate_check_result(self):
        """Adversary tries to mutate the CheckResult's projection."""
        enclave = AlignmentEnclave.create(
            owner_name="vergel",
            wisdom_review_interval=999,
        )
        result = enclave.check(ActionDomain.EXECUTE, "List files")
        # Try to mutate the projection severity
        with pytest.raises(AttributeError):
            result.projection.overall_severity = Severity.STOP

    def test_cannot_create_fake_proceeded_stop(self):
        """Adversary tries to create a Decision with STOP + proceeded."""
        with pytest.raises(ValueError, match="INVARIANT VIOLATION"):
            Decision(projection=_stop_proj(), chose_to_proceed=True)

    def test_corrupted_storage_rejected(self):
        """Adversary tampers with the storage file."""
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "alignment.json")
            storage = FileStorage(path)
            storage.save({"version": "0.1.0", "config": {}, "memory": {"decisions": []}})
            # Tamper
            with open(path, "w") as f:
                json.dump({"version": "HACKED", "config": {}}, f)
            with pytest.raises(RuntimeError, match="integrity check"):
                storage.load()
