"""Chaos mode for flakiness detection.

Intentionally introduces non-determinism to surface flaky tests faster.
Based on Mozilla rr's chaos mode concept.

Types of chaos:
- Timing chaos: Random delays to surface race conditions
- Ordering chaos: Randomize iteration order
- GC chaos: Trigger garbage collection at random times
- Network chaos: Inject latency/failures (via Toxiproxy)

Usage:
    from autodebug.chaos import ChaosInjector, ChaosConfig

    config = ChaosConfig(
        timing_enabled=True,
        max_jitter_ms=100,
    )

    with ChaosInjector(config):
        # Test runs with chaos enabled
        run_tests()
"""

from __future__ import annotations

import gc
import logging
import os
import random
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional

logger = logging.getLogger(__name__)


@dataclass
class SteadyStateHypothesis:
    """Defines a steady-state hypothesis for chaos experiments (H-143).

    Before injecting chaos, measure baseline metrics. After chaos,
    compare against baseline to verify system resilience.

    Attributes:
        metric_name: Human-readable name of the metric being measured
        baseline_value: Measured baseline value before chaos
        tolerance_pct: Allowable deviation from baseline as a percentage (0-100)
        post_chaos_value: Measured value after chaos (set during validation)
        passed: Whether the steady-state hypothesis held
    """

    metric_name: str = "test_pass_rate"
    baseline_value: float = 0.0
    tolerance_pct: float = 10.0
    post_chaos_value: float = 0.0
    passed: bool = False

    def validate(self, current_value: float) -> bool:
        """Validate whether the current value is within tolerance of baseline.

        Args:
            current_value: Current metric value to compare against baseline

        Returns:
            True if within tolerance
        """
        self.post_chaos_value = current_value
        if self.baseline_value == 0:
            self.passed = current_value == 0
        else:
            deviation = abs(current_value - self.baseline_value) / abs(self.baseline_value) * 100
            self.passed = deviation <= self.tolerance_pct
        return self.passed


@dataclass
class ExperimentResult:
    """Structured result of a chaos experiment (P8-F10).

    Records what was injected, when it ran, and the outcome so that
    chaos experiments produce auditable records.

    Attributes:
        config_snapshot: Serializable copy of the ChaosConfig used
        started_at: ISO-8601 timestamp when chaos was enabled
        ended_at: ISO-8601 timestamp when chaos was disabled
        tests_total: Number of tests run during the experiment
        tests_failed: Number of tests that failed during chaos
        steady_state_held: Whether the steady-state hypothesis held (None if not set)
        verdict: "pass", "fail", "abort", or "unknown"
        notes: Free-form notes / failure details
    """

    config_snapshot: Dict = field(default_factory=dict)
    started_at: str = ""
    ended_at: str = ""
    tests_total: int = 0
    tests_failed: int = 0
    steady_state_held: Optional[bool] = None
    verdict: str = "unknown"
    notes: str = ""

    def to_dict(self) -> Dict:
        return {
            "config": self.config_snapshot,
            "started_at": self.started_at,
            "ended_at": self.ended_at,
            "tests_total": self.tests_total,
            "tests_failed": self.tests_failed,
            "steady_state_held": self.steady_state_held,
            "verdict": self.verdict,
            "notes": self.notes,
        }


@dataclass
class ChaosConfig:
    """Configuration for chaos injection."""

    # Timing chaos
    timing_enabled: bool = False
    max_jitter_ms: float = 100.0
    sleep_jitter_probability: float = 0.5  # Probability of adding jitter to sleep

    # GC chaos
    gc_enabled: bool = False
    gc_probability: float = 0.1  # Probability of triggering GC

    # Ordering chaos (experimental)
    ordering_enabled: bool = False

    # Random seed for reproducibility
    seed: Optional[int] = None

    # Network chaos (requires Toxiproxy)
    network_enabled: bool = False
    network_latency_ms: int = 50
    network_jitter_ms: int = 20
    network_failure_rate: float = 0.0

    # Resource chaos (P8-F7): CPU / memory / file-descriptor stress
    resource_chaos_enabled: bool = False
    cpu_stress_threads: int = 1           # Number of CPU-burn threads
    cpu_stress_duration_ms: int = 50      # Burst duration per trigger
    memory_pressure_mb: int = 0           # MB to allocate (0 = disabled)
    fd_limit: int = 0                     # If >0, lower soft FD limit to this

    # Blast radius controls (H-145): restrict chaos to specific tests
    target_tests: List[str] = field(default_factory=list)

    # Automatic abort threshold (H-144): disable chaos if failure rate
    # exceeds this value (0.0-1.0). 0.0 = never abort.
    abort_failure_rate_threshold: float = 0.0

    # Environment safety guard (P8-F9): refuse to enable in production
    # unless this is explicitly set to True.
    allow_in_production: bool = False

    @classmethod
    def aggressive(cls) -> ChaosConfig:
        """Aggressive chaos for finding hard-to-reproduce flakes."""
        return cls(
            timing_enabled=True,
            max_jitter_ms=200.0,
            sleep_jitter_probability=0.8,
            gc_enabled=True,
            gc_probability=0.2,
            ordering_enabled=True,
        )

    @classmethod
    def gentle(cls) -> ChaosConfig:
        """Gentle chaos for CI use."""
        return cls(
            timing_enabled=True,
            max_jitter_ms=50.0,
            sleep_jitter_probability=0.3,
            gc_enabled=True,
            gc_probability=0.05,
        )


class ChaosInjector:
    """Injects chaos to surface flaky tests."""

    def __init__(self, config: Optional[ChaosConfig] = None):
        """Initialize chaos injector.

        Args:
            config: Chaos configuration. Uses gentle defaults if None.
        """
        self.config = config or ChaosConfig.gentle()
        self._original_sleep: Optional[Callable] = None
        self._random = random.Random(self.config.seed)
        self._enabled = False
        self._gc_callback: Optional[Callable] = None
        self._gc_in_callback: bool = False  # P8-F14 re-entrancy guard
        self._network_controller: Optional[object] = None
        self._network_context: Optional[object] = None
        self._steady_state: Optional[SteadyStateHypothesis] = None
        # H-144: Track test outcomes for automatic abort
        self._test_total: int = 0
        self._test_failures: int = 0
        self._aborted: bool = False
        # P8-F7: Resource chaos state
        self._resource_threads: List[threading.Thread] = []
        self._resource_stop_event: Optional[threading.Event] = None
        self._memory_ballast: Optional[bytearray] = None
        self._original_fd_limit: Optional[int] = None
        # P8-F10: Experiment result recording
        self._experiment_start: Optional[float] = None
        self._experiment_result: Optional[ExperimentResult] = None

    def enable(self) -> None:
        """Enable chaos injection.

        Includes environment safety check (P8-F9): if the current environment
        looks like production (RAILWAY_ENVIRONMENT=production, NODE_ENV=production,
        or HIKAFLOW_ENV=production) and ``allow_in_production`` is False, chaos
        is refused.
        """
        if self._enabled:
            return

        # P8-F9: Environment safety guard
        env_markers = [
            os.environ.get("RAILWAY_ENVIRONMENT", ""),
            os.environ.get("NODE_ENV", ""),
            os.environ.get("HIKAFLOW_ENV", ""),
        ]
        if any(e.lower() == "production" for e in env_markers):
            if not self.config.allow_in_production:
                logger.warning(
                    "Chaos injection REFUSED: production environment detected. "
                    "Set allow_in_production=True to override."
                )
                return

        self._enabled = True
        # P8-F10: Record experiment start
        self._experiment_start = time.time()
        logger.info(
            "Chaos enabled: %s",
            {k: v for k, v in {
                "timing": self.config.timing_enabled,
                "gc": self.config.gc_enabled,
                "network": self.config.network_enabled,
                "resource": self.config.resource_chaos_enabled,
            }.items() if v},
        )

        try:
            if self.config.timing_enabled:
                self._install_timing_chaos()

            if self.config.gc_enabled:
                self._install_gc_chaos()

            if self.config.network_enabled:
                self._install_network_chaos()

            if self.config.resource_chaos_enabled:
                self._install_resource_chaos()
        except Exception:
            # If any installation step fails after partially applying,
            # roll back everything to avoid leaking monkeypatches.
            self.disable()
            raise

    def disable(self) -> None:
        """Disable chaos injection and restore all patched state.

        Includes rollback verification (H-146) to confirm that all
        chaos effects were actually removed, and records an experiment
        result (P8-F10).
        """
        if not self._enabled:
            return

        self._enabled = False
        verification_errors: List[str] = []

        # Restore time.sleep unconditionally in a finally-safe manner
        if self._original_sleep is not None:
            time.sleep = self._original_sleep
            self._original_sleep = None

        if self._gc_callback is not None:
            try:
                gc.callbacks.remove(self._gc_callback)
            except ValueError:
                pass
            self._gc_callback = None

        if self._network_context is not None:
            try:
                self._network_context.__exit__(None, None, None)
            except Exception as e:
                logger.warning(f"Failed to clean up network chaos: {e}")
            self._network_context = None
            self._network_controller = None

        # P8-F7: Clean up resource chaos
        self._uninstall_resource_chaos()

        # H-146: Verify that all chaos effects were actually removed
        # Check 1: time.sleep should be the builtin, not our wrapper
        import builtins
        builtin_sleep = getattr(time, 'sleep', None)
        if builtin_sleep is not None and hasattr(builtin_sleep, '__wrapped__'):
            verification_errors.append("time.sleep still appears to be wrapped")

        # Check 2: Our GC callback should not be in gc.callbacks
        for cb in gc.callbacks:
            if cb is self._gc_callback:
                verification_errors.append("GC callback still present in gc.callbacks")
                break

        # Check 3: Network context should be cleaned up
        if self._network_context is not None:
            verification_errors.append("Network chaos context was not cleaned up")

        if verification_errors:
            logger.warning(
                f"Chaos rollback verification found issues: {verification_errors}"
            )
        else:
            logger.debug("Chaos rollback verification passed: all effects removed")

        # P8-F10: Record experiment result
        self._finalize_experiment_result(
            "abort" if self._aborted else "completed",
            verification_errors,
        )

    def __enter__(self) -> ChaosInjector:
        self.enable()
        return self

    def __exit__(self, *args) -> None:
        self.disable()

    def _install_timing_chaos(self) -> None:
        """Install timing chaos (jitter on sleep calls)."""
        self._original_sleep = time.sleep

        def chaotic_sleep(seconds: float) -> None:
            if self._random.random() < self.config.sleep_jitter_probability:
                jitter = self._random.uniform(0, self.config.max_jitter_ms / 1000)
                seconds = seconds + jitter
            self._original_sleep(seconds)

        time.sleep = chaotic_sleep

    def _install_gc_chaos(self) -> None:
        """Install GC chaos (random garbage collection)."""
        def gc_callback(phase: str, info: Dict) -> None:
            # P8-F14: Re-entrancy guard prevents recursive gc.collect() calls
            if self._gc_in_callback:
                return
            if phase == "start" and self._random.random() < self.config.gc_probability:
                self._gc_in_callback = True
                try:
                    gc.collect()
                finally:
                    self._gc_in_callback = False

        gc.callbacks.append(gc_callback)
        self._gc_callback = gc_callback

    def _install_network_chaos(self) -> None:
        """Install network chaos via Toxiproxy."""
        try:
            from autodebug.chaos.test_chaos import ChaosConfig as ToxiChaosConfig
            from autodebug.chaos.test_chaos import ChaosController, ChaosType

            controller = ChaosController()
            if controller.available:
                # Wire up user-provided network config parameters
                toxi_config = ToxiChaosConfig(
                    chaos_type=ChaosType.LATENCY,
                    latency_ms=self.config.network_latency_ms,
                    jitter_ms=self.config.network_jitter_ms,
                    probability=1.0 - self.config.network_failure_rate
                    if self.config.network_failure_rate > 0
                    else 1.0,
                )
                # Actually inject the chaos and hold onto the context for cleanup
                self._network_controller = controller
                self._network_context = controller.inject(toxi_config)
                self._network_context.__enter__()
                logger.info("Network chaos enabled via Toxiproxy")
            else:
                logger.warning("Network chaos requested but Toxiproxy not available")
        except ImportError:
            logger.warning("Network chaos requested but toxiproxy-python not installed")

    def _install_resource_chaos(self) -> None:
        """Install resource chaos: CPU stress, memory pressure, FD limits (P8-F7).

        All effects are reversed in ``_uninstall_resource_chaos()``.
        """
        import resource as _resource_mod

        self._resource_stop_event = threading.Event()

        # CPU stress: spin short bursts on background threads
        if self.config.cpu_stress_threads > 0 and self.config.cpu_stress_duration_ms > 0:
            burst_sec = self.config.cpu_stress_duration_ms / 1000.0

            def _cpu_burn(stop_event: threading.Event) -> None:
                while not stop_event.is_set():
                    end = time.time() + burst_sec
                    while time.time() < end:
                        pass  # spin
                    # Sleep between bursts so we don't fully peg the CPU
                    stop_event.wait(timeout=burst_sec * 2)

            for _ in range(self.config.cpu_stress_threads):
                t = threading.Thread(target=_cpu_burn, args=(self._resource_stop_event,), daemon=True)
                t.start()
                self._resource_threads.append(t)
            logger.info(f"Resource chaos: {self.config.cpu_stress_threads} CPU stress thread(s) started")

        # Memory pressure: allocate a ballast buffer
        if self.config.memory_pressure_mb > 0:
            try:
                self._memory_ballast = bytearray(self.config.memory_pressure_mb * 1024 * 1024)
                logger.info(f"Resource chaos: {self.config.memory_pressure_mb} MB memory pressure applied")
            except MemoryError:
                logger.warning("Resource chaos: could not allocate memory ballast")

        # File descriptor limit
        if self.config.fd_limit > 0:
            try:
                soft, hard = _resource_mod.getrlimit(_resource_mod.RLIMIT_NOFILE)
                self._original_fd_limit = soft
                new_soft = min(self.config.fd_limit, hard)
                _resource_mod.setrlimit(_resource_mod.RLIMIT_NOFILE, (new_soft, hard))
                logger.info(f"Resource chaos: FD soft limit lowered from {soft} to {new_soft}")
            except (ValueError, OSError) as e:
                logger.warning(f"Resource chaos: could not set FD limit: {e}")

    def _uninstall_resource_chaos(self) -> None:
        """Reverse all resource chaos effects (P8-F7)."""
        # Stop CPU stress threads
        if self._resource_stop_event is not None:
            self._resource_stop_event.set()
            for t in self._resource_threads:
                t.join(timeout=2.0)
            self._resource_threads.clear()
            self._resource_stop_event = None

        # Release memory ballast
        self._memory_ballast = None

        # Restore FD limit
        if self._original_fd_limit is not None:
            try:
                import resource as _resource_mod
                _, hard = _resource_mod.getrlimit(_resource_mod.RLIMIT_NOFILE)
                _resource_mod.setrlimit(_resource_mod.RLIMIT_NOFILE, (self._original_fd_limit, hard))
            except (ValueError, OSError) as e:
                logger.warning(f"Resource chaos: could not restore FD limit: {e}")
            self._original_fd_limit = None

    def _finalize_experiment_result(
        self, verdict: str, verification_errors: List[str]
    ) -> None:
        """Build and store the ExperimentResult (P8-F10)."""
        from datetime import datetime, timezone

        now_iso = datetime.now(timezone.utc).isoformat()
        start_iso = ""
        if self._experiment_start is not None:
            start_iso = datetime.fromtimestamp(
                self._experiment_start, tz=timezone.utc
            ).isoformat()

        ss_held = None
        if self._steady_state is not None:
            ss_held = self._steady_state.passed

        config_snapshot = {
            "timing_enabled": self.config.timing_enabled,
            "gc_enabled": self.config.gc_enabled,
            "network_enabled": self.config.network_enabled,
            "resource_chaos_enabled": self.config.resource_chaos_enabled,
            "abort_threshold": self.config.abort_failure_rate_threshold,
        }

        notes = ""
        if verification_errors:
            notes = f"Rollback issues: {verification_errors}"

        if self._test_total > 0:
            failure_rate = self._test_failures / self._test_total
            if self._aborted:
                final_verdict = "abort"
            elif failure_rate > 0:
                final_verdict = "fail"
            else:
                final_verdict = "pass"
        else:
            final_verdict = verdict

        self._experiment_result = ExperimentResult(
            config_snapshot=config_snapshot,
            started_at=start_iso,
            ended_at=now_iso,
            tests_total=self._test_total,
            tests_failed=self._test_failures,
            steady_state_held=ss_held,
            verdict=final_verdict,
            notes=notes,
        )
        logger.info(f"Experiment result: {final_verdict} ({self._test_total} tests, {self._test_failures} failures)")

    @property
    def experiment_result(self) -> Optional[ExperimentResult]:
        """Get the experiment result after disable() has been called (P8-F10)."""
        return self._experiment_result

    def set_steady_state(self, hypothesis: SteadyStateHypothesis) -> None:
        """Set a steady-state hypothesis for this chaos experiment (H-143).

        Call this before enabling chaos to record baseline metrics.

        Args:
            hypothesis: The steady-state hypothesis with baseline_value set
        """
        self._steady_state = hypothesis
        logger.info(
            f"Steady-state hypothesis set: {hypothesis.metric_name}="
            f"{hypothesis.baseline_value} (tolerance={hypothesis.tolerance_pct}%)"
        )

    def validate_steady_state(self, current_value: float) -> bool:
        """Validate the steady-state hypothesis after chaos (H-143).

        Args:
            current_value: Current metric value to compare against baseline

        Returns:
            True if hypothesis holds (system is resilient)
        """
        if self._steady_state is None:
            logger.warning("No steady-state hypothesis set; skipping validation")
            return True

        passed = self._steady_state.validate(current_value)
        if passed:
            logger.info(
                f"Steady-state hypothesis HELD: {self._steady_state.metric_name}="
                f"{current_value} (baseline={self._steady_state.baseline_value})"
            )
        else:
            logger.warning(
                f"Steady-state hypothesis VIOLATED: {self._steady_state.metric_name}="
                f"{current_value} (baseline={self._steady_state.baseline_value}, "
                f"tolerance={self._steady_state.tolerance_pct}%)"
            )
        return passed

    def record_test_outcome(self, passed: bool) -> None:
        """Record a test outcome for automatic abort tracking (H-144).

        If the failure rate exceeds the configured threshold, chaos is
        automatically disabled to prevent excessive damage.

        Args:
            passed: Whether the test passed
        """
        self._test_total += 1
        if not passed:
            self._test_failures += 1

        if (
            self.config.abort_failure_rate_threshold > 0
            and self._test_total > 0
            and not self._aborted
        ):
            failure_rate = self._test_failures / self._test_total
            if failure_rate > self.config.abort_failure_rate_threshold:
                logger.warning(
                    f"Chaos auto-abort: failure rate {failure_rate:.1%} exceeds "
                    f"threshold {self.config.abort_failure_rate_threshold:.1%}. "
                    f"Disabling chaos."
                )
                self._aborted = True
                self.disable()

    def should_apply_to_test(self, test_name: str) -> bool:
        """Check whether chaos should apply to a given test (H-145).

        When target_tests is configured, chaos effects are scoped to only
        the specified test modules/functions (blast radius control).

        Args:
            test_name: Fully qualified test name or module path

        Returns:
            True if chaos should be active for this test
        """
        if not self.config.target_tests:
            # No blast radius restriction -- apply globally
            return True
        return any(target in test_name for target in self.config.target_tests)

    def add_random_jitter(self, base_ms: float = 0) -> float:
        """Add random jitter. Returns total delay in ms."""
        if not self.config.timing_enabled:
            return base_ms
        jitter = self._random.uniform(0, self.config.max_jitter_ms)
        return base_ms + jitter

    def should_fail(self, probability: Optional[float] = None) -> bool:
        """Check if an operation should fail (for testing error paths)."""
        p = probability or self.config.network_failure_rate
        return self._random.random() < p

    def maybe_gc(self) -> None:
        """Maybe trigger garbage collection."""
        if self.config.gc_enabled and self._random.random() < self.config.gc_probability:
            gc.collect()


# Global instance for convenience -- guarded by a lock (P8-F13)
_chaos: Optional[ChaosInjector] = None
_chaos_lock = threading.Lock()


def get_chaos() -> ChaosInjector:
    """Get or create global chaos injector.

    Thread-safe via double-checked locking (P8-F13).
    """
    global _chaos
    if _chaos is None:
        with _chaos_lock:
            if _chaos is None:
                if os.environ.get("HIKAFLOW_CHAOS") == "1":
                    _chaos = ChaosInjector(ChaosConfig.gentle())
                    _chaos.enable()
                elif os.environ.get("HIKAFLOW_CHAOS") == "aggressive":
                    _chaos = ChaosInjector(ChaosConfig.aggressive())
                    _chaos.enable()
                else:
                    _chaos = ChaosInjector(ChaosConfig())
    return _chaos


@contextmanager
def chaos_mode(config: Optional[ChaosConfig] = None):
    """Context manager for chaos mode.

    Usage:
        with chaos_mode(ChaosConfig.aggressive()):
            run_flaky_test()
    """
    injector = ChaosInjector(config or ChaosConfig.gentle())
    injector.enable()
    try:
        yield injector
    finally:
        injector.disable()
