# ____________________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC
# Under the terms of Contract DE-NA0003525 with National Technology and Engineering
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
# software.  This software is distributed under the 3-clause BSD License.
# ____________________________________________________________________________________

import time
import logging
import array
from weakref import ref as weakref_ref

from pyomo.common.log import is_debug_set
from pyomo.common.numeric_types import value
from pyomo.common.modeling import NOTSET
from pyomo.core.expr.expr_common import _type_check_exception_arg
from pyomo.core.expr.numvalue import is_fixed, ZeroConstant
from pyomo.core.base.set_types import Any
from pyomo.core.base import SortComponents, Var
from pyomo.core.base.component import ModelComponentFactory
from pyomo.core.base.constraint import (
    Constraint,
    IndexedConstraint,
    ScalarConstraint,
    ConstraintData,
)
from pyomo.core.expr.numvalue import native_numeric_types
from pyomo.repn import generate_standard_repn

from collections.abc import Mapping

logger = logging.getLogger('pyomo.core')


def _label_bytes(x):
    if x < 1e3:
        return str(x) + " B"
    if x < 1e6:
        return str(x / 1.0e3) + " KB"
    if x < 1e9:
        return str(x / 1.0e6) + " MB"
    return str(x / 1.0e9) + " GB"


#
# Compile a Pyomo constructed model in-place, storing the compiled
# sparse constraint object on the model under constraint_name.
#
def compile_block_linear_constraints(
    parent_block,
    constraint_name,
    skip_trivial_constraints=False,
    single_precision_storage=False,
    verbose=False,
    descend_into=True,
):
    if verbose:
        print("")
        print(
            "Compiling linear constraints on block with name: %s" % (parent_block.name)
        )

    if not parent_block.is_constructed():
        raise RuntimeError(
            "Attempting to compile block '%s' with unconstructed "
            "component(s)" % (parent_block.name)
        )

    #
    # Linear MatrixConstraint in CSR format
    #
    SparseMat_pRows = []
    SparseMat_jCols = []
    SparseMat_Vals = []
    Ranges = []
    RangeTypes = []

    def _get_bound(exp):
        if exp is None:
            return None
        if is_fixed(exp):
            return value(exp)
        raise ValueError("non-fixed bound: " + str(exp))

    start_time = time.time()
    if verbose:
        print("Sorting active blocks...")

    sortOrder = SortComponents.indices | SortComponents.alphabetical
    all_blocks = [
        _b
        for _b in parent_block.block_data_objects(
            active=True, sort=sortOrder, descend_into=descend_into
        )
    ]

    stop_time = time.time()
    if verbose:
        print("Time to sort active blocks: %.2f seconds" % (stop_time - start_time))

    start_time = time.time()
    if verbose:
        print("Collecting variables on active blocks...")

    #
    # First Pass: assign each variable a deterministic id
    #             (an index in a list)
    #
    VarSymbolToVarObject = []
    for block in all_blocks:
        VarSymbolToVarObject.extend(
            block.component_data_objects(Var, sort=sortOrder, descend_into=False)
        )
    VarIDToVarSymbol = dict(
        (id(vardata), index) for index, vardata in enumerate(VarSymbolToVarObject)
    )

    stop_time = time.time()
    if verbose:
        print(
            "Time to collect variables on active blocks: %.2f seconds"
            % (stop_time - start_time)
        )

    start_time = time.time()
    if verbose:
        print("Compiling active linear constraints...")

    #
    # Second Pass: collect and remove active linear constraints
    #
    constraint_data_to_remove = []
    empty_constraint_containers_to_remove = []
    constraint_containers_to_remove = []
    constraint_containers_to_check = set()
    referenced_variable_symbols = set()
    nnz = 0
    nrows = 0
    SparseMat_pRows = [0]
    for block in all_blocks:
        if hasattr(block, '_repn'):
            del block._repn

        for constraint in block.component_objects(
            Constraint, active=True, sort=sortOrder, descend_into=False
        ):
            assert not isinstance(constraint, MatrixConstraint)

            if len(constraint) == 0:
                empty_constraint_containers_to_remove.append((block, constraint))

            else:
                singleton = isinstance(constraint, ScalarConstraint)

                # Note that as we may be removing items from the _data
                # dictionary, we need to make a copy of the items list
                # before iterating:
                for index, constraint_data in list(constraint.items()):
                    if (
                        constraint_data.body.__class__ in native_numeric_types
                        or constraint_data.body.polynomial_degree() <= 1
                    ):
                        # collect for removal
                        if singleton:
                            constraint_containers_to_remove.append((block, constraint))
                        else:
                            constraint_data_to_remove.append((constraint, index))
                            constraint_containers_to_check.add((block, constraint))

                        repn = generate_standard_repn(constraint_data.body)

                        assert repn.nonlinear_expr is None

                        row_variable_symbols = []
                        row_coefficients = []
                        if len(repn.linear_vars) == 0:
                            if skip_trivial_constraints:
                                continue
                        else:
                            row_variable_symbols = [
                                VarIDToVarSymbol[id(vardata)]
                                for vardata in repn.linear_vars
                            ]
                            referenced_variable_symbols.update(row_variable_symbols)
                            assert repn.linear_coefs is not None
                            row_coefficients = repn.linear_coefs

                        SparseMat_pRows.append(
                            SparseMat_pRows[-1] + len(row_variable_symbols)
                        )
                        SparseMat_jCols.extend(row_variable_symbols)
                        SparseMat_Vals.extend(row_coefficients)

                        nnz += len(row_variable_symbols)
                        nrows += 1

                        L = _get_bound(constraint_data.lower)
                        U = _get_bound(constraint_data.upper)
                        constant = value(repn.constant)

                        Ranges.append(L - constant if (L is not None) else 0)
                        Ranges.append(U - constant if (U is not None) else 0)
                        if (
                            (L is not None)
                            and (U is not None)
                            and (not constraint_data.equality)
                        ):
                            RangeTypes.append(
                                MatrixConstraint.LowerBound
                                | MatrixConstraint.UpperBound
                            )
                        elif constraint_data.equality:
                            RangeTypes.append(MatrixConstraint.Equality)
                        elif L is not None:
                            assert U is None
                            RangeTypes.append(MatrixConstraint.LowerBound)
                        else:
                            assert U is not None
                            RangeTypes.append(MatrixConstraint.UpperBound)

                        # Start freeing up memory
                        constraint[index] = Constraint.Skip

    ncols = len(referenced_variable_symbols)

    stop_time = time.time()
    if verbose:
        print(
            "Time to compile active linear constraints: %.2f seconds"
            % (stop_time - start_time)
        )

    start_time = time.time()
    if verbose:
        print("Removing compiled constraint objects...")

    #
    # Remove compiled constraints
    #
    constraints_removed = 0
    constraint_containers_removed = 0
    for block, constraint in empty_constraint_containers_to_remove:
        block.del_component(constraint)
        constraint_containers_removed += 1
    for constraint, index in constraint_data_to_remove:
        # Note that this del is not needed: assigning Constraint.Skip
        # above removes the ConstraintData from the _data dict.
        # del constraint[index]
        constraints_removed += 1
    for block, constraint in constraint_containers_to_remove:
        block.del_component(constraint)
        constraints_removed += 1
        constraint_containers_removed += 1
    for block, constraint in constraint_containers_to_check:
        if len(constraint) == 0:
            block.del_component(constraint)
            constraint_containers_removed += 1

    stop_time = time.time()
    if verbose:
        print(
            "Eliminated %s constraints and %s Constraint container objects"
            % (constraints_removed, constraint_containers_removed)
        )
        print(
            "Time to remove compiled constraint objects: %.2f seconds"
            % (stop_time - start_time)
        )

    start_time = time.time()
    if verbose:
        print("Assigning variable column indices...")

    #
    # Assign a column index to the set of referenced variables
    #
    ColumnIndexToVarSymbol = sorted(referenced_variable_symbols)
    VarSymbolToColumnIndex = dict(
        (symbol, column) for column, symbol in enumerate(ColumnIndexToVarSymbol)
    )
    SparseMat_jCols = [VarSymbolToColumnIndex[symbol] for symbol in SparseMat_jCols]
    del VarSymbolToColumnIndex
    ColumnIndexToVarObject = [
        VarSymbolToVarObject[var_symbol] for var_symbol in ColumnIndexToVarSymbol
    ]

    stop_time = time.time()
    if verbose:
        print(
            "Time to assign variable column indices: %.2f seconds"
            % (stop_time - start_time)
        )

    start_time = time.time()
    if verbose:
        print("Converting compiled constraint data to array storage...")
        print(
            "  - Using %s precision for numeric values"
            % ('single' if single_precision_storage else 'double')
        )

    #
    # Convert to array storage
    #

    number_storage = 'f' if single_precision_storage else 'd'
    SparseMat_pRows = array.array('L', SparseMat_pRows)
    SparseMat_jCols = array.array('L', SparseMat_jCols)
    SparseMat_Vals = array.array(number_storage, SparseMat_Vals)
    Ranges = array.array(number_storage, Ranges)
    RangeTypes = array.array('B', RangeTypes)

    stop_time = time.time()
    if verbose:
        storage_bytes = (
            SparseMat_pRows.buffer_info()[1] * SparseMat_pRows.itemsize
            + SparseMat_jCols.buffer_info()[1] * SparseMat_jCols.itemsize
            + SparseMat_Vals.buffer_info()[1] * SparseMat_Vals.itemsize
            + Ranges.buffer_info()[1] * Ranges.itemsize
            + RangeTypes.buffer_info()[1] * RangeTypes.itemsize
        )
        print("Sparse Matrix Dimension:")
        print("  - Rows: " + str(nrows))
        print("  - Cols: " + str(ncols))
        print("  - Nonzeros: " + str(nnz))
        print("Compiled Data Storage: " + str(_label_bytes(storage_bytes)))
        print(
            "Time to convert compiled constraint data to "
            "array storage: %.2f seconds" % (stop_time - start_time)
        )

    parent_block.add_component(
        constraint_name,
        MatrixConstraint(
            nrows,
            ncols,
            nnz,
            SparseMat_pRows,
            SparseMat_jCols,
            SparseMat_Vals,
            Ranges,
            RangeTypes,
            ColumnIndexToVarObject,
        ),
    )


# class _LinearConstraintData(ConstraintData,LinearCanonicalRepn):
#
# This change breaks this class, but it's unclear whether this
# is being used...
#
class _LinearConstraintData(ConstraintData):
    """
    This class defines the data for a single linear constraint
        in canonical form.

    Constructor arguments:
        component       The Constraint object that owns this data.

    Public class attributes:
        active          A boolean that is true if this constraint is
                            active in the model.
        body            The Pyomo expression for this constraint
        lower           The Pyomo expression for the lower bound
        upper           The Pyomo expression for the upper bound
        equality        A boolean that indicates whether this is an
                            equality constraint
        strict_lower    A boolean that indicates whether this
                            constraint uses a strict lower bound
        strict_upper    A boolean that indicates whether this
                            constraint uses a strict upper bound
        variables       A tuple of variables comprising the body
                            of this constraint
        coefficients    A tuple of coefficients matching the order
                            of variables that comprise the body of
                            this constraint
        constant        A number representing the aggregation of any
                            constant/fixed items found in the body of
                            this constraint

    Private class attributes:
        _component      The objective component.
        _active         A boolean that indicates whether this data is active
    """

    __slots__ = ()

    def __init__(self, index, component=None):
        #
        # These lines represent in-lining of the
        # following constructors:
        #   - ConstraintData,
        #   - ActiveComponentData
        #   - ComponentData
        self._component = weakref_ref(component) if (component is not None) else None
        self._index = index
        self._active = True


class _LinearMatrixConstraintData(_LinearConstraintData):
    """
    This class defines the data for a single linear constraint
        derived from a canonical form Ax=b constraint.

    Constructor arguments:
        component       The Constraint object that owns this data.

    Public class attributes:
        active          A boolean that is true if this constraint is
                            active in the model.
        body            The Pyomo expression for this constraint
        lower           The Pyomo expression for the lower bound
        upper           The Pyomo expression for the upper bound
        equality        A boolean that indicates whether this is an
                            equality constraint
        strict_lower    A boolean that indicates whether this
                            constraint uses a strict lower bound
        strict_upper    A boolean that indicates whether this
                            constraint uses a strict upper bound
        variables       A tuple of variables comprising the body
                            of this constraint
        coefficients    A tuple of coefficients matching the order
                            of variables that comprise the body of
                            this constraint
        constant        A number representing the aggregate of any
                            constants found in the body of this
                            constraint

    Private class attributes:
        _component      The objective component.
        _active         A boolean that indicates whether this data is active
    """

    __slots__ = ()

    def __init__(self, index, component=None):
        #
        # These lines represent in-lining of the
        # following constructors:
        #   - _LinearConstraintData
        #   - ConstraintData,
        #   - ActiveComponentData
        #   - ComponentData
        self._component = weakref_ref(component) if (component is not None) else None
        self._active = True

        # row index into the sparse matrix stored on the parent
        assert index >= 0
        self._index = index

    def __getstate__(self):
        """
        This method must be defined because this class uses slots.
        """
        result = super(_LinearMatrixConstraintData, self).__getstate__()
        return result

    # Since this class requires no special processing of the state
    # dictionary, it does not need to implement __setstate__()

    #
    # Override the default interface methods to
    # avoid generating the body expression where
    # possible
    #

    def __call__(self, exception=NOTSET):
        """
        Compute the value of the body of this constraint.
        """
        exception = _type_check_exception_arg(self, exception)
        comp = self.parent_component()
        index = self.index()
        prows = comp._prows
        jcols = comp._jcols
        varmap = comp._varmap
        vals = comp._vals
        try:
            return sum(
                varmap[jcols[p]]() * vals[p]
                for p in range(prows[index], prows[index + 1])
            )
        except (ValueError, TypeError):
            if exception:
                raise
            return None

    def has_lb(self):
        """Returns :const:`False` when the lower bound is
        :const:`None` or negative infinity"""
        lb = self.lower
        return (lb is not None) and (lb != float('-inf'))

    def has_ub(self):
        """Returns :const:`False` when the upper bound is
        :const:`None` or positive infinity"""
        ub = self.upper
        return (ub is not None) and (ub != float('inf'))

    def lslack(self):
        """
        Returns the value of L-f(x) for constraints of the form:
            L <= f(x) (<= U)
            (U >=) f(x) >= L
        """
        raise self.lower - self()

    def uslack(self):
        """
        Returns the value of U-f(x) for constraints of the form:
            (L <=) f(x) <= U
            U >= f(x) (>= L)
        """
        self.upper - self()

    #
    # Override some default implementations on ComponentData
    #

    def index(self):
        return self._index

    #
    # Abstract Interface (LinearCanonicalRepn)
    #

    @property
    def variables(self):
        """A tuple of variables comprising the constraint body."""
        comp = self.parent_component()
        prows = comp._prows
        jcols = comp._jcols
        varmap = comp._varmap
        if prows[self._index] == prows[self._index + 1]:
            return ()
        variables = tuple(
            varmap[jcols[p]]
            for p in range(prows[self._index], prows[self._index + 1])
            if not varmap[jcols[p]].fixed
        )

        return variables

    @property
    def coefficients(self):
        """A tuple of coefficients associated with the variables."""
        comp = self.parent_component()
        prows = comp._prows
        jcols = comp._jcols
        vals = comp._vals
        varmap = comp._varmap
        if prows[self._index] == prows[self._index + 1]:
            return ()
        coefs = tuple(
            vals[p]
            for p in range(prows[self._index], prows[self._index + 1])
            if not varmap[jcols[p]].fixed
        )

        return coefs

    # for backwards compatibility
    linear = coefficients

    @property
    def constant(self):
        """The constant value associated with the constraint body."""
        comp = self.parent_component()
        prows = comp._prows
        jcols = comp._jcols
        vals = comp._vals
        varmap = comp._varmap
        if prows[self._index] == prows[self._index + 1]:
            return 0
        terms = tuple(
            vals[p] * varmap[jcols[p]]()
            for p in range(prows[self._index], prows[self._index + 1])
            if varmap[jcols[p]].fixed
        )

        return sum(terms)

    #
    # Abstract Interface (ConstraintData)
    #

    def to_bounded_expression(self, evaluate_bounds=False):
        """Access this constraint as a single expression."""
        # Note that the bounds are always going to be floats...
        return self.lower, self.body, self.upper

    @property
    def body(self):
        """Access the body of a constraint expression."""
        comp = self.parent_component()
        index = self.index()
        prows = comp._prows
        jcols = comp._jcols
        varmap = comp._varmap
        vals = comp._vals
        if prows[self._index] == prows[self._index + 1]:
            return ZeroConstant
        return sum(
            varmap[jcols[p]] * vals[p] for p in range(prows[index], prows[index + 1])
        )

    @property
    def lower(self):
        """Access the lower bound of a constraint expression."""
        comp = self.parent_component()
        index = self.index()
        if comp._range_types[index] & MatrixConstraint.LowerBound:
            return comp._ranges[2 * index]
        return None

    @property
    def upper(self):
        """Access the upper bound of a constraint expression."""
        comp = self.parent_component()
        index = self.index()
        if comp._range_types[index] & MatrixConstraint.UpperBound:
            return comp._ranges[(2 * index) + 1]
        return None

    @property
    def lb(self):
        """Access the lower bound of a constraint expression."""
        return self.lower

    @property
    def ub(self):
        """Access the upper bound of a constraint expression."""
        return self.upper

    @property
    def equality(self):
        """A boolean indicating whether this is an equality constraint."""
        return (
            self.parent_component()._range_types[self.index()]
            & MatrixConstraint.Equality
        ) == MatrixConstraint.Equality

    @property
    def strict_lower(self):
        """A boolean indicating whether this constraint has a strict lower bound."""
        return (
            self.parent_component()._range_types[self.index()]
            & MatrixConstraint.StrictLowerBound
        ) == MatrixConstraint.StrictLowerBound

    @property
    def strict_upper(self):
        """A boolean indicating whether this constraint has a strict upper bound."""
        return (
            self.parent_component()._range_types[self.index()]
            & MatrixConstraint.StrictUpperBound
        ) == MatrixConstraint.StrictUpperBound

    def set_value(self, expr):
        """Set the expression on this constraint."""
        raise NotImplementedError("MatrixConstraint row elements can not be updated")


@ModelComponentFactory.register("A set of constraint expressions in Ax=b form.")
class MatrixConstraint(Mapping, IndexedConstraint):
    #
    # Bound types
    # (make sure the maximum value here
    #  will fit in an unsigned char)
    #
    StrictUpperBound = 0b00011
    UpperBound = 0b00010
    Equality = 0b01110
    LowerBound = 0b01000
    StrictLowerBound = 0b11000
    NoBound = 0b00000

    def __init__(
        self, nrows, ncols, nnz, prows, jcols, vals, ranges, range_types, varmap
    ):
        assert len(prows) == nrows + 1
        assert len(jcols) == nnz
        assert len(vals) == nnz
        assert len(ranges) == 2 * nrows
        assert len(range_types) == nrows
        assert len(varmap) == ncols

        IndexedConstraint.__init__(self, Any)

        self._prows = prows
        self._jcols = jcols
        self._vals = vals
        self._ranges = ranges
        self._range_types = range_types
        self._varmap = varmap

    def construct(self, data=None):
        """
        Construct the expression(s) for this constraint.
        """
        if is_debug_set(logger):
            logger.debug("Constructing constraint %s" % (self.name))
        if self._constructed:
            return
        self._constructed = True

        _init = _LinearMatrixConstraintData
        self._data = tuple(
            _init(i, component=self) for i in range(len(self._range_types))
        )

    #
    # Override some IndexedComponent methods
    #

    def __getitem__(self, key):
        return self._data[key]

    def __len__(self):
        return self._data.__len__()

    def __iter__(self):
        return iter(i for i in range(len(self)))

    #
    # Remove methods that allow modifying this constraint
    #

    def add(self, index, expr):
        raise NotImplementedError

    def __delitem__(self):
        raise NotImplementedError

    #
    # Pyomo components support an extended dict API
    #
    def keys(self, sort=None):
        # The 0..n-1 indices are always ordered and sorted; we can
        # ignore the `sort` argument
        return super().keys()

    def values(self, sort=None):
        # The 0..n-1 indices are always ordered and sorted; we can
        # ignore the `sort` argument
        return super().values()

    def items(self, sort=None):
        # The 0..n-1 indices are always ordered and sorted; we can
        # ignore the `sort` argument
        return super().items()
