#
# BSD 3-Clause License
#
# Copyright (c) 2026, Martin Vesterlund
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

import numpy as np
from decimal import Decimal
import numpy as np
import math


class MonteCarloRange:
    def __init__(
        self,
        min: Decimal = Decimal(0.00),
        probable: Decimal = Decimal(0.00),
        max: Decimal = Decimal(0.00),
    ):
        if not (max == min) and ((probable < min) or (probable > max) or (min > max)):
            raise ValueError
        self.max = Decimal(max)
        self.min = Decimal(min)
        self.probable = Decimal(probable)
        if not self.probable.is_zero() and (self.max == self.min):
            self.max = Decimal(self.probable * 2)
            self.min = Decimal(self.probable / 2)
        elif self.probable.is_zero and not self.max.is_zero():
            self.probable = (self.min + self.max) / 2

    def to_dict(self):
        return {
            "min": float(self.min),
            "probable": float(self.probable),
            "max": float(self.max),
        }

    def add(self, other=None):
        if isinstance(other, MonteCarloRange):
            max = self.max + other.max
            min = self.min + other.min
            probable = self.probable + other.probable
        else:
            max = self.max + other
            min = self.min + other
            probable = self.probable + other
        result = MonteCarloRange(
            min=Decimal(min), probable=Decimal(probable), max=Decimal(max)
        )
        return result

    def sub(self, other=None):
        if isinstance(other, MonteCarloRange):
            max = self.max - other.max
            min = self.min - other.min
            probable = self.probable - other.probable
        else:
            max = self.max - other
            min = self.min - other
            probable = self.probable - other
        if max < min:
            tmp = max
            max = min
            min = tmp
        result = MonteCarloRange(
            min=Decimal(min), probable=Decimal(probable), max=Decimal(max)
        )
        return result

    def multiply(self, other=None):
        if isinstance(other, MonteCarloRange) or isinstance(
            other, MonteCarloSimulation
        ):
            max = self.max * other.max
            min = self.min * other.min
            probable = self.probable * other.probable
        else:
            max = Decimal(self.max) * Decimal(other)
            min = Decimal(self.min) * Decimal(other)
            probable = Decimal(self.probable) * Decimal(other)
        if min < 1e-20:
            min = Decimal(0.0)
        if probable < 1e-20:
            probable = Decimal(0.0)
        if max < 1e-20:
            max = Decimal(0.0)
        result = MonteCarloRange(
            min=Decimal(min), probable=Decimal(probable), max=Decimal(max)
        )
        return result

    @classmethod
    def from_dict(cls, values: dict = None):
        if isinstance(values, dict):
            return MonteCarloRange(
                min=values["min"], probable=values["probable"], max=values["max"]
            )
        elif isinstance(values, MonteCarloRange):
            return values
        else:
            raise TypeError

    def __repr__(self):
        return str(self.to_dict())

    def __hash__(self):
        return hash((self.min, self.max, self.probable))

    def __eq__(self, other):
        if isinstance(other, MonteCarloRange) and self.__hash__() == other.__hash__():
            return True
        return False

    def __gt__(self, other):
        if self == other:
            return False
        if self.min >= other.min and self.max > other.max:
            return True
        return False

class PertDistribution:
    def __init__(self, range: MonteCarloRange, samples: int = 100000):
        rng = np.random.default_rng()
        delta_min_max = range.max - range.min
        alpha = 1 + ((range.probable - range.min) * 4) / delta_min_max
        beta = 1 + ((range.max - range.probable) * 4) / delta_min_max
        self.__samples = float(range.min) + rng.beta(alpha, beta, samples) * float(
            delta_min_max
        )

    def get(self):
        return self.__samples.copy()


class LogLogisticDistribution:
    def __init__(
        self,
        range: MonteCarloRange,
        samples: int = 100000,
        upper_quantile: float = 0.99,
    ):
        rng = np.random.default_rng()

        min_value = float(range.min)
        probable_value = float(range.probable)
        max_value = float(range.max)

        eps = np.finfo(float).eps

        if max_value <= min_value:
            self.__samples = np.full(samples, min_value, dtype=float)
            return

        # Shifted log-logistic:
        # X = min_value + Y
        # Y ~ LogLogistic(alpha, beta)
        #
        # Median(X) = min_value + alpha
        alpha = probable_value - min_value
        if alpha <= eps:
            alpha = max((max_value - min_value) * 0.1, eps)

        span_ratio = (max_value - min_value) / alpha

        if span_ratio <= 1.0 + eps:
            beta = 10.0
        else:
            # Q(u) = min_value + alpha * (u / (1-u)) ** (1/beta)
            # Solve Q(upper_quantile) = max_value for beta
            logit_q = math.log(upper_quantile / (1.0 - upper_quantile))
            beta = logit_q / math.log(span_ratio)
            if not np.isfinite(beta) or beta <= 0:
                beta = 10.0

        # Truncate distribution to [min_value, max_value]
        # CDF(max_value) for shifted log-logistic
        f_max = 1.0 / (1.0 + (alpha / (max_value - min_value)) ** beta)

        # Sample uniformly in truncated CDF interval
        u = rng.uniform(low=eps, high=max(f_max - eps, eps), size=samples)

        # Inverse CDF
        self.__samples = min_value + alpha * (u / (1.0 - u)) ** (1.0 / beta)

    def get(self):
        return self.__samples.copy()

class MonteCarloSimulation:
    def __init__(self, range: MonteCarloRange = None):
        if not range:
            range = MonteCarloRange(probable=Decimal(1))
        if range.min == range.max == range.probable:
            self.probable = Decimal(range.probable)
            range.max = Decimal(Decimal(range.probable) + Decimal(0.000000000001))
            range.min = Decimal(Decimal(range.probable) - Decimal(0.000000000001))

        pd = LogLogisticDistribution(range=range)
        self.__samples = pd.get()
        self.probable = Decimal(np.mean(self.__samples))
        self.p90 = Decimal(np.percentile(self.__samples, 90))
        self.p75 = Decimal(np.percentile(self.__samples, 75))
        self.max = Decimal(np.max(self.__samples))
        self.min = Decimal(np.min(self.__samples))

    def to_dict(self):
        return {
            "min": float(self.min),
            "probable": float(self.probable),
            "max": float(self.max),
            "p90": float(self.p90),
            "p75": float(self.p75),
            "__samples": self.__samples,
        }

    @classmethod
    def from_dict(cls, values: dict = {}):
        if isinstance(values, dict) and "p90" in values:
            mcs = MonteCarloSimulation()
            mcs.min = Decimal(values["min"])
            mcs.probable = Decimal(values["probable"])
            mcs.max = Decimal(values["max"])
            mcs.p90 = Decimal(values["p90"])
            mcs.p75 = Decimal(values["p75"])
            mcs.__samples = np.array(values.get("__samples", []))
            return mcs
        else:
            range = MonteCarloRange.from_dict(values)
            return MonteCarloSimulation(range=range)

    def get_montecarlo_range(self):
        return MonteCarloRange(min=self.min, probable=self.probable, max=self.max)

    def multiply(self, other):
        if isinstance(other, MonteCarloSimulation):
            range = self.get_montecarlo_range().multiply(
                other=other.get_montecarlo_range()
            )
        else:
            range = self.get_montecarlo_range().multiply(other=other)
        return MonteCarloSimulation(range=range)

    def add(self, other):
        if isinstance(other, MonteCarloSimulation):
            range = self.get_montecarlo_range().add(other=other.get_montecarlo_range())
        else:
            range = self.get_montecarlo_range().add(other=other)
        return MonteCarloSimulation(range=range)

    def delta(self, other):
        if isinstance(other, MonteCarloSimulation):
            range = self.get_montecarlo_range().sub(other=other.get_montecarlo_range())
        else:
            range = self.get_montecarlo_range().sub(other=other)
        return MonteCarloSimulation(range=range)

    def __add__(self, other):
        return self.add(other)

    def __radd__(self, other):
        return self.add(other)

    def __mul__(self, other):
        return self.multiply(other)

    def __rmul__(self, other):
        return self.multiply(other)

    def __repr__(self):
        return str(self.to_dict())

    def __str__(self):
        return f"min: {str(self.min)} mode: {str(self.probable)} p75: {str(self.p75)} p90: {str(self.p90)} max: {str(self.max)}"

    def __hash__(self):
        return hash((self.min, self.probable, self.p75, self.p90, self.max))

    def __eq__(self, value):
        if (
            isinstance(value, MonteCarloSimulation)
            and self.__hash__() == value.__hash__()
        ):
            return True
        return False

    def __gt__(self, other):
        if self == other:
            return False
        if self.min >= other.min and self.max > other.max:
            return True
        return False
