#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
#
from polygraphy import mod
from polygraphy.logger import G_LOGGER
from polygraphy.tools.args.base import BaseArgs
from polygraphy.tools.args import util as args_util
from polygraphy.tools.sparse import SparsityPruner
from polygraphy.tools.surgeon.subtool.base import BaseSurgeonSubtool
from polygraphy.tools.args import ModelArgs, OnnxLoadArgs, OnnxSaveArgs

gs = mod.lazy_import("onnx_graphsurgeon")
np = mod.lazy_import("numpy")
onnx = mod.lazy_import("onnx")


class WeightStripperArgs(BaseArgs):
    """
    Weight Stripping: weight stripping
    """

    def add_parser_args_impl(self):
        self.group.add_argument(
            "--exclude-list",
            help="Path to text file containing a list of initializers to skip",
            default=None,
            required=False,
        )

    def parse_impl(self, args):
        """
        Parses command-line arguments and populates the following attributes:

        Attributes:
            exclude_list (str): Path to text file containing a list of initializers to skip.
        """
        self.exclude_list = args_util.get(args, "exclude_list")

    def get_exclude_list(self):
        if not self.exclude_list:
            return set()
        with open(self.exclude_list) as fp:
            lines = [line.rstrip() for line in fp]
            return set(lines)


def get_patterns():
    """
    Base Patterns contain single ops: Conv, ConvTranspose, Gemm, Gather, MatMul, Slice

    Q/DQ Patterns contain sequences with Q/DQ ops followed by a few base patterns:
        [QuantizeLinear, DequantizeLinear, Conv]
        [QuantizeLinear, DequantizeLinear, ConvTranspose]
        [QuantizeLinear, DequantizeLinear, Gemm]
        [QuantizeLinear, DequantizeLinear, MatMul]

    Transpose Patterns contain sequences with the Transpose op followed  by a few base patterns:
        [Transpose, Conv]
        [Transpose, ConvTranspose]
        [Transpose, Gemm]
        [Transpose, MatMul]
    """
    base_patterns = []

    # dictionary storing the index of the input the Producer output can be linked to
    input_positions = {
        "Conv": [0],
        "ConvTranspose": [0],
        "Gemm": [0, 1, 2],
        "MatMul": [0, 1],
    }

    # Conv with Weight input
    conv_w = gs.GraphPattern()
    in_0 = conv_w.variable()
    w = conv_w.variable()
    conv_w_out = conv_w.add("conv_w", "Conv", inputs=[in_0, w])
    conv_w.set_output_tensors([conv_w_out])
    base_patterns.append(conv_w)

    # Conv with Weight and Bias inputs
    conv_w_b = gs.GraphPattern()
    in_0 = conv_w_b.variable()
    w = conv_w_b.variable()
    b = conv_w_b.variable()
    conv_w_b_out = conv_w_b.add("conv_w_b", "Conv", inputs=[in_0, w, b])
    conv_w_b.set_output_tensors([conv_w_b_out])
    base_patterns.append(conv_w_b)

    # ConvTranspose with Weight input
    convt_w = gs.GraphPattern()
    in_0 = convt_w.variable()
    w = convt_w.variable()
    convt_w_out = convt_w.add("convt_w", "ConvTranspose", inputs=[in_0, w])
    convt_w.set_output_tensors([convt_w_out])
    base_patterns.append(convt_w)

    # ConvTranspose with Weight and Bias inputs
    convt_w_b = gs.GraphPattern()
    in_0 = convt_w_b.variable()
    w = convt_w_b.variable()
    b = convt_w_b.variable()
    convt_w_b_out = convt_w_b.add("convt_w_b", "ConvTranspose", inputs=[in_0, w, b])
    convt_w_b.set_output_tensors([convt_w_b_out])
    base_patterns.append(convt_w_b)

    # Gemm with A and B inputs
    gemm_1 = gs.GraphPattern()
    in_0 = gemm_1.variable()
    in_1 = gemm_1.variable()
    gemm_1_out = gemm_1.add("gemm_1", "Gemm", inputs=[in_0, in_1])
    gemm_1.set_output_tensors([gemm_1_out])
    base_patterns.append(gemm_1)

    # Gemm with A, B and C inputs
    gemm_2 = gs.GraphPattern()
    in_0 = gemm_2.variable()
    in_1 = gemm_2.variable()
    in_2 = gemm_2.variable()
    gemm_2_out = gemm_2.add("gemm_2", "Gemm", inputs=[in_0, in_1, in_2])
    gemm_2.set_output_tensors([gemm_2_out])
    base_patterns.append(gemm_2)

    # MatMul
    matmul = gs.GraphPattern()
    in_0 = matmul.variable()
    in_1 = matmul.variable()
    matmul_out = matmul.add("matmul", "MatMul", inputs=[in_0, in_1])
    matmul.set_output_tensors([matmul_out])
    base_patterns.append(matmul)

    # Q/DQ patterns
    # QuantizeLinear with y_scale input
    q_1 = gs.GraphPattern()
    in_0 = q_1.variable()
    y_scale = q_1.variable()
    q_1_out = q_1.add("q_1", "QuantizeLinear", inputs=[in_0, y_scale])
    q_1.set_output_tensors([q_1_out])

    # QuantizeLinear with y_scale and y_zero_point inputs
    q_2 = gs.GraphPattern()
    in_0 = q_2.variable()
    y_scale = q_2.variable()
    y_zero_point = q_2.variable()
    q_2_out = q_2.add("q_2", "QuantizeLinear", inputs=[in_0, y_scale, y_zero_point])
    q_2.set_output_tensors([q_2_out])

    # DequantizeLinear with x_scale input
    dq_1 = gs.GraphPattern()
    in_0 = dq_1.variable()
    x_scale = dq_1.variable()
    dq_1_out = dq_1.add("dq_1", "DequantizeLinear", inputs=[in_0, x_scale])
    dq_1.set_output_tensors([dq_1_out])

    # QuantizeLinear with y_scale and y_zero_point inputs
    dq_2 = gs.GraphPattern()
    in_0 = dq_2.variable()
    x_scale = dq_2.variable()
    x_zero_point = dq_2.variable()
    dq_2_out = dq_2.add(
        "dq_2", "DequantizeLinear", inputs=[in_0, x_scale, x_zero_point]
    )
    dq_2.set_output_tensors([dq_2_out])

    qdq_patterns = []
    for op in base_patterns:
        # all base patterns contain a single node
        op_type = next(iter(op.nodes.values())).op
        for input_pos in input_positions[op_type]:
            for q in [q_1, q_2]:
                for dq in [dq_1, dq_2]:
                    curr_pattern = gs.GraphPattern()

                    q_inps = [
                        curr_pattern.variable() for _ in range(len(q.input_tensors))
                    ]
                    q_out = curr_pattern.add("Q", q, inputs=q_inps)

                    dq_inps = [
                        curr_pattern.variable()
                        for _ in range(len(dq.input_tensors) - 1)
                    ]
                    dq_out = curr_pattern.add("DQ", dq, inputs=[q_out] + dq_inps)

                    # in case of Gemm with 2 inputs, skip the case where output of dq node is the 3rd input of Gemm
                    if len(op.input_tensors) <= input_pos:
                        continue
                    op_inps = [
                        curr_pattern.variable() for _ in range(len(op.input_tensors))
                    ]
                    op_inps[input_pos] = dq_out
                    out = curr_pattern.add("base_op", op, inputs=op_inps)
                    curr_pattern.set_output_tensors([out])
                    qdq_patterns.append(curr_pattern)

    # Transpose patterns
    transpose_patterns = []
    transpose = gs.GraphPattern()
    in_0 = transpose.variable()
    transpose_out = transpose.add("transpose", "Transpose", inputs=[in_0])
    transpose.set_output_tensors([transpose_out])

    for op in base_patterns:
        # all base patterns contain a single node
        op_type = next(iter(op.nodes.values())).op
        for input_pos in input_positions[op_type]:
            curr_pattern = gs.GraphPattern()

            t_inps = [
                curr_pattern.variable() for _ in range(len(transpose.input_tensors))
            ]
            t_out = curr_pattern.add("t", transpose, inputs=t_inps)

            # in case of Gemm with 2 inputs, skip the case where output of transpose node is the 3rd input of Gemm
            if len(op.input_tensors) <= input_pos:
                continue
            op_inps = [curr_pattern.variable() for _ in range(len(op.input_tensors))]
            op_inps[input_pos] = t_out
            out = curr_pattern.add("base_op", op, inputs=op_inps)
            curr_pattern.set_output_tensors([out])
            transpose_patterns.append(curr_pattern)

    # Gather
    gather = gs.GraphPattern()
    in_0 = gather.variable()
    indices = gather.variable()
    gather_out = gather.add("gather", "Gather", inputs=[in_0, indices])
    gather.set_output_tensors([gather_out])
    base_patterns.append(gather)

    # Slice without no optional inputs
    slice_0 = gs.GraphPattern()
    in_0 = slice_0.variable()
    starts = slice_0.variable()
    ends = slice_0.variable()
    slice_0_out = slice_0.add("slice_0", "Slice", inputs=[in_0, starts, ends])
    slice_0.set_output_tensors([slice_0_out])
    base_patterns.append(slice_0)

    # Slice with axes inputs
    slice_1 = gs.GraphPattern()
    in_0 = slice_1.variable()
    starts = slice_1.variable()
    ends = slice_1.variable()
    axes = slice_1.variable()
    slice_1_out = slice_1.add("slice_1", "Slice", inputs=[in_0, starts, ends, axes])
    slice_1.set_output_tensors([slice_1_out])
    base_patterns.append(slice_1)

    # Slice with steps inputs
    slice_2 = gs.GraphPattern()
    in_0 = slice_2.variable()
    starts = slice_2.variable()
    ends = slice_2.variable()
    steps = slice_2.variable()
    slice_2_out = slice_2.add("slice_2", "Slice", inputs=[in_0, starts, ends, steps])
    slice_2.set_output_tensors([slice_2_out])
    base_patterns.append(slice_2)

    # Slice with axes and steps inputs
    slice_3 = gs.GraphPattern()
    in_0 = slice_3.variable()
    starts = slice_3.variable()
    ends = slice_3.variable()
    axes = slice_3.variable()
    steps = slice_3.variable()
    slice_3_out = slice_3.add(
        "slice_3", "Slice", inputs=[in_0, starts, ends, axes, steps]
    )
    slice_3.set_output_tensors([slice_3_out])
    base_patterns.append(slice_3)

    return base_patterns + qdq_patterns + transpose_patterns


def get_size_thresholds():
    """
    Strip the initializers of the ops only if the size threshold has been crossed
    """
    return {
        "Conv": 1,
        "ConvTranspose": 1,
        "Gather": 1024,
        "Gemm": 1,
        "Plugin": 1024,
        "Slice": 1024,
    }


def get_inputs_to_strip():
    """
    Restrict the stripping of initializers of the ops to the input index specified
    """
    return {
        "QuantizeLinear": set([0]),
        "Slice": set([0]),
    }


class WeightStripper(BaseSurgeonSubtool):
    """
    Strip weights from the provided ONNX model
    """

    def __init__(self):
        super().__init__("weight-strip")

    def show_start_end_logging_impl(self, args):
        return True

    def get_subscriptions_impl(self):
        return [
            ModelArgs(
                model_opt_required=True,
                input_shapes_opt_name=False,
                required_model_type="onnx",
            ),
            OnnxLoadArgs(
                allow_shape_inference=False,
                outputs_opt_prefix=False,
                allow_from_tf=False,
            ),
            OnnxSaveArgs(allow_shape_inference=False, output_opt_required=True),
            WeightStripperArgs(),
        ]

    def __skip(self, node, inp, inp_index):
        """
        Skip stripping the input based on pre-defined heuristics

        The function also modifies exclude_list if a matching input is found
        """
        # restrict stripping of certain op inputs
        if (
            node.op in self.inputs_to_strip
            and inp_index not in self.inputs_to_strip[node.op]
        ):
            return True
        # Skip inputs that are not initializers
        if not isinstance(inp, gs.Constant):
            return True
        # Skip initializers with uint8 dtype
        if inp.dtype == np.uint8:
            return True
        # Skip initializers specified in user defined skip list
        if inp.name in self.exclude_list:
            self.exclude_list.remove(inp.name)
            return True
        # Heuristic to strip based on size
        if (
            node.op in self.size_thresholds
            and inp.values.size < self.size_thresholds[node.op]
        ):
            return True

        return False

    def __get_matching_subgraph_inputs(self, graph):
        """
        Use GraphPattern to find matching patterns in the graph
        """
        for pattern in self.patterns:
            subgraphs = pattern.match_all(graph)
            for subgraph in subgraphs:
                # the first node in the matched subgraph contains the initializer to strip
                curr_node = next(iter(subgraph.values()))
                while curr_node._get_node() is None:
                    curr_node = next(iter(curr_node.values()))
                onnx_node = curr_node.onnx_node
                for inp_index, inp in enumerate(onnx_node.inputs):
                    if not self.__skip(onnx_node, inp, inp_index):
                        self.initializers_to_strip.add(inp.name)

    def __get_plugin_inputs(self, nodes):
        """
        Identify Plugin inputs to strip
        """
        for node in nodes:
            # If plugin found
            if not onnx.defs.has(node.op):
                for inp_index, inp in enumerate(node.inputs):
                    if not self.__skip(node, inp, inp_index):
                        G_LOGGER.verbose(
                            f"Stripping initializer {inp.name} to the {node.op} op."
                        )
                        self.initializers_to_strip.add(inp.name)

    def __get_sparse_tensors(self, model):
        """
        Identify sparse tensors in the model
        """
        sparsity_checker = SparsityPruner(model)
        sparsity_checker.check()
        sparse_tensors = sparsity_checker.sparse_tensors
        return sparse_tensors

    def run_impl_surgeon(self, args):
        def strip_weights(model):
            G_LOGGER.start("Beginning weight stripping...")
            G_LOGGER.warning(
                "The model is expected to be constant folded to successfully capture all weights eligible for stripping"
            )
            graph = gs.import_onnx(model)
            # check model sparsity
            G_LOGGER.info("Querying Sparse Initializers in the model")
            sparse_initializers = self.__get_sparse_tensors(model)

            # Call PatternMatcher to populate initializers_to_strip
            self.__get_matching_subgraph_inputs(graph)
            self.__get_plugin_inputs(graph.nodes)

            # Strip initializers identified by the PatternMatcher and Plugin Identifier
            num_stripped = 0
            for initializer in model.graph.initializer:
                if initializer.name in self.initializers_to_strip:
                    G_LOGGER.verbose(f"Stripping initializer {initializer.name}")
                    # Erase initializer data
                    initializer.raw_data = b""

                    # Check sparsity
                    sparse_str = (
                        "SPARSE_2_4" if initializer.name in sparse_initializers else ""
                    )

                    # Update initializer doc_string
                    initializer.doc_string = "/".join(["TRT_WEIGHTLESS", sparse_str])
                    num_stripped += 1

            if self.exclude_list:
                G_LOGGER.warning(
                    f"The following weights provided by the user to skip stripping were not found in the model: {self.exclude_list}."
                )
            assert num_stripped == len(self.initializers_to_strip)

            if num_stripped:
                model.doc_string = "-".join(
                    filter(None, [model.doc_string, "TRT_WEIGHTLESS"])
                )
            G_LOGGER.finish(f"Finished stripping {num_stripped} weights")

            return model

        # Initialize patterns
        self.patterns = get_patterns()
        self.size_thresholds = get_size_thresholds()
        self.inputs_to_strip = get_inputs_to_strip()
        self.initializers_to_strip = set()

        # load model
        model = super().load_model()

        self.exclude_list = self.arg_groups[WeightStripperArgs].get_exclude_list()
        stripped_model = strip_weights(model)
        super().save_model(stripped_model)
