#  Copyright (c) 2023, Apple Inc. All rights reserved.
#
#  Use of this source code is governed by a BSD-3-clause license that can be
#  found in the LICENSE.txt file or at https://opensource.org/licenses/BSD-3-Clause


from coremltools.converters.mil.mil import Builder as mb
from coremltools.converters.mil.mil.passes.graph_pass import AbstractGraphPass
from coremltools.converters.mil.mil.passes.helper import block_context_manager
from coremltools.converters.mil.mil.passes.pass_registry import register_pass
from coremltools.converters.mil.mil.utils import CacheDoublyLinkedList


@register_pass(namespace="common")
class topological_reorder(AbstractGraphPass):
    """
    Topologically re-orders the list of operations in a program by places each operation closer to its
    first use, or at the end if it's not consumed by any other operation.

    Currently, This pass re-orders only Transpose and Cast operations.

    .. code-block::

        # Example: input program
        main(x: (2, 4, fp32)) {
            x = mb.cast(x=x, dtype="fp16")
            x1 = mb.square(x=x)
            x1_t = mb.transpose(x=x1, perm=[1, 0])
            x2 = mb.cast(x=x1_t, dtype="fp32")
            x3 = mb.log(x=x)
            x3_t = mb.transpose(x=x3, perm=[1, 0])
            x4 = mb.cast(x=x3_t, dtype="fp32")
            x5 = mb.relu(x=x)
            x6 = mb.cast(x=x5, dtype="fp32")
            x7 = mb.relu(x=x6)
            x8 = mb.relu(x=x)
        } -> x2, x4, x7, x8

        # After moving `cast` ops becomes
        main(x: (2, 4, fp32)) {
            x = mb.cast(x=x, dtype="fp16")
            x1 = mb.square(x=x)
            x1_t = mb.transpose(x=x1, perm=[1, 0])
            x3 = mb.log(x=x)
            x3_t = mb.transpose(x=x3, perm=[1, 0])
            x5 = mb.relu(x=x)
            x6 = mb.cast(x=x5, dtype="fp32")
            x7 = mb.relu(x=x6)
            x8 = mb.relu(x=x)
            x4 = mb.cast(x=x3_t, dtype="fp32")
            x2 = mb.cast(x=x1_t, dtype="fp32")
        } -> x2, x4, x7, x8

        # After moving `transpose` ops becomes
        main(x: (2, 4, fp32)) {
            x = mb.cast(x=x, dtype="fp16")
            x1 = mb.square(x=x)
            x3 = mb.log(x=x)
            x5 = mb.relu(x=x)
            x6 = mb.cast(x=x5, dtype="fp32")
            x7 = mb.relu(x=x6)
            x8 = mb.relu(x=x)
            x3_t = mb.transpose(x=x3, perm=[1, 0])
            x4 = mb.cast(x=x3_t, dtype="fp32")
            x1_t = mb.transpose(x=x1, perm=[1, 0])
            x2 = mb.cast(x=x1_t, dtype="fp32")
        } -> x2, x4, x7, x8
    """

    def apply(self, prog):
        for f in prog.functions.values():
            self._move_operations_to_the_end_block(f, ["cast", "transpose"])

    @staticmethod
    @block_context_manager
    def _move_operations_to_the_end_block(block, op_type_to_move):
        # Moves ops with `op_type_to_move` in `block.operations` (list) to the end of the program.
        # Note: ops with `op_type_to_move` and is dead code are moved toward end, which can be eliminated
        # later with dead-code-elimination pass.
        #
        # Inputs:
        #  - block (mil.Block): block to be modified in-place
        #  - op_type_to_move (List[str])
        # Returns:
        #  - set[Var]: Set of vars consumed in block (or returned as block output)

        # first_use maps var to (index, op) representing the first op in block.operation that consumes this var.
        block.operations = list(block.operations)
        first_use = {}  # var -> op
        ops_to_remove = []  # list of ops to be deleted at the end of pass
        for op in reversed(block.operations):
            current_op = op

            if op.op_type in op_type_to_move:
                # Mark op for deletion
                ops_to_remove.append(op)

                # Create list of operations consuming each output of current operation
                first_consumers = [first_use[v] for v in op.outputs if v in first_use]

                before_op = None  # None means adding at the end of block
                if len(first_consumers) > 0:
                    # Current op should be moved right before this first consumer of one of it's output.
                    #  1. Find indices for all the consumer ops of outputs
                    #  2. Move current op right before first consumer i.e. smallest index in block.operations
                    first_use_indices = [
                        block.operations.index(first_use_op) for first_use_op in first_consumers
                    ]
                    before_op = block.operations[min(first_use_indices)]

                # Create new copy of current operation
                new_var = getattr(mb, op.op_type)(**op.inputs, before_op=before_op)

                if not isinstance(new_var, (list, tuple)):
                    new_var = [new_var]

                # the new var should have the same name as the old var
                for i, old_var in enumerate(op.outputs):
                    new_var[i].name = old_var.name

                # Override current_op to be newly created op to ensure `first_use`
                # points to newly created op instead of old one.
                current_op = new_var[0].op

                for old_output_var, new_output_var in zip(op.outputs, new_var):
                    block.replace_uses_of_var_after_op(
                        anchor_op=op, old_var=old_output_var, new_var=new_output_var
                    )

            # Collect input vars from sub-block if present
            relevant_inputs = set()
            for b in current_op.blocks:
                relevant_inputs |= topological_reorder._move_operations_to_the_end_block(
                    b, op_type_to_move
                )

            # Collect vars from operation input
            for v in current_op.inputs.values():
                if isinstance(v, (tuple, list)):
                    relevant_inputs |= set(v)
                    continue
                relevant_inputs.add(v)

            # Mark current op as first use for all the input vars
            #  a) of it's sub-block
            #  b) of current op
            for v in relevant_inputs:
                # input is seen for the first time or
                # current_op is first_use i.e. appears before earlier recorded first_use.
                # Note: since ops are moved to the end, it's possible that an op is moved right after
                # earlier recorded first_use and in such cases, first_use should not be modified.
                #
                # == Example ==
                #     main( %x: (10, 20, fp32)(Tensor)) {
                #      block0() {
                #         %cast_0: (10, 20, fp16)(Tensor) = cast(x= %x, dtype = "fp16", name = "cast_0")
                #         %cast_1: (10, 20, fp32)(Tensor) = cast(x= %cast_0, dtype = "fp32", name = "cast_1")
                #         %transpose_0: (20, 10, fp16)(Tensor) = transpose(x= %cast_0, perm = [1, 0], name = "transpose_0")
                #         %transpose_1: (10, 20, fp16)(Tensor) = transpose(x= %transpose_0, perm = [1, 0], name = "transpose_1")
                #       } -> (% cast_1, % transpose_1)
                #     }
                # In above example, `%cast_1` will be moved to the end of the block and first_use info for `%cast_0`
                # should point to `%transpose_0` and not to `%cast_1`
                if v not in first_use or block.operations.index(
                    first_use[v]
                ) > block.operations.index(current_op):
                    first_use[v] = current_op

        # Remove ops that are reordered
        block.operations = CacheDoublyLinkedList(block.operations)
        block.remove_ops(ops_to_remove)

        # Returns set of vars consumed in current block
        vars_consumed_in_block = set([v for v in first_use])
        vars_consumed_in_block.update(block.outputs)
        return vars_consumed_in_block
