# ____________________________________________________________________________________
#
# 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 math
import copy
import re
import io
import pyomo.environ as pyo
from pyomo.core.expr.visitor import StreamBasedExpressionVisitor
from pyomo.core.expr import (
    NegationExpression,
    ProductExpression,
    DivisionExpression,
    PowExpression,
    AbsExpression,
    UnaryFunctionExpression,
    MonomialTermExpression,
    LinearExpression,
    SumExpression,
    EqualityExpression,
    InequalityExpression,
    RangedExpression,
    Expr_ifExpression,
    ExternalFunctionExpression,
)

from pyomo.core.expr.visitor import identify_components
from pyomo.core.expr.base import ExpressionBase
from pyomo.core.base.expression import ScalarExpression, ExpressionData
from pyomo.core.base.objective import ScalarObjective, ObjectiveData
import pyomo.core.kernel as kernel
from pyomo.core.expr.template_expr import (
    GetItemExpression,
    GetAttrExpression,
    TemplateSumExpression,
    IndexTemplate,
    Numeric_GetItemExpression,
    templatize_constraint,
    resolve_template,
    templatize_rule,
)
from pyomo.core.base.var import ScalarVar, VarData, IndexedVar
from pyomo.core.base.param import ParamData, ScalarParam, IndexedParam
from pyomo.core.base.set import SetData, SetOperator
from pyomo.core.base.constraint import ScalarConstraint, IndexedConstraint
from pyomo.common.collections.component_map import ComponentMap
from pyomo.common.collections.component_set import ComponentSet
from pyomo.core.expr.template_expr import (
    NPV_Numeric_GetItemExpression,
    NPV_Structural_GetItemExpression,
    Numeric_GetAttrExpression,
)
from pyomo.core.expr.numeric_expr import NPV_SumExpression, NPV_DivisionExpression
from pyomo.core.base.block import IndexedBlock

from pyomo.core.base.external import _PythonCallbackFunctionID
from pyomo.core.base.enums import SortComponents

from pyomo.core.base.block import BlockData

from pyomo.repn.util import ExprType

from pyomo.common import DeveloperError

_CONSTANT = ExprType.CONSTANT
_MONOMIAL = ExprType.MONOMIAL
_GENERAL = ExprType.GENERAL

from pyomo.common.errors import InfeasibleConstraintException

from pyomo.common.dependencies import numpy as np, numpy_available

set_operator_map = {
    '|': r' \cup ',
    '&': r' \cap ',
    '*': r' \times ',
    '-': r' \setminus ',
    '^': r' \triangle ',
}

latex_reals = r'\mathds{R}'
latex_integers = r'\mathds{Z}'

domainMap = {
    'Reals': latex_reals,
    'PositiveReals': latex_reals + '_{> 0}',
    'NonPositiveReals': latex_reals + '_{\\leq 0}',
    'NegativeReals': latex_reals + '_{< 0}',
    'NonNegativeReals': latex_reals + '_{\\geq 0}',
    'Integers': latex_integers,
    'PositiveIntegers': latex_integers + '_{> 0}',
    'NonPositiveIntegers': latex_integers + '_{\\leq 0}',
    'NegativeIntegers': latex_integers + '_{< 0}',
    'NonNegativeIntegers': latex_integers + '_{\\geq 0}',
    'Boolean': '\\left\\{ \\text{True} , \\text{False} \\right \\}',
    'Binary': '\\left\\{ 0 , 1 \\right \\}',
    # 'Any': None,
    # 'AnyWithNone': None,
    'EmptySet': '\\varnothing',
    'UnitInterval': latex_reals,
    'PercentFraction': latex_reals,
    # 'RealInterval' :        None    ,
    # 'IntegerInterval' :     None    ,
}


def decoder(num, base):
    if int(num) != abs(num):
        # Requiring an integer is nice, but not strictly necessary;
        # the algorithm works for floating point
        raise ValueError("num should be a nonnegative integer")
    if int(base) != abs(base) or not base:
        raise ValueError("base should be a positive integer")
    ans = []
    while 1:
        ans.append(num % base)
        num //= base
        if not num:
            return list(reversed(ans))


def indexCorrector(ixs, base):
    for i in range(0, len(ixs)):
        ix = ixs[i]
        if i + 1 < len(ixs):
            if ixs[i + 1] == 0:
                ixs[i] -= 1
                ixs[i + 1] = base
                if ixs[i] == 0:
                    ixs = indexCorrector(ixs, base)
    return ixs


def alphabetStringGenerator(num):
    alphabet = ['.', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r']

    ixs = decoder(num + 1, len(alphabet) - 1)
    pstr = ''
    ixs = indexCorrector(ixs, len(alphabet) - 1)
    for i in range(0, len(ixs)):
        ix = ixs[i]
        pstr += alphabet[ix]
        pstr = pstr.replace('.', '')
    return pstr


def templatize_expression(expr):
    expr, indices = templatize_rule(expr.parent_block(), expr._rule, expr.index_set())
    return (expr, indices)


def templatize_passthrough(con):
    return (con, [])


def precedenceChecker(node, arg1, arg2=None):
    childPrecedence = []
    for a in node.args:
        if hasattr(a, 'PRECEDENCE'):
            if a.PRECEDENCE is None:
                childPrecedence.append(-1)
            else:
                childPrecedence.append(a.PRECEDENCE)
        else:
            childPrecedence.append(-1)

    if hasattr(node, 'PRECEDENCE'):
        precedence = node.PRECEDENCE
    else:
        # Should never hit this
        raise DeveloperError(
            'This error should never be thrown, node does not have a precedence.  Report to developers'
        )

    if childPrecedence[0] > precedence:
        arg1 = ' \\left( ' + arg1 + ' \\right) '

    if arg2 is not None:
        if childPrecedence[1] > precedence:
            arg2 = ' \\left( ' + arg2 + ' \\right) '

    return arg1, arg2


def handle_negation_node(visitor, node, arg1):
    arg1, tsh = precedenceChecker(node, arg1)
    return '-' + arg1


def handle_product_node(visitor, node, arg1, arg2):
    arg1, arg2 = precedenceChecker(node, arg1, arg2)
    return ' '.join([arg1, arg2])


def handle_pow_node(visitor, node, arg1, arg2):
    arg1, arg2 = precedenceChecker(node, arg1, arg2)
    return "%s^{%s}" % (arg1, arg2)


def handle_division_node(visitor, node, arg1, arg2):
    return '\\frac{%s}{%s}' % (arg1, arg2)


def handle_abs_node(visitor, node, arg1):
    return ' \\left| ' + arg1 + ' \\right| '


def handle_unary_node(visitor, node, arg1):
    fcn_handle = node.getname()
    if fcn_handle == 'log10':
        fcn_handle = 'log_{10}'

    if fcn_handle == 'sqrt':
        return '\\sqrt { ' + arg1 + ' }'
    else:
        return '\\' + fcn_handle + ' \\left( ' + arg1 + ' \\right) '


def handle_equality_node(visitor, node, arg1, arg2):
    return arg1 + ' = ' + arg2


def handle_inequality_node(visitor, node, arg1, arg2):
    return arg1 + ' \\leq ' + arg2


def handle_var_node(visitor, node):
    return visitor.variableMap[node]


def handle_num_node(visitor, node):
    if isinstance(node, float):
        if node.is_integer():
            node = int(node)
    return str(node)


def handle_sumExpression_node(visitor, node, *args):
    rstr = args[0]
    for i in range(1, len(args)):
        if args[i][0] == '-':
            rstr += ' - ' + args[i][1:]
        else:
            rstr += ' + ' + args[i]
    return rstr


def handle_monomialTermExpression_node(visitor, node, arg1, arg2):
    if arg1 == '1':
        return arg2
    elif arg1 == '-1':
        return '-' + arg2
    else:
        return arg1 + ' ' + arg2


def handle_named_expression_node(visitor, node, arg1):
    # needed to preserve consistency with the exitNode function call
    # prevents the need to type check in the exitNode function
    return arg1


def handle_ranged_inequality_node(visitor, node, arg1, arg2, arg3):
    return arg1 + ' \\leq ' + arg2 + ' \\leq ' + arg3


def handle_exprif_node(visitor, node, arg1, arg2, arg3):
    return 'f_{\\text{exprIf}}(' + arg1 + ',' + arg2 + ',' + arg3 + ')'

    ## Could be handled in the future using cases or similar

    ## Raises not implemented error
    # raise NotImplementedError('Expr_if objects not supported by the Latex Printer')

    ## Puts cases in a bracketed matrix
    # pstr = ''
    # pstr += '\\begin{Bmatrix} '
    # pstr += arg2 + ' , & ' + arg1 + '\\\\ '
    # pstr += arg3 + ' , & \\text{otherwise}' + '\\\\ '
    # pstr += '\\end{Bmatrix}'
    # return pstr


def handle_external_function_node(visitor, node, *args):
    pstr = ''
    visitor.externalFunctionCounter += 1
    pstr += 'f\\_' + str(visitor.externalFunctionCounter) + '('
    for i in range(0, len(args) - 1):
        pstr += args[i]
        if i <= len(args) - 3:
            pstr += ','
        else:
            pstr += ')'
    return pstr


def handle_functionID_node(visitor, node, *args):
    # seems to just be a placeholder empty wrapper object
    return ''


def handle_indexTemplate_node(visitor, node, *args):
    if node._set in visitor.setMap:
        # already detected set, do nothing
        pass
    else:
        visitor.setMap[node._set] = 'SET%d' % (len(visitor.setMap) + 1)

    return '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % (
        node._group,
        node._id,
        visitor.setMap[node._set],
    )


def handle_numericGetItemExpression_node(visitor, node, *args):
    joinedName = args[0]

    pstr = ''
    pstr += joinedName + '_{'
    for i in range(1, len(args)):
        pstr += args[i]
        if i <= len(args) - 2:
            pstr += ','
        else:
            pstr += '}'
    return pstr


def handle_templateSumExpression_node(visitor, node, *args):
    pstr = ''
    for i in range(0, len(node._iters)):
        pstr += '\\sum_{__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__} ' % (
            node._iters[i][0]._group,
            ','.join(str(it._id) for it in node._iters[i]),
            visitor.setMap[node._iters[i][0]._set],
        )

    pstr += args[0]

    return pstr


def handle_param_node(visitor, node):
    return visitor.parameterMap[node]


def handle_str_node(visitor, node):
    return "\\mathtt{'" + node.replace('_', '\\_') + "'}"


def handle_npv_structuralGetItemExpression_node(visitor, node, *args):
    joinedName = args[0]

    pstr = ''
    pstr += joinedName + '['
    for i in range(1, len(args)):
        pstr += args[i]
        if i <= len(args) - 2:
            pstr += ','
        else:
            pstr += ']'
    return pstr


def handle_indexedBlock_node(visitor, node, *args):
    return str(node)


def handle_numericGetAttrExpression_node(visitor, node, *args):
    return args[0] + '.' + args[1]


class _LatexVisitor(StreamBasedExpressionVisitor):
    def __init__(self):
        super().__init__()
        self.externalFunctionCounter = 0

        self._operator_handles = {
            ScalarVar: handle_var_node,
            int: handle_num_node,
            float: handle_num_node,
            NegationExpression: handle_negation_node,
            ProductExpression: handle_product_node,
            DivisionExpression: handle_division_node,
            PowExpression: handle_pow_node,
            AbsExpression: handle_abs_node,
            UnaryFunctionExpression: handle_unary_node,
            Expr_ifExpression: handle_exprif_node,
            EqualityExpression: handle_equality_node,
            InequalityExpression: handle_inequality_node,
            RangedExpression: handle_ranged_inequality_node,
            ExpressionData: handle_named_expression_node,
            ScalarExpression: handle_named_expression_node,
            kernel.expression.expression: handle_named_expression_node,
            kernel.expression.noclone: handle_named_expression_node,
            ObjectiveData: handle_named_expression_node,
            VarData: handle_var_node,
            ScalarObjective: handle_named_expression_node,
            kernel.objective.objective: handle_named_expression_node,
            ExternalFunctionExpression: handle_external_function_node,
            _PythonCallbackFunctionID: handle_functionID_node,
            LinearExpression: handle_sumExpression_node,
            SumExpression: handle_sumExpression_node,
            MonomialTermExpression: handle_monomialTermExpression_node,
            IndexedVar: handle_var_node,
            IndexTemplate: handle_indexTemplate_node,
            Numeric_GetItemExpression: handle_numericGetItemExpression_node,
            TemplateSumExpression: handle_templateSumExpression_node,
            ScalarParam: handle_param_node,
            ParamData: handle_param_node,
            IndexedParam: handle_param_node,
            NPV_Numeric_GetItemExpression: handle_numericGetItemExpression_node,
            IndexedBlock: handle_indexedBlock_node,
            NPV_Structural_GetItemExpression: handle_npv_structuralGetItemExpression_node,
            str: handle_str_node,
            Numeric_GetAttrExpression: handle_numericGetAttrExpression_node,
            NPV_SumExpression: handle_sumExpression_node,
            NPV_DivisionExpression: handle_division_node,
        }
        if numpy_available:
            self._operator_handles[np.float64] = handle_num_node

    def exitNode(self, node, data):
        try:
            return self._operator_handles[node.__class__](self, node, *data)
        except:
            raise DeveloperError(
                'Latex printer encountered an error when processing type %s, contact the developers'
                % (node.__class__)
            )


def analyze_variable(vr):
    domainName = vr.domain.name
    varBounds = vr.bounds
    lowerBoundValue = varBounds[0]
    upperBoundValue = varBounds[1]

    if domainName in ['Reals', 'Integers']:
        if lowerBoundValue is not None:
            lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ''

        if upperBoundValue is not None:
            upperBound = ' \\leq ' + str(upperBoundValue)
        else:
            upperBound = ''

    elif domainName in ['PositiveReals', 'PositiveIntegers']:
        if lowerBoundValue > 0:
            lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ' 0 < '

        if upperBoundValue is not None:
            if upperBoundValue <= 0:
                raise InfeasibleConstraintException(
                    'Formulation is infeasible due to bounds on variable %s' % (vr.name)
                )
            else:
                upperBound = ' \\leq ' + str(upperBoundValue)
        else:
            upperBound = ''

    elif domainName in ['NonPositiveReals', 'NonPositiveIntegers']:
        if lowerBoundValue is not None:
            if lowerBoundValue > 0:
                raise InfeasibleConstraintException(
                    'Formulation is infeasible due to bounds on variable %s' % (vr.name)
                )
            elif lowerBoundValue == 0:
                lowerBound = ' 0 = '
            else:
                lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ''

        if upperBoundValue >= 0:
            upperBound = ' \\leq 0 '
        else:
            upperBound = ' \\leq ' + str(upperBoundValue)

    elif domainName in ['NegativeReals', 'NegativeIntegers']:
        if lowerBoundValue is not None:
            if lowerBoundValue >= 0:
                raise InfeasibleConstraintException(
                    'Formulation is infeasible due to bounds on variable %s' % (vr.name)
                )
            else:
                lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ''

        if upperBoundValue >= 0:
            upperBound = ' < 0 '
        else:
            upperBound = ' \\leq ' + str(upperBoundValue)

    elif domainName in ['NonNegativeReals', 'NonNegativeIntegers']:
        if lowerBoundValue > 0:
            lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ' 0 \\leq '

        if upperBoundValue is not None:
            if upperBoundValue < 0:
                raise InfeasibleConstraintException(
                    'Formulation is infeasible due to bounds on variable %s' % (vr.name)
                )
            elif upperBoundValue == 0:
                upperBound = ' = 0 '
            else:
                upperBound = ' \\leq ' + str(upperBoundValue)
        else:
            upperBound = ''

    elif domainName in ['Boolean', 'Binary', 'Any', 'AnyWithNone', 'EmptySet']:
        lowerBound = ''
        upperBound = ''

    elif domainName in ['UnitInterval', 'PercentFraction']:
        if lowerBoundValue > 1:
            raise InfeasibleConstraintException(
                'Formulation is infeasible due to bounds on variable %s' % (vr.name)
            )
        elif lowerBoundValue == 1:
            lowerBound = ' = 1 '
        elif lowerBoundValue > 0:
            lowerBound = str(lowerBoundValue) + ' \\leq '
        else:
            lowerBound = ' 0 \\leq '

        if upperBoundValue < 0:
            raise InfeasibleConstraintException(
                'Formulation is infeasible due to bounds on variable %s' % (vr.name)
            )
        elif upperBoundValue == 0:
            upperBound = ' = 0 '
        elif upperBoundValue < 1:
            upperBound = ' \\leq ' + str(upperBoundValue)
        else:
            upperBound = ' \\leq 1 '

    else:
        raise NotImplementedError(
            'Invalid domain encountered, will be supported in a future update'
        )

    varBoundData = {
        'variable': vr,
        'lowerBound': lowerBound,
        'upperBound': upperBound,
        'domainName': domainName,
        'domainLatex': domainMap[domainName],
    }

    return varBoundData


def multiple_replace(pstr, rep_dict):
    pattern = re.compile("|".join(rep_dict.keys()), flags=re.DOTALL)
    return pattern.sub(lambda x: rep_dict[x.group(0)], pstr)


def latex_printer(
    pyomo_component,
    latex_component_map=None,
    ostream=None,
    use_equation_environment=False,
    explicit_set_summation=False,
    throw_templatization_error=False,
):
    """This function produces a string that can be rendered as LaTeX

    Prints a Pyomo component (Block, Model, Objective, Constraint, or Expression) to a LaTeX compatible string

    Parameters
    ----------
    pyomo_component: BlockData or Model or Objective or Constraint or Expression
        The Pyomo component to be printed

    latex_component_map: pyomo.common.collections.component_map.ComponentMap
        A map keyed by Pyomo component, values become the LaTeX representation in
        the printer

    ostream: io.TextIOWrapper or io.StringIO or str
        The object to print the LaTeX string to.  Can be an open file object,
        string I/O object, or a string for a filename to write to

    use_equation_environment: bool
        If False, the equation/aligned construction is used to create a single
         LaTeX equation.  If True, then the align environment is used in LaTeX and
         each constraint and objective will be given an individual equation number

    explicit_set_summation: bool
        If False, all sums will be done over 'index in set' or similar.  If True,
        sums will be done over 'i=1' to 'N' or similar if the set is a continuous
        set

    throw_templatization_error: bool
        Option to throw an error on templatization failure rather than
        printing each constraint individually, useful for very large models


    Returns
    -------
    str
        A LaTeX string of the pyomo_component

    """

    # Various setup things

    # is Single implies Objective, constraint, or expression
    # these objects require a slight modification of behavior
    # isSingle==False means a model or block

    use_short_descriptors = True

    # Cody's backdoor because he got outvoted
    if latex_component_map is not None:
        if 'use_short_descriptors' in latex_component_map:
            if latex_component_map['use_short_descriptors'] == False:
                use_short_descriptors = False

    if latex_component_map is None:
        latex_component_map = ComponentMap()
        existing_components = ComponentSet()
    else:
        existing_components = ComponentSet(latex_component_map)

    isSingle = False

    if isinstance(pyomo_component, pyo.Objective):
        objectives = [pyomo_component]
        constraints = []
        expressions = []
        templatize_fcn = templatize_constraint
        use_equation_environment = True
        isSingle = True

    elif isinstance(pyomo_component, pyo.Constraint):
        objectives = []
        constraints = [pyomo_component]
        expressions = []
        templatize_fcn = templatize_constraint
        use_equation_environment = True
        isSingle = True

    elif isinstance(pyomo_component, pyo.Expression):
        objectives = []
        constraints = []
        expressions = [pyomo_component]
        templatize_fcn = templatize_expression
        use_equation_environment = True
        isSingle = True

    elif isinstance(pyomo_component, (ExpressionBase, pyo.Var)):
        objectives = []
        constraints = []
        expressions = [pyomo_component]
        templatize_fcn = templatize_passthrough
        use_equation_environment = True
        isSingle = True

    elif isinstance(pyomo_component, BlockData):
        objectives = [
            obj
            for obj in pyomo_component.component_data_objects(
                pyo.Objective,
                descend_into=True,
                active=True,
                sort=SortComponents.deterministic,
            )
        ]
        constraints = [
            con
            for con in pyomo_component.component_objects(
                pyo.Constraint,
                descend_into=True,
                active=True,
                sort=SortComponents.deterministic,
            )
        ]
        expressions = []
        templatize_fcn = templatize_constraint

    else:
        raise ValueError(
            "Invalid type %s passed into the latex printer"
            % (str(type(pyomo_component)))
        )

    if isSingle:
        temp_comp, temp_indexes = templatize_fcn(pyomo_component)
        variableList = []
        for v in identify_components(temp_comp, [ScalarVar, VarData, IndexedVar]):
            if isinstance(v, VarData):
                v_write = v.parent_component()
                if v_write not in ComponentSet(variableList):
                    variableList.append(v_write)
            else:
                if v not in ComponentSet(variableList):
                    variableList.append(v)

        parameterList = []
        for p in identify_components(temp_comp, [ScalarParam, ParamData, IndexedParam]):
            if isinstance(p, ParamData):
                p_write = p.parent_component()
                if p_write not in ComponentSet(parameterList):
                    parameterList.append(p_write)
            else:
                if p not in ComponentSet(parameterList):
                    parameterList.append(p)

        # Will grab the sets as the expression is walked
        setList = []

    else:
        variableList = [
            vr
            for vr in pyomo_component.component_objects(
                pyo.Var,
                descend_into=True,
                active=True,
                sort=SortComponents.deterministic,
            )
        ]

        parameterList = [
            pm
            for pm in pyomo_component.component_objects(
                pyo.Param,
                descend_into=True,
                active=True,
                sort=SortComponents.deterministic,
            )
        ]

        setList = [
            st
            for st in pyomo_component.component_objects(
                pyo.Set,
                descend_into=True,
                active=True,
                sort=SortComponents.deterministic,
            )
        ]

    descriptorDict = {}
    if use_short_descriptors:
        descriptorDict['minimize'] = '\\min'
        descriptorDict['maximize'] = '\\max'
        descriptorDict['subject to'] = '\\text{s.t.}'
        descriptorDict['with bounds'] = '\\text{w.b.}'
    else:
        descriptorDict['minimize'] = '\\text{minimize}'
        descriptorDict['maximize'] = '\\text{maximize}'
        descriptorDict['subject to'] = '\\text{subject to}'
        descriptorDict['with bounds'] = '\\text{with bounds}'

    # In the case where just a single expression is passed, add this to the constraint list for printing
    constraints = constraints + expressions

    # Declare a visitor/walker
    visitor = _LatexVisitor()

    variableMap = ComponentMap()
    vrIdx = 0
    for vr in variableList:
        vrIdx += 1
        if isinstance(vr, ScalarVar):
            variableMap[vr] = 'x_' + str(vrIdx) + '_'
        elif isinstance(vr, IndexedVar):
            variableMap[vr] = 'x_' + str(vrIdx) + '_'
            for sd in vr.index_set().data():
                vrIdx += 1
                variableMap[vr[sd]] = 'x_' + str(vrIdx) + '_'
        else:
            raise DeveloperError(
                'Variable is not a variable.  Should not happen.  Contact developers'
            )
    visitor.variableMap = variableMap

    parameterMap = ComponentMap()
    pmIdx = 0
    for vr in parameterList:
        pmIdx += 1
        if isinstance(vr, ScalarParam):
            parameterMap[vr] = 'p_' + str(pmIdx) + '_'
        elif isinstance(vr, IndexedParam):
            parameterMap[vr] = 'p_' + str(pmIdx) + '_'
            for sd in vr.index_set().data():
                pmIdx += 1
                parameterMap[vr[sd]] = 'p_' + str(pmIdx) + '_'
        else:
            raise DeveloperError(
                'Parameter is not a parameter.  Should not happen.  Contact developers'
            )
    visitor.parameterMap = parameterMap

    setMap = ComponentMap()
    for i in range(0, len(setList)):
        st = setList[i]
        setMap[st] = 'SET' + str(i + 1)
    visitor.setMap = setMap

    # starts building the output string
    pstr = ''
    if not use_equation_environment:
        pstr += '\\begin{align} \n'
        tbSpc = 4
        trailingAligner = '& '
    else:
        pstr += '\\begin{equation} \n'
        if not isSingle:
            pstr += '    \\begin{aligned} \n'
            tbSpc = 8
        else:
            tbSpc = 4
        trailingAligner = ''

    # Iterate over the objectives and print
    for obj in objectives:
        try:
            obj_template, obj_indices = templatize_fcn(obj)
        except:
            if throw_templatization_error:
                raise RuntimeError(
                    "An objective named '%s' has been constructed that cannot be templatized"
                    % (obj.__str__())
                )
            else:
                obj_template = obj

        if obj.sense == pyo.minimize:  # or == 1
            pstr += ' ' * tbSpc + '& %s \n' % (descriptorDict['minimize'])
        else:
            pstr += ' ' * tbSpc + '& %s \n' % (descriptorDict['maximize'])

        pstr += ' ' * tbSpc + '& & %s %s' % (
            visitor.walk_expression(obj_template),
            trailingAligner,
        )
        if not use_equation_environment:
            pstr += '\\label{obj:' + pyomo_component.name + '_' + obj.name + '} '
        if not isSingle:
            pstr += '\\\\ \n'
        else:
            pstr += '\n'

    # Iterate over the constraints
    if len(constraints) > 0:
        # only print this if printing a full formulation
        if not isSingle:
            pstr += ' ' * tbSpc + '& %s \n' % (descriptorDict['subject to'])

        # first constraint needs different alignment because of the 'subject to':
        # & minimize   & & [Objective]
        # & subject to & & [Constraint 1]
        # &            & & [Constraint 2]
        # &            & & [Constraint N]

        # The double '& &' renders better for some reason

        for i, con in enumerate(constraints):
            if not isSingle:
                if i == 0:
                    algn = '& &'
                else:
                    algn = '&&&'
            else:
                algn = ''

            if not isSingle:
                tail = '\\\\ \n'
            else:
                tail = '\n'

            # grab the constraint and templatize
            try:
                con_template, indices = templatize_fcn(con)
                con_template_list = [con_template]
            except:
                if throw_templatization_error:
                    raise RuntimeError(
                        "A constraint named '%s' has been constructed that cannot be templatized"
                        % (con.__str__())
                    )
                else:
                    con_template_list = [c.expr for c in con.values()]
                    indices = []

            for con_template in con_template_list:
                # Walk the constraint
                conLine = (
                    ' ' * tbSpc
                    + algn
                    + ' %s %s'
                    % (visitor.walk_expression(con_template), trailingAligner)
                )

                # setMap = visitor.setMap
                # Multiple constraints are generated using a set
                if len(indices) > 0:
                    conLine += ' \\qquad \\forall'

                    _bygroups = {}
                    for idx in indices:
                        _bygroups.setdefault(idx._group, []).append(idx)
                    for _group, idxs in _bygroups.items():
                        if idxs[0]._set in visitor.setMap:
                            # already detected set, do nothing
                            pass
                        else:
                            visitor.setMap[idxs[0]._set] = 'SET%d' % (
                                len(visitor.setMap) + 1
                            )

                        idxTag = ','.join(
                            '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__'
                            % (idx._group, idx._id, visitor.setMap[idx._set])
                            for idx in idxs
                        )

                        setTag = '__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % (
                            indices[0]._group,
                            ','.join(str(it._id) for it in idxs),
                            visitor.setMap[indices[0]._set],
                        )

                        conLine += ' %s \\in %s ' % (idxTag, setTag)
                pstr += conLine

                # Add labels as needed
                if not use_equation_environment:
                    pstr += (
                        '\\label{con:' + pyomo_component.name + '_' + con.name + '} '
                    )

                pstr += tail

    # Print bounds and sets
    if not isSingle:
        varBoundData = []
        for i in range(0, len(variableList)):
            vr = variableList[i]
            if isinstance(vr, ScalarVar):
                varBoundDataEntry = analyze_variable(vr)
                varBoundData.append(varBoundDataEntry)
            elif isinstance(vr, IndexedVar):
                varBoundData_indexedVar = []
                setData = vr.index_set().data()
                for sd in setData:
                    varBoundDataEntry = analyze_variable(vr[sd])
                    varBoundData_indexedVar.append(varBoundDataEntry)
                globIndexedVariables = True
                for j in range(0, len(varBoundData_indexedVar) - 1):
                    chks = []
                    chks.append(
                        varBoundData_indexedVar[j]['lowerBound']
                        == varBoundData_indexedVar[j + 1]['lowerBound']
                    )
                    chks.append(
                        varBoundData_indexedVar[j]['upperBound']
                        == varBoundData_indexedVar[j + 1]['upperBound']
                    )
                    chks.append(
                        varBoundData_indexedVar[j]['domainName']
                        == varBoundData_indexedVar[j + 1]['domainName']
                    )
                    if not all(chks):
                        globIndexedVariables = False
                        break
                if globIndexedVariables:
                    varBoundData.append(
                        {
                            'variable': vr,
                            'lowerBound': varBoundData_indexedVar[0]['lowerBound'],
                            'upperBound': varBoundData_indexedVar[0]['upperBound'],
                            'domainName': varBoundData_indexedVar[0]['domainName'],
                            'domainLatex': varBoundData_indexedVar[0]['domainLatex'],
                        }
                    )
                else:
                    varBoundData += varBoundData_indexedVar
            else:
                raise DeveloperError(
                    'Variable is not a variable.  Should not happen.  Contact developers'
                )

        # print the accumulated data to the string
        bstr = ''
        appendBoundString = False
        useThreeAlgn = False
        for i, vbd in enumerate(varBoundData):
            if (
                vbd['lowerBound'] == ''
                and vbd['upperBound'] == ''
                and vbd['domainName'] == 'Reals'
            ):
                # unbounded all real, do not print
                if i == len(varBoundData) - 1:
                    bstr = bstr[0:-4]
            else:
                if not useThreeAlgn:
                    algn = '& &'
                    useThreeAlgn = True
                else:
                    algn = '&&&'

                if use_equation_environment:
                    conLabel = ''
                else:
                    conLabel = (
                        ' \\label{con:'
                        + pyomo_component.name
                        + '_'
                        + variableMap[vbd['variable']]
                        + '_bound'
                        + '} '
                    )

                appendBoundString = True
                coreString = (
                    vbd['lowerBound']
                    + variableMap[vbd['variable']]
                    + vbd['upperBound']
                    + ' '
                    + trailingAligner
                    + '\\qquad \\in '
                    + vbd['domainLatex']
                    + conLabel
                )
                bstr += ' ' * tbSpc + algn + ' %s' % (coreString)
                if i <= len(varBoundData) - 2:
                    bstr += '\\\\ \n'
                else:
                    bstr += '\n'

        if appendBoundString:
            pstr += ' ' * tbSpc + '& %s \n' % (descriptorDict['with bounds'])
            pstr += bstr + '\n'
        else:
            pstr = pstr[0:-4] + '\n'

    # close off the print string
    if not use_equation_environment:
        pstr += '\\end{align} \n'
    else:
        if not isSingle:
            pstr += '    \\end{aligned} \n'
            pstr += '    \\label{%s} \n' % (pyomo_component.name)
        pstr += '\\end{equation} \n'

    setMap = visitor.setMap
    setMap_inverse = {vl: ky for ky, vl in setMap.items()}

    def generate_set_name(st, lcm):
        if st in lcm:
            return lcm[st][0]
        if st.parent_block().component(st.name) is st:
            return st.name.replace('_', r'\_')
        if isinstance(st, SetOperator):
            return set_operator_map[st._operator.strip()].join(
                generate_set_name(s, lcm) for s in st.subsets(False)
            )
        else:
            return str(st).replace('_', r'\_').replace('{', r'\{').replace('}', r'\}')

    # Handling the iterator indices
    defaultSetLatexNames = ComponentMap()
    for ky in setMap:
        defaultSetLatexNames[ky] = generate_set_name(ky, latex_component_map)

    latexLines = pstr.split('\n')
    for jj in range(0, len(latexLines)):
        groupMap = {}
        uniqueSets = []
        ln = latexLines[jj]
        # only modify if there is a placeholder in the line
        if "PLACEHOLDER_8675309_GROUP_" in ln:
            splitLatex = ln.split('__')
            # Find the unique combinations of group numbers and set names
            for word in splitLatex:
                if "PLACEHOLDER_8675309_GROUP_" in word:
                    ifo = word.split("PLACEHOLDER_8675309_GROUP_")[1]
                    gpNum, idNum, stName = ifo.split('_')
                    if gpNum not in groupMap:
                        groupMap[gpNum] = [stName]
                    if stName not in ComponentSet(uniqueSets):
                        uniqueSets.append(stName)

            # Determine if the set is continuous
            setInfo = dict(
                zip(
                    uniqueSets,
                    [{'continuous': False} for i in range(0, len(uniqueSets))],
                )
            )

            for ky, vl in setInfo.items():
                ix = int(ky[3:]) - 1
                setInfo[ky]['setObject'] = setMap_inverse[ky]  # setList[ix]
                setInfo[ky]['setRegEx'] = (
                    r'__S_PLACEHOLDER_8675309_GROUP_([0-9]+)_([0-9,]+)_%s__' % (ky,)
                )
                # setInfo[ky]['idxRegEx'] = r'__I_PLACEHOLDER_8675309_GROUP_[0-9*]_%s__'%(ky)

            if explicit_set_summation:
                for ky, vl in setInfo.items():
                    st = vl['setObject']
                    stData = st.data()
                    stCont = True
                    for ii in range(0, len(stData)):
                        if ii + stData[0] != stData[ii]:
                            stCont = False
                            break
                    setInfo[ky]['continuous'] = stCont

            # replace the sets
            for ky, vl in setInfo.items():
                # if the set is continuous and the flag has been set
                if explicit_set_summation and setInfo[ky]['continuous']:
                    st = setInfo[ky]['setObject']
                    stData = st.data()
                    bgn = stData[0]
                    ed = stData[-1]

                    replacement = (
                        r'sum_{ __I_PLACEHOLDER_8675309_GROUP_\1_\2_%s__ = %d }^{%d}'
                        % (ky, bgn, ed)
                    )
                    ln = re.sub(
                        'sum_{' + setInfo[ky]['setRegEx'] + '}', replacement, ln
                    )
                else:
                    # if the set is not continuous or the flag has not been set
                    for _grp, _id in re.findall(
                        'sum_{' + setInfo[ky]['setRegEx'] + '}', ln
                    ):
                        set_placeholder = '__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % (
                            _grp,
                            _id,
                            ky,
                        )
                        i_placeholder = ','.join(
                            '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % (_grp, _, ky)
                            for _ in _id.split(',')
                        )
                        replacement = r'sum_{ %s \in %s  }' % (
                            i_placeholder,
                            set_placeholder,
                        )
                        ln = ln.replace('sum_{' + set_placeholder + '}', replacement)

                replacement = repr(defaultSetLatexNames[setInfo[ky]['setObject']])[1:-1]
                ln = re.sub(setInfo[ky]['setRegEx'], replacement, ln)

            # groupNumbers = re.findall(r'__I_PLACEHOLDER_8675309_GROUP_([0-9*])_SET[0-9]*__',ln)
            setNumbers = re.findall(
                r'__I_PLACEHOLDER_8675309_GROUP_[0-9]+_[0-9]+_SET([0-9]+)__', ln
            )
            groupIdSetTuples = re.findall(
                r'__I_PLACEHOLDER_8675309_GROUP_([0-9]+)_([0-9]+)_SET([0-9]+)__', ln
            )

            groupInfo = {}
            for vl in setNumbers:
                groupInfo['SET' + vl] = {
                    'setObject': setInfo['SET' + vl]['setObject'],
                    'indices': [],
                }

            for _gp, _id, _set in groupIdSetTuples:
                if (_gp, _id) not in groupInfo['SET' + _set]['indices']:
                    groupInfo['SET' + _set]['indices'].append((_gp, _id))

            def get_index_names(st, lcm):
                if st in lcm:
                    return lcm[st][1]
                elif isinstance(st, SetOperator):
                    return sum(
                        (get_index_names(s, lcm) for s in st.subsets(False)), start=[]
                    )
                elif st.dimen is not None:
                    return [None] * st.dimen
                else:
                    return [Ellipsis]

            indexCounter = 0
            for ky, vl in groupInfo.items():
                indexNames = get_index_names(vl['setObject'], latex_component_map)
                nonNone = list(filter(None, indexNames))
                if nonNone:
                    if len(nonNone) < len(vl['indices']):
                        raise ValueError(
                            'Insufficient number of indices provided to the '
                            'overwrite dictionary for set %s (expected %s, but got %s)'
                            % (vl['setObject'].name, len(vl['indices']), indexNames)
                        )
                else:
                    indexNames = []
                    for i in vl['indices']:
                        indexNames.append(alphabetStringGenerator(indexCounter))
                        indexCounter += 1
                for i in range(0, len(vl['indices'])):
                    ln = ln.replace(
                        '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__'
                        % (*vl['indices'][i], ky),
                        indexNames[i],
                    )
        latexLines[jj] = ln

    pstr = '\n'.join(latexLines)

    new_variableMap = ComponentMap()
    for i, vr in enumerate(variableList):
        if isinstance(vr, ScalarVar):
            new_variableMap[vr] = vr.name
        elif isinstance(vr, IndexedVar):
            new_variableMap[vr] = vr.name
            for sd in vr.index_set().data():
                sdString = str(sd)
                if sdString[0] == '(':
                    sdString = sdString[1:]
                if sdString[-1] == ')':
                    sdString = sdString[0:-1]
                new_variableMap[vr[sd]] = vr[sd].name
        else:
            raise DeveloperError(
                'Variable is not a variable.  Should not happen.  Contact developers'
            )

    new_parameterMap = ComponentMap()
    for i, pm in enumerate(parameterList):
        pm = parameterList[i]
        if isinstance(pm, ScalarParam):
            new_parameterMap[pm] = pm.name
        elif isinstance(pm, IndexedParam):
            new_parameterMap[pm] = pm.name
            for sd in pm.index_set().data():
                sdString = str(sd)
                if sdString[0] == '(':
                    sdString = sdString[1:]
                if sdString[-1] == ')':
                    sdString = sdString[0:-1]
                new_parameterMap[pm[sd]] = str(pm[sd])  # .name
        else:
            raise DeveloperError(
                'Parameter is not a parameter.  Should not happen.  Contact developers'
            )

    for ky, vl in new_variableMap.items():
        if ky not in latex_component_map:
            latex_component_map[ky] = vl
    for ky, vl in new_parameterMap.items():
        if ky not in latex_component_map:
            latex_component_map[ky] = vl

    rep_dict = {}
    for ky in reversed(list(latex_component_map)):
        if isinstance(ky, (pyo.Var, VarData)):
            overwrite_value = latex_component_map[ky]
            if ky not in existing_components:
                overwrite_value = overwrite_value.replace('_', '\\_')
            rep_dict[variableMap[ky]] = overwrite_value
        elif isinstance(ky, (pyo.Param, ParamData)):
            overwrite_value = latex_component_map[ky]
            if ky not in existing_components:
                overwrite_value = overwrite_value.replace('_', '\\_')
            rep_dict[parameterMap[ky]] = overwrite_value
        elif isinstance(ky, SetData):
            # already handled
            pass
        elif isinstance(ky, (float, int)):
            # happens when immutable parameters are used, do nothing
            pass
        else:
            raise ValueError(
                'The latex_component_map object has a key of invalid type: %s'
                % (str(ky))
            )

    label_rep_dict = copy.deepcopy(rep_dict)
    for ky, vl in label_rep_dict.items():
        label_rep_dict[ky] = vl.replace('{', '').replace('}', '').replace('\\', '')

    splitLines = pstr.split('\n')
    for i in range(0, len(splitLines)):
        if use_equation_environment:
            splitLines[i] = multiple_replace(splitLines[i], rep_dict)
        else:
            if '\\label{' in splitLines[i]:
                epr, lbl = splitLines[i].split('\\label{')
                epr = multiple_replace(epr, rep_dict)
                # rep_dict[ky] = vl.replace('_', '\\_')
                lbl = multiple_replace(lbl, label_rep_dict)
                splitLines[i] = epr + '\\label{' + lbl

    pstr = '\n'.join(splitLines)

    pattern = r'_{([^{]*)}_{([^{]*)}'
    replacement = r'_{\1_{\2}}'
    pstr = re.sub(pattern, replacement, pstr)

    pattern = r'_(.)_{([^}]*)}'
    replacement = r'_{\1_{\2}}'
    pstr = re.sub(pattern, replacement, pstr)

    splitLines = pstr.split('\n')
    finalLines = []
    for sl in splitLines:
        if sl != '':
            finalLines.append(sl)

    pstr = '\n'.join(finalLines)

    if ostream is not None:
        fstr = ''
        fstr += '\\documentclass{article} \n'
        fstr += '\\usepackage{amsmath} \n'
        fstr += '\\usepackage{amssymb} \n'
        fstr += '\\usepackage{dsfont} \n'
        fstr += '\\usepackage[paperheight=11in, paperwidth=8.5in, left=1in, right=1in, top=1in, bottom=1in]{geometry} \n'
        fstr += '\\allowdisplaybreaks \n'
        fstr += '\\begin{document} \n'
        fstr += '\\normalsize \n'
        fstr += pstr + '\n'
        fstr += '\\end{document} \n'

        # optional write to output file
        if isinstance(ostream, (io.TextIOWrapper, io.StringIO)):
            ostream.write(fstr)
        elif isinstance(ostream, str):
            f = open(ostream, 'w')
            f.write(fstr)
            f.close()
        else:
            raise ValueError(
                'Invalid type %s encountered when parsing the ostream.  Must be a StringIO, FileIO, or valid filename string'
            )

    # return the latex string
    return pstr
