#!/usr/bin/env python3
# Copyright 2010-2025 Google LLC
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Statistics about MIP/LP models."""

import dataclasses
import io
import math
from typing import Iterable, Optional

from ortools.math_opt.python import model


@dataclasses.dataclass(frozen=True)
class Range:
    """A close range of values [min, max].

    Attributes:
      minimum: The minimum value.
      maximum: The maximum value.
    """

    minimum: float
    maximum: float


def merge_optional_ranges(
    lhs: Optional[Range], rhs: Optional[Range]
) -> Optional[Range]:
    """Merges the two optional ranges.

    Args:
      lhs: The left hand side range.
      rhs: The right hand side range.

    Returns:
      A merged range (None if both lhs and rhs are None).
    """
    if lhs is None:
        return rhs
    if rhs is None:
        return lhs
    return Range(
        minimum=min(lhs.minimum, rhs.minimum),
        maximum=max(lhs.maximum, rhs.maximum),
    )


def absolute_finite_non_zeros_range(values: Iterable[float]) -> Optional[Range]:
    """Returns the range of the absolute values of the finite non-zeros.

    Args:
      values: An iterable object of float values.

    Returns:
      The range of the absolute values of the finite non-zeros, None if no such
      value is found.
    """
    minimum: Optional[float] = None
    maximum: Optional[float] = None
    for v in values:
        v = abs(v)
        if math.isinf(v) or v == 0.0:
            continue
        if minimum is None:
            minimum = v
            maximum = v
        else:
            minimum = min(minimum, v)
            maximum = max(maximum, v)

    assert (maximum is None) == (minimum is None), (minimum, maximum)

    if minimum is None:
        return None
    return Range(minimum=minimum, maximum=maximum)


@dataclasses.dataclass(frozen=True)
class ModelRanges:
    """The ranges of the absolute values of the finite non-zero values in the model.

    Each range is optional since there may be no finite non-zero values
    (e.g. empty model, empty objective, all variables unbounded, ...).

    Attributes:
      objective_terms: The linear and quadratic objective terms (not including the
        offset).
      variable_bounds: The variables' lower and upper bounds.
      linear_constraint_bounds: The linear constraints' lower and upper bounds.
      linear_constraint_coefficients: The coefficients of the variables in linear
        constraints.
    """

    objective_terms: Optional[Range]
    variable_bounds: Optional[Range]
    linear_constraint_bounds: Optional[Range]
    linear_constraint_coefficients: Optional[Range]

    def __str__(self) -> str:
        """Prints the ranges in scientific format with 2 digits (i.e.

        f'{x:.2e}').

        It returns a multi-line table list of ranges. The last line does NOT end
        with a new line.

        Returns:
          The ranges in multiline string.
        """
        buf = io.StringIO()

        def print_range(prefix: str, value: Optional[Range]) -> None:
            buf.write(prefix)
            if value is None:
                buf.write("no finite values")
                return
            # Numbers are printed in scientific notation with a precision of 2. Since
            # they are expected to be positive we can ignore the optional leading
            # minus sign. We thus expects `d.dde[+-]dd(d)?` (the exponent is at least
            # 2 digits but double can require 3 digits, with max +308 and min
            # -308). Thus we can use a width of 9 to align the ranges properly.
            buf.write(f"[{value.minimum:<9.2e}, {value.maximum:<9.2e}]")

        print_range("Objective terms           : ", self.objective_terms)
        print_range("\nVariable bounds           : ", self.variable_bounds)
        print_range("\nLinear constraints bounds : ", self.linear_constraint_bounds)
        print_range(
            "\nLinear constraints coeffs : ", self.linear_constraint_coefficients
        )
        return buf.getvalue()


def compute_model_ranges(mdl: model.Model) -> ModelRanges:
    """Returns the ranges of the finite non-zero values in the given model.

    Args:
      mdl: The input model.

    Returns:
      The ranges of the finite non-zero values in the model.
    """
    return ModelRanges(
        objective_terms=absolute_finite_non_zeros_range(
            term.coefficient for term in mdl.objective.linear_terms()
        ),
        variable_bounds=merge_optional_ranges(
            absolute_finite_non_zeros_range(v.lower_bound for v in mdl.variables()),
            absolute_finite_non_zeros_range(v.upper_bound for v in mdl.variables()),
        ),
        linear_constraint_bounds=merge_optional_ranges(
            absolute_finite_non_zeros_range(
                c.lower_bound for c in mdl.linear_constraints()
            ),
            absolute_finite_non_zeros_range(
                c.upper_bound for c in mdl.linear_constraints()
            ),
        ),
        linear_constraint_coefficients=absolute_finite_non_zeros_range(
            e.coefficient for e in mdl.linear_constraint_matrix_entries()
        ),
    )
