#  MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
#  Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
#  Released under the modified BSD license. See COPYING.md for more details.

from io import StringIO
from typing import Callable

import gurobipy as gp
from gurobipy import GRB, LinExpr

from ..h5 import H5File
from ..io import _RedirectOutput, load

import numpy as np


class LazyCollector:
    def __init__(
        self,
        min_constrs: int = 100_000,
        time_limit: float = 900,
    ) -> None:
        self.min_constrs = min_constrs
        self.time_limit = time_limit

    def collect(self, data: str, build_model: Callable, tol: float = 1e-6) -> None:
        h5_filename = data.replace(".pkl.gz", ".h5")
        with H5File(h5_filename, "r+") as h5:
            streams = [StringIO()]
            lazy = None
            with _RedirectOutput(streams):
                slacks = h5.get_array("mip_constr_slacks")
                assert slacks is not None

                # Check minimum problem size
                if len(slacks) < self.min_constrs:
                    print("Problem is too small. Skipping.")
                    h5.put_array("mip_constr_lazy", np.zeros(len(slacks)))
                    return

                # Load model
                print("Loading model...")
                model = load(data, build_model)
                model.params.LazyConstraints = True
                model.params.timeLimit = self.time_limit
                gp_constrs = np.array(model.getConstrs())
                gp_vars = np.array(model.getVars())

                # Load constraints
                lhs = h5.get_sparse("static_constr_lhs")
                rhs = h5.get_array("static_constr_rhs")
                sense = h5.get_array("static_constr_sense")
                assert lhs is not None
                assert rhs is not None
                assert sense is not None
                lhs_csr = lhs.tocsr()
                lhs_csc = lhs.tocsc()
                constr_idx = np.array(range(len(rhs)))
                lazy = np.zeros(len(rhs))

                # Drop loose constraints
                selected = (slacks > 0) & ((sense == b"<") | (sense == b">"))
                loose_constrs = gp_constrs[selected]
                print(
                    f"Removing {len(loose_constrs):,d} constraints (out of {len(rhs):,d})..."
                )
                model.remove(list(loose_constrs))

                # Filter to constraints that were dropped
                lhs_csr = lhs_csr[selected, :]
                lhs_csc = lhs_csc[selected, :]
                rhs = rhs[selected]
                sense = sense[selected]
                constr_idx = constr_idx[selected]
                lazy[selected] = 1

                # Load warm start
                var_names = h5.get_array("static_var_names")
                var_values = h5.get_array("mip_var_values")
                assert var_values is not None
                assert var_names is not None
                for (var_idx, var_name) in enumerate(var_names):
                    var = model.getVarByName(var_name.decode())
                    var.start = var_values[var_idx]

                print("Solving MIP with lazy constraints callback...")

                def callback(model: gp.Model, where: int) -> None:
                    assert rhs is not None
                    assert lazy is not None
                    assert sense is not None

                    if where == GRB.Callback.MIPSOL:
                        x_val = np.array(model.cbGetSolution(model.getVars()))
                        slack = lhs_csc * x_val - rhs
                        slack[sense == b">"] *= -1
                        is_violated = slack > tol

                        for (j, rhs_j) in enumerate(rhs):
                            if is_violated[j]:
                                lazy[constr_idx[j]] = 0
                                expr = LinExpr(
                                    lhs_csr[j, :].data, gp_vars[lhs_csr[j, :].indices]
                                )
                                if sense[j] == b"<":
                                    model.cbLazy(expr <= rhs_j)
                                elif sense[j] == b">":
                                    model.cbLazy(expr >= rhs_j)
                                else:
                                    raise RuntimeError(f"Unknown sense: {sense[j]}")

                model.optimize(callback)
                print(f"Marking {lazy.sum():,.0f} constraints as lazy...")

            h5.put_array("mip_constr_lazy", lazy)
            h5.put_scalar("mip_constr_lazy_log", streams[0].getvalue())
