class PuLPEngine(Engine):
    """
    Concrete engine implementation using PuLP.
    This class provides a PuLP-based implementation of the abstract Engine interface for formulating
    and solving linear and integer optimization models.
    """
    class _Variable(Variable):
        """
        Represents a PuLP variable in an optimization model.
        The `PuLPVariable` class is a concrete implementation of the abstract `Variable` class.
        It represents a variable that is compatible with the PuLP solver.
        """
        # Strict class attributes.
        __slots__ = ["_pulp_var"]
        @property
        def name(self) -> str:
            return str(self._pulp_var.name)
        @property
        def lower_bound(self) -> float:
            lb = self._pulp_var.lowBound
            return float(lb) if lb is not None else -inf
        @property
        def upper_bound(self) -> float:
            ub = self._pulp_var.upBound
            return float(ub) if ub is not None else inf
        @property
        def value(self) -> float:
            val = self._pulp_var.value()
            return float(val) if val else -0.0
        @property
        def raw(self) -> Any:
            return self._pulp_var
        def __init__(
            self,
            name: str,
            solver: LpProblem,
            value_type: ValueType,
            lower_bound: float = 0,
            upper_bound: float = inf,
        ):
            """
            Initializes a new `PuLPVariable` object with the specified attributes and creates a corresponding PuLP
            variable in the PuLP solver.
            :param name: The name of the variable.
            :param solver: A reference to the PuLP solver.
            :param value_type: An enumeration representing the type of the variable's value.
            :param lower_bound: The lower bound of the variable. Default is 0.
            :param upper_bound: The upper bound of the variable. Default is infinity.
            """
            # Calls the super init method and its validations
            super().__init__(name=name, value_type=value_type, lower_bound=lower_bound, upper_bound=upper_bound)
            # Applies new validations
            if solver is None:
                raise PuLPException("The 'solver' argument cannot be None.")
            # Creates the PuLP variable according to the value type
            pulp_var: LpVariable | None
            if self.value_type == ValueType.BINARY:
                pulp_var = LpVariable(name=name, cat=LpBinary, lowBound=0, upBound=1)
            elif self.value_type == ValueType.INTEGER:
                pulp_var = LpVariable(
                    name=name,
                    cat=LpInteger,
                    lowBound=lower_bound if lower_bound > -inf else None,
                    upBound=upper_bound if upper_bound < inf else None,
                )
            elif self.value_type == ValueType.CONTINUOUS:
                pulp_var = LpVariable(
                    name=name,
                    cat=LpContinuous,
                    lowBound=lower_bound if lower_bound > -inf else None,
                    upBound=upper_bound if upper_bound < inf else None,
                )
            else:
                raise PuLPException("Unknown ValueType.")
            # Applies new validations
            if pulp_var is None:  # pragma: no cover
                raise PuLPException("Failed to create the PuLP variable.")
            # Instance attributes
            self._pulp_var: LpVariable = pulp_var
            """ A LpVariable object representing the variable in the PuLP solver. """
    @property
    def name(self) -> str:  # pragma: no cover
        return "PuLP Engine"
    @property
    def constraints(self) -> List[Element]:
        return [Expression(expression=constraint) for constraint in self._solver.constraints.values()]
    @property
    def objective_value(self) -> float | None:
        if self.solution_status in [SolutionStatus.OPTIMAL, SolutionStatus.FEASIBLE]:
            return float(value(self._solver.objective))
        return None
    @property
    def objective_expr(self) -> Element | None:
        return Expression(expression=self._objective) if self._objective is not None else None
    @property
    def solution_status(self) -> SolutionStatus:  # pragma: no cover
        if self._status == 0:
            return SolutionStatus.NOT_SOLVED
        elif self._status == 1:
            return SolutionStatus.OPTIMAL
        elif self._status == -1:
            return SolutionStatus.INFEASIBLE
        elif self._status in [-2, -3]:
            return SolutionStatus.ERROR
        else:
            StdOutLogger.error(action="Solution status: ", msg=f"{self._status}")
            raise PuLPException("Unhandled PuLP status code.")
    def __init__(self, solver: LpProblem | None = None):
        """
        Initializes the PuLPEngine instance.
        The solver parameter enables the user to pass a pre-configured PuLP solver with custom parameters
        instead of using the default solver. This allows greater flexibility in specifying solver options.
        :param solver: A pre-configured PuLP LpProblem
            object to use as the solver. This allows custom configuration
            of the solver before passing to the engine. If None, a default
            solver will be instantiated with default settings.
            Defaults to None.
        """
        # Instance attributes
        self._solver: LpProblem = solver if solver else LpProblem()
        """ A reference to the PuLP solver. """
        if self._solver is None or not isinstance(self._solver, LpProblem):
            raise PuLPException("The PuLP solver cannot be None.")
        self._objective: Any = None
        """ An object representing the optimization function of the problem. """
        self._status: int = 0
        """ Represents the state of the solution. """
    def add_variable(
        self,
        name: str,
        value_type: ValueType,
        lower_bound: float = 0,
        upper_bound: float = inf,
    ) -> Variable:
        return PuLPEngine._Variable(
            name=name,
            solver=self._solver,
            value_type=value_type,
            lower_bound=lower_bound,
            upper_bound=upper_bound,
        )
    def add_constraint(self, expression: Element) -> Element:
        self._solver += expression.raw
        return expression
    def set_objective(self, opt_type: OptimizationType, expression: Element) -> Element:
        if opt_type == OptimizationType.MINIMIZE:
            self._solver.sense = LpMinimize
        elif opt_type == OptimizationType.MAXIMIZE:
            self._solver.sense = LpMaximize
        else:
            raise PuLPException("Optimization type not supported.")
        self._solver.setObjective(expression.raw)
        self._objective = expression.raw
        return expression
    def solve(self) -> None:
        solve_param = LpSolverDefault.msg = False
        self._status = self._solver.solve(solve_param)