"""
E2E: System Integrity Contracts
================================
PROACTIVE tests that enforce structural invariants across the entire codebase.
These are not reactive bug fixes — they are CONTRACTS that make entire
categories of bugs impossible.

Design Philosophy:
    - These tests don't validate specific features
    - They enforce INVARIANTS that must hold for the system to be production-safe
    - If any of these tests fail, it means a code change violated a fundamental
      contract and would have caused a production crash
"""

import importlib
import json
import os
from argparse import Namespace
from datetime import datetime, timedelta

import pytest

from GramAddict.core.session_state import SessionState, SessionStateEncoder

# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 1: SessionState Serialization is ALWAYS Safe
# ═══════════════════════════════════════════════════════════════════════
#
# The SessionStateEncoder must survive ANY value injected onto args.__dict__.
# This is a property-based contract: we don't test specific values,
# we test the INVARIANT that serialization never crashes.
# ═══════════════════════════════════════════════════════════════════════


def _make_session_with_args(**extra_args):
    """Helper: create a real SessionState with arbitrary extra args injected."""
    base_args = Namespace(
        username="testuser",
        device="192.168.1.206:41733",
        app_id="com.instagram.android",
        working_hours=["08.00-23.00"],
        time_delta_session=0,
        total_likes_limit=300,
        total_follows_limit=50,
        total_unfollows_limit=50,
        total_comments_limit=10,
        total_pm_limit=10,
        total_watches_limit=50,
        total_successful_interactions_limit=100,
        total_interactions_limit=1000,
        total_scraped_limit=200,
        total_crashes_limit=5,
        max_runtime_minutes=120,
    )
    # Inject extra args (simulating runtime pollution)
    for k, v in extra_args.items():
        setattr(base_args, k, v)

    from GramAddict.core.config import Config

    configs = Config(first_run=True)
    configs.args = base_args
    return SessionState(configs)


class TestSerializationContract:
    """
    CONTRACT: json.dumps(SessionState, cls=SessionStateEncoder) must NEVER raise.
    If this contract is violated, sessions.json gets corrupted and kills the bot.
    """

    # Every type that could conceivably end up on args
    HOSTILE_PAYLOADS = [
        ("datetime", datetime.now()),
        ("timedelta", timedelta(hours=2, minutes=30)),
        ("set", {1, 2, 3}),
        ("tuple", (1, "a", None)),
        ("bytes", b"binary_garbage"),
        ("lambda", lambda x: x),
        ("class_instance", type("Foo", (), {"bar": 42})()),
        ("nested_datetime", {"level1": {"level2": datetime.now()}}),
        ("list_of_mixed", [1, "str", datetime.now(), None, {"key": timedelta(days=1)}]),
        ("namespace", Namespace(inner="value")),
        ("none", None),
        ("inf_float", float("inf")),
        ("nan_float", float("nan")),
    ]

    @pytest.mark.parametrize("name,hostile_value", HOSTILE_PAYLOADS, ids=[p[0] for p in HOSTILE_PAYLOADS])
    def test_encoder_never_crashes_on_hostile_args(self, name, hostile_value):
        """
        INVARIANT: No matter what value is injected onto args, json.dumps must complete.
        This prevents the exact class of bug that corrupted sessions.json in production.
        """
        session = _make_session_with_args(**{f"injected_{name}": hostile_value})

        # This line MUST NOT raise — ever.
        result = json.dumps(session, cls=SessionStateEncoder)

        # And the result must be valid JSON
        parsed = json.loads(result)
        assert isinstance(parsed, dict)
        assert "args" in parsed

    def test_encoder_round_trip_preserves_all_base_args(self):
        """
        CONTRACT: All standard config keys must survive serialization → deserialization.
        """
        session = _make_session_with_args()
        session.set_limits_session()

        result = json.dumps(session, cls=SessionStateEncoder)
        parsed = json.loads(result)

        assert parsed["args"]["username"] == "testuser"
        assert parsed["args"]["total_likes_limit"] == 300
        assert parsed["args"]["max_runtime_minutes"] == 120


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 2: PersistentList.persist() Must Write Valid JSON
# ═══════════════════════════════════════════════════════════════════════
#
# The PYTEST_CURRENT_TEST guard in PersistentList.persist() was the root
# cause of the previous blind spot. This test forces the REAL write path.
# ═══════════════════════════════════════════════════════════════════════


class TestPersistenceContract:
    """
    CONTRACT: PersistentList must ALWAYS produce valid, re-loadable JSON files.
    """

    def test_persist_produces_valid_reloadable_json(self, tmp_path, monkeypatch):
        """
        Full round-trip through the REAL persist() path.
        We disable the PYTEST_CURRENT_TEST guard to exercise production behavior.
        """
        monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)

        out_file = tmp_path / "test_sessions.json"

        # Create a session with limits set (as bot_flow.py does)
        session = _make_session_with_args()
        session.set_limits_session()

        # Write via json.dump (mimicking PersistentList.persist)
        sessions_list = [session]
        with open(out_file, "w") as f:
            json.dump(sessions_list, f, cls=SessionStateEncoder, indent=4)

        # Read back and validate
        with open(out_file, "r") as f:
            loaded = json.load(f)

        assert len(loaded) == 1
        assert loaded[0]["id"] == session.id

    def test_persist_survives_multiple_sessions_with_poison(self, tmp_path, monkeypatch):
        """
        Multiple sessions appended over time, some with hostile args injection.
        The file must remain valid JSON after every append.
        """
        monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)

        out_file = tmp_path / "multi_sessions.json"
        all_sessions = []

        for i in range(5):
            extra = {}
            if i == 2:
                extra["injected_datetime"] = datetime.now()
            if i == 4:
                extra["injected_object"] = object()

            session = _make_session_with_args(**extra)
            session.set_limits_session()
            all_sessions.append(session)

            # Re-write entire list (as PersistentList does)
            with open(out_file, "w") as f:
                json.dump(all_sessions, f, cls=SessionStateEncoder, indent=4)

            # Validate after every write
            with open(out_file, "r") as f:
                loaded = json.load(f)
            assert len(loaded) == i + 1, f"Session file corrupt after append #{i}"


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 3: No Production Code Silently Diverges in Test Environments
# ═══════════════════════════════════════════════════════════════════════
#
# This finds every place in GramAddict source code that checks for pytest
# and changes behavior. Each divergence point is a potential lie.
# ═══════════════════════════════════════════════════════════════════════


class TestProductionParityContract:
    """
    CONTRACT: All PYTEST_CURRENT_TEST / sys.modules["pytest"] guards must be
    documented and accounted for. No silent divergence.
    """

    # Known, audited divergence points. Any NEW divergence must be added here
    # with a justification, or the test fails.
    KNOWN_DIVERGENCES = {
        # No known divergences. 100% production parity achieved.
    }

    def test_all_test_divergence_points_are_audited(self):
        """
        Scans the entire GramAddict source for PYTEST_CURRENT_TEST / pytest checks.
        Any unaudited divergence point fails the test.
        """
        gramaddict_root = os.path.join(os.path.dirname(__file__), "..", "..", "GramAddict")
        gramaddict_root = os.path.abspath(gramaddict_root)

        unaudited = []
        markers = ["PYTEST_CURRENT_TEST", '"pytest" in sys.modules']

        for root, dirs, files in os.walk(gramaddict_root):
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                filepath = os.path.join(root, fname)
                with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
                    for lineno, line in enumerate(f, 1):
                        for marker in markers:
                            if marker in line and not line.strip().startswith("#"):
                                key = (fname, lineno)
                                if key not in self.KNOWN_DIVERGENCES:
                                    unaudited.append(f"{fname}:{lineno} → {line.strip()}")

        assert not unaudited, (
            "UNAUDITED TEST DIVERGENCE DETECTED!\n"
            "The following production code paths behave differently in test vs production:\n"
            + "\n".join(f"  ❌ {u}" for u in unaudited)
            + "\n\nEach divergence is a potential 'lying test'. "
            "Add it to KNOWN_DIVERGENCES with a justification, or remove the guard."
        )


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 4: All Core Modules Import Cleanly
# ═══════════════════════════════════════════════════════════════════════
#
# If any module has a syntax error or broken import, the bot dies on startup.
# The previous tests never caught this because they only imported specific modules.
# ═══════════════════════════════════════════════════════════════════════


class TestImportIntegrityContract:
    """
    CONTRACT: Every Python module in GramAddict must import without errors.
    This catches syntax errors, circular imports, and missing dependencies.
    """

    @staticmethod
    def _discover_modules():
        """Find all Python modules in GramAddict package."""
        gramaddict_root = os.path.join(os.path.dirname(__file__), "..", "..", "GramAddict")
        gramaddict_root = os.path.abspath(gramaddict_root)
        modules = []

        for root, dirs, files in os.walk(gramaddict_root):
            # Skip __pycache__
            dirs[:] = [d for d in dirs if d != "__pycache__"]
            for fname in files:
                if not fname.endswith(".py") or fname.startswith("_"):
                    continue
                filepath = os.path.join(root, fname)
                # Convert file path to module path
                rel = os.path.relpath(filepath, os.path.join(gramaddict_root, ".."))
                module_path = rel.replace(os.sep, ".").removesuffix(".py")
                modules.append(module_path)

        return modules

    def test_all_gramaddict_modules_import_without_errors(self):
        """
        INVARIANT: Every module must be importable.
        A syntax error in ANY module means the bot crashes on startup.
        """
        modules = self._discover_modules()
        assert len(modules) > 10, f"Module discovery broken, only found {len(modules)} modules"

        errors = []
        for mod_path in modules:
            try:
                importlib.import_module(mod_path)
            except Exception as e:
                errors.append(f"{mod_path}: {type(e).__name__}: {e}")

        assert not errors, "MODULE IMPORT FAILURES — The bot would crash on startup!\n" + "\n".join(
            f"  ❌ {e}" for e in errors
        )
