#  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 dataclasses import dataclass
from typing import List

import gurobipy as gp
import numpy as np
import pyomo.environ as pe
from gurobipy.gurobipy import GRB
from scipy.stats import uniform, randint
from scipy.stats.distributions import rv_frozen

from miplearn.solvers.gurobi import GurobiModel
from miplearn.solvers.pyomo import PyomoModel


@dataclass
class SetCoverData:
    costs: np.ndarray
    incidence_matrix: np.ndarray


class SetCoverGenerator:
    def __init__(
        self,
        n_elements: rv_frozen = randint(low=50, high=51),
        n_sets: rv_frozen = randint(low=100, high=101),
        costs: rv_frozen = uniform(loc=0.0, scale=100.0),
        costs_jitter: rv_frozen = uniform(loc=-5.0, scale=10.0),
        K: rv_frozen = uniform(loc=25.0, scale=0.0),
        density: rv_frozen = uniform(loc=0.02, scale=0.00),
        fix_sets: bool = True,
    ):
        self.n_elements = n_elements
        self.n_sets = n_sets
        self.costs = costs
        self.costs_jitter = costs_jitter
        self.density = density
        self.K = K
        self.fix_sets = fix_sets
        self.fixed_costs = None
        self.fixed_matrix = None

    def generate(self, n_samples: int) -> List[SetCoverData]:
        def _sample() -> SetCoverData:
            if self.fixed_matrix is None:
                n_sets = self.n_sets.rvs()
                n_elements = self.n_elements.rvs()
                density = self.density.rvs()

                incidence_matrix = np.random.rand(n_elements, n_sets) < density
                incidence_matrix = incidence_matrix.astype(int)

                # Ensure each element belongs to at least one set
                for j in range(n_elements):
                    if incidence_matrix[j, :].sum() == 0:
                        incidence_matrix[j, randint(low=0, high=n_sets).rvs()] = 1

                # Ensure each set contains at least one element
                for i in range(n_sets):
                    if incidence_matrix[:, i].sum() == 0:
                        incidence_matrix[randint(low=0, high=n_elements).rvs(), i] = 1

                costs = self.costs.rvs(n_sets) + self.K.rvs() * incidence_matrix.sum(
                    axis=0
                )
                if self.fix_sets:
                    self.fixed_matrix = incidence_matrix
                    self.fixed_costs = costs
            else:
                incidence_matrix = self.fixed_matrix
                (_, n_sets) = incidence_matrix.shape
                costs = self.fixed_costs * self.costs_jitter.rvs(n_sets)
            return SetCoverData(
                costs=costs.round(2),
                incidence_matrix=incidence_matrix,
            )

        return [_sample() for _ in range(n_samples)]


def build_setcover_model_gurobipy(data: SetCoverData) -> GurobiModel:
    (n_elements, n_sets) = data.incidence_matrix.shape
    model = gp.Model()
    x = model.addMVar(n_sets, vtype=GRB.BINARY, name="x")
    model.addConstr(data.incidence_matrix @ x >= np.ones(n_elements), name="eqs")
    model.setObjective(data.costs @ x)
    model.update()
    return GurobiModel(model)


def build_setcover_model_pyomo(
    data: SetCoverData,
    solver="gurobi_persistent",
) -> PyomoModel:
    (n_elements, n_sets) = data.incidence_matrix.shape
    model = pe.ConcreteModel()
    model.sets = pe.Set(initialize=range(n_sets))
    model.x = pe.Var(model.sets, domain=pe.Boolean, name="x")
    model.eqs = pe.Constraint(pe.Any)
    for i in range(n_elements):
        model.eqs[i] = (
            sum(data.incidence_matrix[i, j] * model.x[j] for j in range(n_sets)) >= 1
        )
    model.obj = pe.Objective(
        expr=sum(data.costs[j] * model.x[j] for j in range(n_sets))
    )
    return PyomoModel(model, solver)
