#!/usr/bin/env python3
# Copyright 2010-2021 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.
"""Solves the Stell Mill Slab problem with 4 different techniques."""

# overloaded sum() clashes with pytype.
# pytype: disable=wrong-arg-types

import collections
import time

from absl import app
from absl import flags
from ortools.linear_solver import pywraplp
from ortools.sat.python import cp_model

FLAGS = flags.FLAGS

flags.DEFINE_integer('problem', 2, 'Problem id to solve.')
flags.DEFINE_bool('break_symmetries', True,
                     'Break symmetries between equivalent orders.')
flags.DEFINE_string(
    'solver', 'mip_column', 'Method used to solve: sat, sat_table, sat_column, '
    'mip_column.')


def build_problem(problem_id):
    """Build problem data."""
    if problem_id == 0:
        capacities = [
            0, 12, 14, 17, 18, 19, 20, 23, 24, 25, 26, 27, 28, 29, 30, 32, 35,
            39, 42, 43, 44
        ]
        num_colors = 88
        num_slabs = 111
        orders = [
            (4, 1),  # (size, color)
            (22, 2),
            (9, 3),
            (5, 4),
            (8, 5),
            (3, 6),
            (3, 4),
            (4, 7),
            (7, 4),
            (7, 8),
            (3, 6),
            (2, 6),
            (2, 4),
            (8, 9),
            (5, 10),
            (7, 11),
            (4, 7),
            (7, 11),
            (5, 10),
            (7, 11),
            (8, 9),
            (3, 1),
            (25, 12),
            (14, 13),
            (3, 6),
            (22, 14),
            (19, 15),
            (19, 15),
            (22, 16),
            (22, 17),
            (22, 18),
            (20, 19),
            (22, 20),
            (5, 21),
            (4, 22),
            (10, 23),
            (26, 24),
            (17, 25),
            (20, 26),
            (16, 27),
            (10, 28),
            (19, 29),
            (10, 30),
            (10, 31),
            (23, 32),
            (22, 33),
            (26, 34),
            (27, 35),
            (22, 36),
            (27, 37),
            (22, 38),
            (22, 39),
            (13, 40),
            (14, 41),
            (16, 27),
            (26, 34),
            (26, 42),
            (27, 35),
            (22, 36),
            (20, 43),
            (26, 24),
            (22, 44),
            (13, 45),
            (19, 46),
            (20, 47),
            (16, 48),
            (15, 49),
            (17, 50),
            (10, 28),
            (20, 51),
            (5, 52),
            (26, 24),
            (19, 53),
            (15, 54),
            (10, 55),
            (10, 56),
            (13, 57),
            (13, 58),
            (13, 59),
            (12, 60),
            (12, 61),
            (18, 62),
            (10, 63),
            (18, 64),
            (16, 65),
            (20, 66),
            (12, 67),
            (6, 68),
            (6, 68),
            (15, 69),
            (15, 70),
            (15, 70),
            (21, 71),
            (30, 72),
            (30, 73),
            (30, 74),
            (30, 75),
            (23, 76),
            (15, 77),
            (15, 78),
            (27, 79),
            (27, 80),
            (27, 81),
            (27, 82),
            (27, 83),
            (27, 84),
            (27, 79),
            (27, 85),
            (27, 86),
            (10, 87),
            (3, 88)
        ]
    elif problem_id == 1:
        capacities = [0, 17, 44]
        num_colors = 23
        num_slabs = 30
        orders = [
            (4, 1),  # (size, color)
            (22, 2),
            (9, 3),
            (5, 4),
            (8, 5),
            (3, 6),
            (3, 4),
            (4, 7),
            (7, 4),
            (7, 8),
            (3, 6),
            (2, 6),
            (2, 4),
            (8, 9),
            (5, 10),
            (7, 11),
            (4, 7),
            (7, 11),
            (5, 10),
            (7, 11),
            (8, 9),
            (3, 1),
            (25, 12),
            (14, 13),
            (3, 6),
            (22, 14),
            (19, 15),
            (19, 15),
            (22, 16),
            (22, 17),
            (22, 18),
            (20, 19),
            (22, 20),
            (5, 21),
            (4, 22),
            (10, 23)
        ]
    elif problem_id == 2:
        capacities = [0, 17, 44]
        num_colors = 15
        num_slabs = 20
        orders = [
            (4, 1),  # (size, color)
            (22, 2),
            (9, 3),
            (5, 4),
            (8, 5),
            (3, 6),
            (3, 4),
            (4, 7),
            (7, 4),
            (7, 8),
            (3, 6),
            (2, 6),
            (2, 4),
            (8, 9),
            (5, 10),
            (7, 11),
            (4, 7),
            (7, 11),
            (5, 10),
            (7, 11),
            (8, 9),
            (3, 1),
            (25, 12),
            (14, 13),
            (3, 6),
            (22, 14),
            (19, 15),
            (19, 15)
        ]

    elif problem_id == 3:
        capacities = [0, 17, 44]
        num_colors = 8
        num_slabs = 10
        orders = [
            (4, 1),  # (size, color)
            (22, 2),
            (9, 3),
            (5, 4),
            (8, 5),
            (3, 6),
            (3, 4),
            (4, 7),
            (7, 4),
            (7, 8),
            (3, 6)
        ]

    return (num_slabs, capacities, num_colors, orders)


class SteelMillSlabSolutionPrinter(cp_model.CpSolverSolutionCallback):
    """Print intermediate solutions."""

    def __init__(self, orders, assign, load, loss):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.__orders = orders
        self.__assign = assign
        self.__load = load
        self.__loss = loss
        self.__solution_count = 0
        self.__all_orders = range(len(orders))
        self.__all_slabs = range(len(assign[0]))
        self.__start_time = time.time()

    def on_solution_callback(self):
        """Called on each new solution."""
        current_time = time.time()
        objective = sum(self.Value(l) for l in self.__loss)
        print('Solution %i, time = %f s, objective = %i' %
              (self.__solution_count, current_time - self.__start_time,
               objective))
        self.__solution_count += 1
        orders_in_slab = [[
            o for o in self.__all_orders if self.Value(self.__assign[o][s])
        ] for s in self.__all_slabs]
        for s in self.__all_slabs:
            if orders_in_slab[s]:
                line = '  - slab %i, load = %i, loss = %i, orders = [' % (
                    s, self.Value(self.__load[s]), self.Value(self.__loss[s]))
                for o in orders_in_slab[s]:
                    line += '#%i(w%i, c%i) ' % (o, self.__orders[o][0],
                                                self.__orders[o][1])
                line += ']'
                print(line)


def steel_mill_slab(problem, break_symmetries):
    """Solves the Steel Mill Slab Problem."""
    ### Load problem.
    (num_slabs, capacities, num_colors, orders) = build_problem(problem)

    num_orders = len(orders)
    num_capacities = len(capacities)
    all_slabs = range(num_slabs)
    all_colors = range(num_colors)
    all_orders = range(len(orders))
    print('Solving steel mill with %i orders, %i slabs, and %i capacities' %
          (num_orders, num_slabs, num_capacities - 1))

    # Compute auxiliary data.
    widths = [x[0] for x in orders]
    colors = [x[1] for x in orders]
    max_capacity = max(capacities)
    loss_array = [
        min(x for x in capacities if x >= c) - c for c in range(max_capacity +
                                                                1)
    ]
    max_loss = max(loss_array)
    orders_per_color = [
        [o for o in all_orders if colors[o] == c + 1] for c in all_colors
    ]
    unique_color_orders = [
        o for o in all_orders if len(orders_per_color[colors[o] - 1]) == 1
    ]

    ### Model problem.

    # Create the model and the decision variables.
    model = cp_model.CpModel()
    assign = [[
        model.NewBoolVar('assign_%i_to_slab_%i' % (o, s)) for s in all_slabs
    ] for o in all_orders]
    loads = [
        model.NewIntVar(0, max_capacity, 'load_of_slab_%i' % s)
        for s in all_slabs
    ]
    color_is_in_slab = [[
        model.NewBoolVar('color_%i_in_slab_%i' % (c + 1, s)) for c in all_colors
    ] for s in all_slabs]

    # Compute load of all slabs.
    for s in all_slabs:
        model.Add(sum(assign[o][s] * widths[o] for o in all_orders) == loads[s])

    # Orders are assigned to one slab.
    for o in all_orders:
        model.AddExactlyOne(assign[o])

    # Redundant constraint (sum of loads == sum of widths).
    model.Add(sum(loads) == sum(widths))

    # Link present_colors and assign.
    for c in all_colors:
        for s in all_slabs:
            for o in orders_per_color[c]:
                model.AddImplication(assign[o][s], color_is_in_slab[s][c])
                model.AddImplication(color_is_in_slab[s][c].Not(),
                                     assign[o][s].Not())

    # At most two colors per slab.
    for s in all_slabs:
        model.Add(sum(color_is_in_slab[s]) <= 2)

    # Project previous constraint on unique_color_orders
    for s in all_slabs:
        model.Add(sum(assign[o][s] for o in unique_color_orders) <= 2)

    # Symmetry breaking.
    for s in range(num_slabs - 1):
        model.Add(loads[s] >= loads[s + 1])

    # Collect equivalent orders.
    width_to_unique_color_order = {}
    ordered_equivalent_orders = []
    for c in all_colors:
        colored_orders = orders_per_color[c]
        if not colored_orders:
            continue
        if len(colored_orders) == 1:
            o = colored_orders[0]
            w = widths[o]
            if w not in width_to_unique_color_order:
                width_to_unique_color_order[w] = [o]
            else:
                width_to_unique_color_order[w].append(o)
        else:
            local_width_to_order = {}
            for o in colored_orders:
                w = widths[o]
                if w not in local_width_to_order:
                    local_width_to_order[w] = []
                local_width_to_order[w].append(o)
            for w, os in local_width_to_order.items():
                if len(os) > 1:
                    for p in range(len(os) - 1):
                        ordered_equivalent_orders.append((os[p], os[p + 1]))
    for w, os in width_to_unique_color_order.items():
        if len(os) > 1:
            for p in range(len(os) - 1):
                ordered_equivalent_orders.append((os[p], os[p + 1]))

    # Create position variables if there are symmetries to be broken.
    if break_symmetries and ordered_equivalent_orders:
        print('  - creating %i symmetry breaking constraints' %
              len(ordered_equivalent_orders))
        positions = {}
        for p in ordered_equivalent_orders:
            if p[0] not in positions:
                positions[p[0]] = model.NewIntVar(0, num_slabs - 1,
                                                  'position_of_slab_%i' % p[0])
                model.AddMapDomain(positions[p[0]], assign[p[0]])
            if p[1] not in positions:
                positions[p[1]] = model.NewIntVar(0, num_slabs - 1,
                                                  'position_of_slab_%i' % p[1])
                model.AddMapDomain(positions[p[1]], assign[p[1]])
            # Finally add the symmetry breaking constraint.
            model.Add(positions[p[0]] <= positions[p[1]])

    # Objective.
    obj = model.NewIntVar(0, num_slabs * max_loss, 'obj')
    losses = [model.NewIntVar(0, max_loss, 'loss_%i' % s) for s in all_slabs]
    for s in all_slabs:
        model.AddElement(loads[s], loss_array, losses[s])
    model.Add(obj == sum(losses))
    model.Minimize(obj)

    ### Solve model.
    solver = cp_model.CpSolver()
    solver.parameters.num_search_workers = 8
    objective_printer = cp_model.ObjectiveSolutionPrinter()
    status = solver.Solve(model, objective_printer)

    ### Output the solution.
    if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        print(
            'Loss = %i, time = %f s, %i conflicts' %
            (solver.ObjectiveValue(), solver.WallTime(), solver.NumConflicts()))
    else:
        print('No solution')


def collect_valid_slabs_dp(capacities, colors, widths, loss_array):
    """Collect valid columns (assign, loss) for one slab."""
    start_time = time.time()

    max_capacity = max(capacities)

    valid_assignment = collections.namedtuple('valid_assignment',
                                              'orders load colors')
    all_valid_assignments = [valid_assignment(orders=[], load=0, colors=[])]

    for order_id in range(len(colors)):
        new_width = widths[order_id]
        new_color = colors[order_id]
        new_assignments = []
        for assignment in all_valid_assignments:
            if assignment.load + new_width > max_capacity:
                continue
            new_colors = list(assignment.colors)
            if new_color not in new_colors:
                new_colors.append(new_color)
            if len(new_colors) > 2:
                continue
            new_assignment = valid_assignment(orders=assignment.orders +
                                              [order_id],
                                              load=assignment.load + new_width,
                                              colors=new_colors)
            new_assignments.append(new_assignment)
        all_valid_assignments.extend(new_assignments)

    print('%i assignments created in %.2f s' %
          (len(all_valid_assignments), time.time() - start_time))
    tuples = []
    for assignment in all_valid_assignments:
        solution = [0 for _ in range(len(colors))]
        for i in assignment.orders:
            solution[i] = 1
        solution.append(loss_array[assignment.load])
        solution.append(assignment.load)
        tuples.append(solution)

    return tuples


def steel_mill_slab_with_valid_slabs(problem, break_symmetries):
    """Solves the Steel Mill Slab Problem."""
    ### Load problem.
    (num_slabs, capacities, num_colors, orders) = build_problem(problem)

    num_orders = len(orders)
    num_capacities = len(capacities)
    all_slabs = range(num_slabs)
    all_colors = range(num_colors)
    all_orders = range(len(orders))
    print('Solving steel mill with %i orders, %i slabs, and %i capacities' %
          (num_orders, num_slabs, num_capacities - 1))

    # Compute auxiliary data.
    widths = [x[0] for x in orders]
    colors = [x[1] for x in orders]
    max_capacity = max(capacities)
    loss_array = [
        min(x for x in capacities if x >= c) - c for c in range(max_capacity +
                                                                1)
    ]
    max_loss = max(loss_array)

    ### Model problem.

    # Create the model and the decision variables.
    model = cp_model.CpModel()
    assign = [[
        model.NewBoolVar('assign_%i_to_slab_%i' % (o, s)) for s in all_slabs
    ] for o in all_orders]
    loads = [model.NewIntVar(0, max_capacity, 'load_%i' % s) for s in all_slabs]
    losses = [model.NewIntVar(0, max_loss, 'loss_%i' % s) for s in all_slabs]

    unsorted_valid_slabs = collect_valid_slabs_dp(capacities, colors, widths,
                                                  loss_array)
    # Sort slab by descending load/loss. Remove duplicates.
    valid_slabs = sorted(unsorted_valid_slabs,
                         key=lambda c: 1000 * c[-1] + c[-2])

    for s in all_slabs:
        model.AddAllowedAssignments([assign[o][s] for o in all_orders] +
                                    [losses[s], loads[s]], valid_slabs)

    # Orders are assigned to one slab.
    for o in all_orders:
        model.AddExactlyOne(assign[o])

    # Redundant constraint (sum of loads == sum of widths).
    model.Add(sum(loads) == sum(widths))

    # Symmetry breaking.
    for s in range(num_slabs - 1):
        model.Add(loads[s] >= loads[s + 1])

    # Collect equivalent orders.
    if break_symmetries:
        print('Breaking symmetries')
        width_to_unique_color_order = {}
        ordered_equivalent_orders = []
        orders_per_color = [
            [o for o in all_orders if colors[o] == c + 1] for c in all_colors
        ]
        for c in all_colors:
            colored_orders = orders_per_color[c]
            if not colored_orders:
                continue
            if len(colored_orders) == 1:
                o = colored_orders[0]
                w = widths[o]
                if w not in width_to_unique_color_order:
                    width_to_unique_color_order[w] = [o]
                else:
                    width_to_unique_color_order[w].append(o)
            else:
                local_width_to_order = {}
                for o in colored_orders:
                    w = widths[o]
                    if w not in local_width_to_order:
                        local_width_to_order[w] = []
                        local_width_to_order[w].append(o)
                for w, os in local_width_to_order.items():
                    if len(os) > 1:
                        for p in range(len(os) - 1):
                            ordered_equivalent_orders.append((os[p], os[p + 1]))
        for w, os in width_to_unique_color_order.items():
            if len(os) > 1:
                for p in range(len(os) - 1):
                    ordered_equivalent_orders.append((os[p], os[p + 1]))

        # Create position variables if there are symmetries to be broken.
        if ordered_equivalent_orders:
            print('  - creating %i symmetry breaking constraints' %
                  len(ordered_equivalent_orders))
            positions = {}
            for p in ordered_equivalent_orders:
                if p[0] not in positions:
                    positions[p[0]] = model.NewIntVar(
                        0, num_slabs - 1, 'position_of_slab_%i' % p[0])
                    model.AddMapDomain(positions[p[0]], assign[p[0]])
                if p[1] not in positions:
                    positions[p[1]] = model.NewIntVar(
                        0, num_slabs - 1, 'position_of_slab_%i' % p[1])
                    model.AddMapDomain(positions[p[1]], assign[p[1]])
                    # Finally add the symmetry breaking constraint.
                model.Add(positions[p[0]] <= positions[p[1]])

    # Objective.
    model.Minimize(sum(losses))

    print('Model created')

    ### Solve model.
    solver = cp_model.CpSolver()
    solver.num_search_workers = 8
    solution_printer = SteelMillSlabSolutionPrinter(orders, assign, loads,
                                                    losses)
    status = solver.Solve(model, solution_printer)

    ### Output the solution.
    if status == cp_model.OPTIMAL:
        print(
            'Loss = %i, time = %.2f s, %i conflicts' %
            (solver.ObjectiveValue(), solver.WallTime(), solver.NumConflicts()))
    else:
        print('No solution')


def steel_mill_slab_with_column_generation(problem):
    """Solves the Steel Mill Slab Problem."""
    ### Load problem.
    (num_slabs, capacities, _, orders) = build_problem(problem)

    num_orders = len(orders)
    num_capacities = len(capacities)
    all_orders = range(len(orders))
    print('Solving steel mill with %i orders, %i slabs, and %i capacities' %
          (num_orders, num_slabs, num_capacities - 1))

    # Compute auxiliary data.
    widths = [x[0] for x in orders]
    colors = [x[1] for x in orders]
    max_capacity = max(capacities)
    loss_array = [
        min(x for x in capacities if x >= c) - c for c in range(max_capacity +
                                                                1)
    ]

    ### Model problem.

    # Generate all valid slabs (columns)
    unsorted_valid_slabs = collect_valid_slabs_dp(capacities, colors, widths,
                                                  loss_array)

    # Sort slab by descending load/loss. Remove duplicates.
    valid_slabs = sorted(unsorted_valid_slabs,
                         key=lambda c: 1000 * c[-1] + c[-2])
    all_valid_slabs = range(len(valid_slabs))

    # create model and decision variables.
    model = cp_model.CpModel()
    selected = [model.NewBoolVar('selected_%i' % i) for i in all_valid_slabs]

    for order_id in all_orders:
        model.Add(
            sum(selected[i]
                for i, slab in enumerate(valid_slabs)
                if slab[order_id]) == 1)

    # Redundant constraint (sum of loads == sum of widths).
    model.Add(
        sum(selected[i] * valid_slabs[i][-1]
            for i in all_valid_slabs) == sum(widths))

    # Objective.
    model.Minimize(
        sum(selected[i] * valid_slabs[i][-2] for i in all_valid_slabs))

    print('Model created')

    ### Solve model.
    solver = cp_model.CpSolver()
    solver.parameters.num_search_workers = 8
    solver.parameters.log_search_progress = True
    solution_printer = cp_model.ObjectiveSolutionPrinter()
    status = solver.Solve(model, solution_printer)

    ### Output the solution.
    if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        print(
            'Loss = %i, time = %.2f s, %i conflicts' %
            (solver.ObjectiveValue(), solver.WallTime(), solver.NumConflicts()))
    else:
        print('No solution')


def steel_mill_slab_with_mip_column_generation(problem):
    """Solves the Steel Mill Slab Problem."""
    ### Load problem.
    (num_slabs, capacities, _, orders) = build_problem(problem)

    num_orders = len(orders)
    num_capacities = len(capacities)
    all_orders = range(len(orders))
    print('Solving steel mill with %i orders, %i slabs, and %i capacities' %
          (num_orders, num_slabs, num_capacities - 1))

    # Compute auxiliary data.
    widths = [x[0] for x in orders]
    colors = [x[1] for x in orders]
    max_capacity = max(capacities)
    loss_array = [
        min(x for x in capacities if x >= c) - c for c in range(max_capacity +
                                                                1)
    ]

    ### Model problem.

    # Generate all valid slabs (columns)
    unsorted_valid_slabs = collect_valid_slabs_dp(capacities, colors, widths,
                                                  loss_array)
    # Sort slab by descending load/loss. Remove duplicates.
    valid_slabs = sorted(unsorted_valid_slabs,
                         key=lambda c: 1000 * c[-1] + c[-2])
    all_valid_slabs = range(len(valid_slabs))

    # create model and decision variables.
    start_time = time.time()
    solver = pywraplp.Solver('Steel',
                             pywraplp.Solver.SCIP_MIXED_INTEGER_PROGRAMMING)
    selected = [
        solver.IntVar(0.0, 1.0, 'selected_%i' % i) for i in all_valid_slabs
    ]

    for order in all_orders:
        solver.Add(
            sum(selected[i]
                for i in all_valid_slabs
                if valid_slabs[i][order]) == 1)

    # Redundant constraint (sum of loads == sum of widths).
    solver.Add(
        sum(selected[i] * valid_slabs[i][-1]
            for i in all_valid_slabs) == sum(widths))

    # Objective.
    solver.Minimize(
        sum(selected[i] * valid_slabs[i][-2] for i in all_valid_slabs))

    status = solver.Solve()

    ### Output the solution.
    if status == pywraplp.Solver.OPTIMAL:
        print('Objective value = %f found in %.2f s' %
              (solver.Objective().Value(), time.time() - start_time))
    else:
        print('No solution')


def main(_=None):
    if FLAGS.solver == 'sat':
        steel_mill_slab(FLAGS.problem, FLAGS.break_symmetries)
    elif FLAGS.solver == 'sat_table':
        steel_mill_slab_with_valid_slabs(FLAGS.problem, FLAGS.break_symmetries)
    elif FLAGS.solver == 'sat_column':
        steel_mill_slab_with_column_generation(FLAGS.problem)
    else:  # 'mip_column'
        steel_mill_slab_with_mip_column_generation(FLAGS.problem)


if __name__ == '__main__':
    app.run(main)
