import json
import logging
import math
import statistics
import time
from pathlib import Path
from collections import Counter


class CrashAnalysis:
    """
    Statistical analysis and range-prediction engine.

    This module does not attempt to predict an exact crash
    multiplier. It estimates a probable range using historical
    observations and records predictions so they can later be
    compared against the actual result.

    All predictions are evaluated out-of-sample whenever enough
    historical data exists.
    """

    def __init__(self, config_path="config.json"):
        self.logger = logging.getLogger("CrashAnalysis")

        with open(config_path, "r", encoding="utf-8") as file:
            self.config = json.load(file)

        self.data_dir = Path(
            self.config["capture"]["data_directory"]
        )
        self.data_dir.mkdir(parents=True, exist_ok=True)

        analysis_config = self.config.get("analysis", {})

        self.confidence_threshold = float(
            analysis_config.get("confidence_threshold", 0.80)
        )

        self.minimum_samples = int(
            analysis_config.get("minimum_samples", 100)
        )

        self.prediction_file = (
            self.data_dir / "predictions.json"
        )

        self.predictions = self._load_predictions()

    # ---------------------------------------------------------
    # Persistence
    # ---------------------------------------------------------

    def _load_predictions(self):
        if not self.prediction_file.exists():
            return []

        try:
            with open(
                self.prediction_file,
                "r",
                encoding="utf-8"
            ) as file:
                data = json.load(file)

            if isinstance(data, list):
                return data

        except (OSError, json.JSONDecodeError):
            pass

        return []

    def _save_predictions(self):
        with open(
            self.prediction_file,
            "w",
            encoding="utf-8"
        ) as file:
            json.dump(
                self.predictions,
                file,
                indent=2,
                ensure_ascii=False
            )

    # ---------------------------------------------------------
    # Basic preparation
    # ---------------------------------------------------------

    def _clean_values(self, history):
        values = []

        for item in history:
            try:
                if isinstance(item, dict):
                    value = float(item.get("multiplier"))
                else:
                    value = float(item)

                if math.isfinite(value) and value > 0:
                    values.append(value)

            except (TypeError, ValueError):
                continue

        return values

    # ---------------------------------------------------------
    # Distribution statistics
    # ---------------------------------------------------------

    def statistics(self, history):
        values = self._clean_values(history)

        if not values:
            return {
                "samples": 0
            }

        values_sorted = sorted(values)

        return {
            "samples": len(values),
            "minimum": values_sorted[0],
            "maximum": values_sorted[-1],
            "mean": statistics.mean(values),
            "median": statistics.median(values),
            "stdev": (
                statistics.stdev(values)
                if len(values) >= 2
                else 0.0
            ),
            "q10": self._percentile(values_sorted, 10),
            "q25": self._percentile(values_sorted, 25),
            "q50": self._percentile(values_sorted, 50),
            "q75": self._percentile(values_sorted, 75),
            "q90": self._percentile(values_sorted, 90),
            "q95": self._percentile(values_sorted, 95)
        }

    def _percentile(self, values, percentile):
        if not values:
            return None

        if len(values) == 1:
            return values[0]

        position = (
            (len(values) - 1)
            * percentile
            / 100
        )

        lower = math.floor(position)
        upper = math.ceil(position)

        if lower == upper:
            return values[int(position)]

        weight = position - lower

        return (
            values[lower]
            + (values[upper] - values[lower])
            * weight
        )

    # ---------------------------------------------------------
    # Rolling statistics
    # ---------------------------------------------------------

    def rolling_statistics(self, history, window=50):
        values = self._clean_values(history)

        if len(values) < window:
            return None

        sample = values[-window:]

        return {
            "window": window,
            "samples": len(sample),
            "mean": statistics.mean(sample),
            "median": statistics.median(sample),
            "stdev": (
                statistics.stdev(sample)
                if len(sample) >= 2
                else 0.0
            ),
            "q25": self._percentile(
                sorted(sample),
                25
            ),
            "q75": self._percentile(
                sorted(sample),
                75
            ),
            "q90": self._percentile(
                sorted(sample),
                90
            )
        }

    # ---------------------------------------------------------
    # Sequence analysis
    # ---------------------------------------------------------

    def sequence_analysis(
        self,
        history,
        low_threshold=2.0,
        high_threshold=10.0
    ):
        """
        Converts results into broad multiplier states for
        sequence analysis.

        This is NOT a prediction by itself.

        States:
            LOW
            MID
            HIGH
        """

        values = self._clean_values(history)

        states = []

        for value in values:
            if value < low_threshold:
                states.append("LOW")
            elif value < high_threshold:
                states.append("MID")
            else:
                states.append("HIGH")

        transitions = Counter()

        for previous, current in zip(
            states,
            states[1:]
        ):
            transitions[
                f"{previous}->{current}"
            ] += 1

        transition_probabilities = {}

        for state in ("LOW", "MID", "HIGH"):
            outgoing = [
                count
                for key, count in transitions.items()
                if key.startswith(state + "->")
            ]

            total = sum(outgoing)

            if total == 0:
                continue

            transition_probabilities[state] = {}

            for next_state in (
                "LOW",
                "MID",
                "HIGH"
            ):
                key = f"{state}->{next_state}"

                transition_probabilities[state][
                    next_state
                ] = (
                    transitions.get(key, 0)
                    / total
                )

        return {
            "samples": len(values),
            "state_counts": dict(Counter(states)),
            "transitions": dict(transitions),
            "transition_probabilities":
                transition_probabilities
        }

    # ---------------------------------------------------------
    # Run analysis
    # ---------------------------------------------------------

    def run_analysis(self, history):
        values = self._clean_values(history)

        if not values:
            return {
                "samples": 0,
                "runs": []
            }

        states = []

        for value in values:
            if value < 2.0:
                states.append("LOW")
            elif value < 10.0:
                states.append("MID")
            else:
                states.append("HIGH")

        runs = []

        current_state = states[0]
        current_length = 1

        for state in states[1:]:
            if state == current_state:
                current_length += 1
            else:
                runs.append({
                    "state": current_state,
                    "length": current_length
                })

                current_state = state
                current_length = 1

        runs.append({
            "state": current_state,
            "length": current_length
        })

        return {
            "samples": len(values),
            "runs": runs,
            "current_state": current_state,
            "current_run_length": current_length,
            "longest_run": max(
                run["length"]
                for run in runs
            )
        }

    # ---------------------------------------------------------
    # Candidate range models
    # ---------------------------------------------------------

    def _quantile_range(
        self,
        values,
        lower=10,
        upper=90
    ):
        ordered = sorted(values)

        return (
            self._percentile(ordered, lower),
            self._percentile(ordered, upper)
        )

    def _rolling_range(
        self,
        values,
        window=100,
        lower=10,
        upper=90
    ):
        if len(values) < window:
            return None

        sample = values[-window:]

        return self._quantile_range(
            sample,
            lower,
            upper
        )

    def _recent_range(
        self,
        values,
        window=50
    ):
        if len(values) < window:
            return None

        sample = values[-window:]

        ordered = sorted(sample)

        return (
            self._percentile(ordered, 15),
            self._percentile(ordered, 85)
        )

    # ---------------------------------------------------------
    # Range prediction
    # ---------------------------------------------------------

    def predict_range(self, history):
        """
        Produces a range for the next observation.

        The method combines several descriptive estimates:

        1. Full historical distribution
        2. Rolling distribution
        3. Recent distribution

        It does not claim that the next result must fall inside
        the range.
        """

        values = self._clean_values(history)

        if len(values) < self.minimum_samples:
            return {
                "ready": False,
                "reason": (
                    "Insufficient historical samples"
                ),
                "samples": len(values),
                "required_samples":
                    self.minimum_samples
            }

        full_range = self._quantile_range(
            values,
            10,
            90
        )

        rolling_range = self._rolling_range(
            values,
            min(100, len(values)),
            10,
            90
        )

        recent_range = self._recent_range(
            values,
            min(50, len(values))
        )

        candidates = [
            full_range,
            rolling_range,
            recent_range
        ]

        candidates = [
            item
            for item in candidates
            if item is not None
        ]

        lower_values = [
            item[0]
            for item in candidates
        ]

        upper_values = [
            item[1]
            for item in candidates
        ]

        lower = statistics.median(
            lower_values
        )

        upper = statistics.median(
            upper_values
        )

        if lower > upper:
            lower, upper = upper, lower

        diagnostics = {
            "full_range": full_range,
            "rolling_range": rolling_range,
            "recent_range": recent_range
        }

        prediction = {
            "ready": True,
            "created_at": time.time(),
            "samples": len(values),
            "lower_bound": round(lower, 4),
            "upper_bound": round(upper, 4),
            "confidence_target":
                self.confidence_threshold,
            "diagnostics": diagnostics,
            "method": "ensemble_quantile_range"
        }

        return prediction

    # ---------------------------------------------------------
    # Save prediction before next round
    # ---------------------------------------------------------

    def create_prediction(self, history):
        prediction = self.predict_range(history)

        if not prediction.get("ready"):
            return prediction

        record = {
            "prediction_id": (
                len(self.predictions) + 1
            ),
            **prediction,
            "actual": None,
            "evaluated": False,
            "correct": None
        }

        self.predictions.append(record)

        if len(self.predictions) > 10000:
            self.predictions = (
                self.predictions[-10000:]
            )

        self._save_predictions()

        return record

    # ---------------------------------------------------------
    # Evaluate prediction against actual result
    # ---------------------------------------------------------

    def evaluate_latest(self, actual_multiplier):
        try:
            actual = float(actual_multiplier)
        except (TypeError, ValueError):
            return None

        if not math.isfinite(actual):
            return None

        for prediction in reversed(
            self.predictions
        ):
            if prediction.get("evaluated"):
                continue

            lower = prediction.get(
                "lower_bound"
            )
            upper = prediction.get(
                "upper_bound"
            )

            if lower is None or upper is None:
                continue

            correct = (
                lower <= actual <= upper
            )

            prediction["actual"] = actual
            prediction["evaluated"] = True
            prediction["correct"] = correct
            prediction["evaluated_at"] = time.time()

            self._save_predictions()

            return prediction

        return None

    # ---------------------------------------------------------
    # Backtest
    # ---------------------------------------------------------

    def backtest(
        self,
        history,
        minimum_training_samples=None
    ):
        """
        Walk-forward evaluation.

        For each historical result after the training period,
        the model creates a prediction using only results that
        occurred before that result.

        This prevents future results from leaking into the
        prediction.
        """

        values = self._clean_values(history)

        minimum_training_samples = (
            minimum_training_samples
            or self.minimum_samples
        )

        if len(values) <= minimum_training_samples:
            return {
                "ready": False,
                "samples": len(values),
                "required_samples":
                    minimum_training_samples + 1
            }

        results = []

        for index in range(
            minimum_training_samples,
            len(values)
        ):
            training = values[:index]
            actual = values[index]

            prediction = self.predict_range(
                training
            )

            if not prediction.get("ready"):
                continue

            lower = prediction["lower_bound"]
            upper = prediction["upper_bound"]

            correct = (
                lower <= actual <= upper
            )

            results.append({
                "index": index,
                "lower_bound": lower,
                "upper_bound": upper,
                "actual": actual,
                "correct": correct
            })

        total = len(results)

        correct_count = sum(
            1
            for item in results
            if item["correct"]
        )

        coverage = (
            correct_count / total
            if total
            else 0.0
        )

        return {
            "ready": True,
            "training_samples":
                minimum_training_samples,
            "tested_predictions": total,
            "correct_predictions":
                correct_count,
            "coverage": coverage,
            "coverage_percent":
                round(coverage * 100, 2),
            "results": results
        }

    # ---------------------------------------------------------
    # Prediction performance
    # ---------------------------------------------------------

    def performance(self):
        evaluated = [
            item
            for item in self.predictions
            if item.get("evaluated")
        ]

        if not evaluated:
            return {
                "evaluated": 0,
                "correct": 0,
                "coverage": None
            }

        correct = sum(
            1
            for item in evaluated
            if item.get("correct") is True
        )

        total = len(evaluated)

        return {
            "evaluated": total,
            "correct": correct,
            "incorrect": total - correct,
            "coverage": correct / total,
            "coverage_percent":
                round(
                    (correct / total) * 100,
                    2
                )
        }

    # ---------------------------------------------------------
    # Complete analysis snapshot
    # ---------------------------------------------------------

    def analyze(self, history):
        values = self._clean_values(history)

        return {
            "statistics": self.statistics(
                values
            ),
            "rolling": {
                "50": self.rolling_statistics(
                    values,
                    50
                ),
                "100": self.rolling_statistics(
                    values,
                    100
                ),
                "250": self.rolling_statistics(
                    values,
                    250
                )
            },
            "sequence": self.sequence_analysis(
                values
            ),
            "runs": self.run_analysis(
                values
            ),
            "performance":
                self.performance()
        }