# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.
"""Arm Compute Library integration conv2d tests."""

import numpy as np
import pytest

import tvm
from tvm import relay

from test_arm_compute_lib.infrastructure import (
    QNN_DTYPES,
    get_low_high_atol_rtol,
    skip_runtime_test,
    skip_codegen_test,
    build_and_run,
    verify,
    verify_codegen,
)
from test_arm_compute_lib.infrastructure import Device


def _get_model(
    shape,
    kernel_h,
    kernel_w,
    padding,
    strides,
    dilation,
    groups,
    dtype,
    channels,
    var_names,
    has_bias=False,
    has_activation=False,
    has_pad=False,
):
    """Return a model and any parameters it may have"""
    a = relay.var(next(var_names), shape=shape, dtype=dtype)
    if has_pad:
        p = ((0, 0), (padding[0], padding[0]), (padding[1], padding[1]), (0, 0))
        a = relay.nn.pad(a, pad_width=p)
        padding = (0, 0, 0, 0)
    else:
        if len(padding) == 2:
            padding = (padding[0], padding[1], padding[0], padding[1])
        shape = (shape[0], shape[1] + padding[0] * 2, shape[2] + padding[1] * 2, shape[3])
    is_depthwise = shape[3] == channels == groups
    weight_format = "HWOI" if is_depthwise else "HWIO"
    if weight_format == "HWIO":
        weight_shape = (kernel_h, kernel_w, shape[3] // groups, channels)
    else:
        weight_shape = (kernel_h, kernel_w, channels, shape[3] // groups)
    w = tvm.nd.array(np.random.uniform(-128, 127, weight_shape).astype(dtype))
    weights = relay.const(w, dtype)
    out = relay.nn.conv2d(
        a,
        weights,
        kernel_size=(kernel_h, kernel_w),
        data_layout="NHWC",
        kernel_layout=weight_format,
        dilation=dilation,
        strides=strides,
        padding=padding,
        groups=groups,
        channels=channels,
        out_dtype=dtype,
    )
    params = {"w": w}
    if has_bias:
        bias_shape = weight_shape[2] if is_depthwise else weight_shape[3]
        b = tvm.nd.array(np.random.uniform(-128, 127, bias_shape).astype(dtype))
        biasc = relay.const(b, dtype)
        out = relay.nn.bias_add(out, biasc, axis=3)
        params["b"] = b
    if has_activation:
        out = relay.nn.relu(out)
    return out, params


def _get_qnn_params(input_zp, input_sc, kernel_zp, kernel_sc, kernel_h, kernel_w, channels):
    """Get output qnn parameters given input and kernel parameters."""
    input_max = input_sc * (255 - input_zp)
    input_min = -input_sc * input_zp
    kernel_max = kernel_sc * (255 - kernel_zp)
    kernel_min = -kernel_sc * kernel_zp
    output_limits = [
        kernel_max * kernel_h * kernel_w * channels * input_max,
        kernel_min * kernel_h * kernel_w * channels * input_max,
        kernel_min * kernel_h * kernel_w * channels * input_min,
        kernel_max * kernel_h * kernel_w * channels * input_min,
    ]
    output_max = max(output_limits)
    output_min = min(output_limits)
    output_sc = (output_max - output_min) / 255
    output_zp = -int(output_min / output_sc)
    return output_zp, output_sc


def _get_qnn_model(
    shape,
    kernel_h,
    kernel_w,
    padding,
    strides,
    dilation,
    groups,
    dtype,
    channels,
    input_zp,
    input_sc,
    kernel_zp,
    kernel_sc,
    output_zp,
    output_sc,
    var_names,
    has_bias=False,
    has_activation=False,
    has_pad=False,
):
    """Return a model and any parameters it may have."""
    low, high, _, _ = get_low_high_atol_rtol(dtype)

    a = relay.var(next(var_names), shape=shape, dtype=dtype)
    if has_pad:
        p = ((0, 0), (padding[0], padding[0]), (padding[1], padding[1]), (0, 0))
        a = relay.nn.pad(a, pad_width=p, pad_value=input_zp, pad_mode="constant")
        padding = (0, 0, 0, 0)
    else:
        if len(padding) == 2:
            padding = (padding[0], padding[1], padding[0], padding[1])
        shape = (shape[0], shape[1] + padding[0] * 2, shape[2] + padding[1] * 2, shape[3])
    is_depthwise = shape[3] == channels == groups
    weight_format = "HWOI" if is_depthwise else "HWIO"
    if weight_format == "HWIO":
        weight_shape = (kernel_h, kernel_w, shape[3] // groups, channels)
    else:
        weight_shape = (kernel_h, kernel_w, channels, shape[3] // groups)
    w = tvm.nd.array(np.random.uniform(low, high, weight_shape).astype(dtype))
    weights = relay.const(w, dtype)
    out = relay.qnn.op.conv2d(
        a,
        weights,
        input_zero_point=relay.const(input_zp, "int32"),
        kernel_zero_point=relay.const(kernel_zp, "int32"),
        input_scale=relay.const(input_sc, "float32"),
        kernel_scale=relay.const(kernel_sc, "float32"),
        kernel_size=(kernel_h, kernel_w),
        data_layout="NHWC",
        kernel_layout=weight_format,
        dilation=dilation,
        strides=strides,
        padding=padding,
        groups=groups,
        channels=channels,
        out_dtype="int32",
    )
    params = {"w": w}
    if has_bias:
        bias_shape = weight_shape[2] if is_depthwise else weight_shape[3]
        b = tvm.nd.array(np.random.uniform(-128, 127, bias_shape).astype("int32"))
        biasc = relay.const(b, "int32")
        out = relay.nn.bias_add(out, biasc, axis=3)
        params["b"] = b
    if has_activation:
        out = relay.nn.relu(out)
    req = relay.qnn.op.requantize(
        out,
        relay.const(input_sc * kernel_sc, "float32"),  # input scale
        relay.const(0, "int32"),  # input zero point
        relay.const(output_sc, "float32"),  # output scale
        relay.const(output_zp, "int32"),  # output zero point
        out_dtype=dtype,
    )
    return req, params


def _get_expected_codegen(
    shape,
    kernel_h,
    kernel_w,
    padding,
    strides,
    dilation,
    groups,
    dtype,
    channels,
    has_bias=False,
    has_activation=False,
):
    if len(padding) == 2:
        padding = (padding[0], padding[1], padding[0], padding[1])
    output_height = ((shape[1] - kernel_h + padding[0] + padding[2]) / strides[0]) + 1
    output_width = ((shape[2] - kernel_w + padding[1] + padding[3]) / strides[1]) + 1
    output_shape = (1, int(output_height), int(output_width), channels)
    out_dtype = "int32" if dtype in QNN_DTYPES else "float32"
    is_depthwise = shape[3] == channels == groups
    weight_format = "IHWO" if is_depthwise else "OHWI"
    if weight_format == "IHWO":
        weight_shape = (shape[3] // groups, kernel_h, kernel_w, channels)
    else:
        weight_shape = (channels, kernel_h, kernel_w, shape[3] // groups)
    if is_depthwise:
        name = "nn.depthwise_conv2d"
    else:
        name = "nn.conv2d"

    node = {
        "op": "kernel",
        "name": name,
        "inputs": [],
        "attrs": {
            "groups": [[str(groups)]],
            "num_outputs": "1",
            "data_layout": [["NHWC"]],
            "kernel_layout": [[weight_format]],
            "channels": [[str(channels)]],
            "dilation": [[str(dilation[0]), str(dilation[1])]],
            "out_layout": [[""]],
            "out_dtype": [[out_dtype]],
            "kernel_size": [[str(kernel_h), str(kernel_w)]],
            "shape": [[list(output_shape)]],
            "dtype": [[dtype]],
            "padding": [[str(p) for p in padding]],
            "strides": [[str(s) for s in strides]],
        },
    }

    if has_activation:
        node["attrs"]["activation_type"] = [["relu"]]

    inputs = [
        {"op": "input", "name": "", "attrs": {"shape": [[list(shape)]], "dtype": [[str(dtype)]]}},
        {
            "op": "const",
            "name": "",
            "attrs": {"shape": [[list(weight_shape)]], "dtype": [[str(dtype)]]},
        },
    ]

    # qnn.conv2d params, input and kernel
    if dtype in QNN_DTYPES:
        node["name"] = "qnn." + node["name"].split(".")[1]
        for param_dtype in ["int32", "float32"]:
            for _ in range(2):
                inputs.append(
                    {
                        "op": "const",
                        "name": "",
                        "attrs": {"shape": [[[]]], "dtype": [[param_dtype]]},
                    }
                )

    if has_bias:
        bias_dtype = "int32" if dtype in QNN_DTYPES else "float32"
        inputs.append(
            {
                "op": "const",
                "name": "",
                "attrs": {
                    "shape": [[[1, 1, 1, weight_shape[3] if is_depthwise else weight_shape[0]]]],
                    "dtype": [[bias_dtype]],
                },
            }
        )

    # qnn.conv2d params, output
    if dtype in QNN_DTYPES:
        for param_dtype in ["float32", "int32"]:
            inputs.append(
                {"op": "const", "name": "", "attrs": {"shape": [[[]]], "dtype": [[param_dtype]]}}
            )

    input_idx = 0
    for _ in range(len(inputs)):
        node["inputs"].append([input_idx, 0, 0])
        input_idx += 1
    node["attrs"]["num_inputs"] = str(len(inputs))
    inputs.append(node)
    return inputs


@pytest.mark.parametrize(
    "trial",
    [
        # Normal convolution
        [2, 2, (1, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, False, False), False],
        [2, 1, (2, 2), (1, 1), (1, 1), 7, (12, 15, 16), (False, False, True), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, True, False), False],
        [3, 3, (1, 1), (1, 1), (1, 1), 16, (12, 15, 16), (False, False, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [1, 3, (1, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, True), False],
        [2, 2, (2, 2), (1, 1), (1, 1), 4, (20, 20, 20), (False, True, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, False), False],
        [3, 3, (1, 1), (2, 2), (1, 1), 16, (10, 10, 14), (False, True, True), False],
        # Depth-wise convolution
        [3, 3, (1, 1), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, True), True],
        [5, 5, (2, 2), (1, 1), (1, 1), 20, (20, 20, 20), (False, True, False), True],
        [3, 3, (2, 2), (2, 2), (1, 1), 14, (10, 10, 14), (True, False, False), True],
        [5, 5, (0, 0), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, False), True],
        [3, 3, (1, 1), (2, 2), (1, 1), 14, (10, 10, 14), (False, True, True), True],
    ],
)
def test_conv2d(trial):
    Device.load("test_config.json")

    if skip_runtime_test():
        return

    device = Device()
    np.random.seed(0)

    dtype = "float32"

    (
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        out_channels,
        shape,
        composite,
        is_depthwise,
    ) = trial
    shape = (1, *shape)
    if is_depthwise:
        groups = shape[3]
    else:
        groups = 1
    outputs = []
    inputs = {
        "a": tvm.nd.array(np.random.uniform(-128, 127, shape).astype(dtype)),
    }

    func, params = _get_model(
        shape,
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        groups,
        dtype,
        out_channels,
        iter(inputs),
        has_pad=composite[0],
        has_bias=composite[1],
        has_activation=composite[2],
    )
    # Generate results for ACL conv2d and TVM native conv2d for comparison
    for acl in [False, True]:
        outputs.append(build_and_run(func, inputs, 1, params, device, enable_acl=acl)[0])

    config = {
        "shape": shape,
        "groups": groups,
        "kernel size": (kernel_h, kernel_w),
        "padding": pad,
        "stride": stride,
        "dilation": dilation,
        "out channels": out_channels,
        "composite operators (pad, bias, activation)": composite,
    }
    verify(outputs, atol=0.002, rtol=0.01, config=config)


@pytest.mark.parametrize(
    "trial",
    [
        # Normal convolution
        [2, 2, (1, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, False, False), False],
        [2, 1, (2, 2), (1, 1), (1, 1), 7, (12, 15, 16), (False, False, True), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, True, False), False],
        [3, 3, (1, 1), (1, 1), (1, 1), 16, (12, 15, 16), (False, False, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [1, 3, (1, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, True), False],
        [2, 2, (2, 2), (1, 1), (1, 1), 4, (20, 20, 20), (False, True, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, False), False],
        [3, 3, (1, 1), (2, 2), (1, 1), 16, (10, 10, 14), (False, True, True), False],
        # Depth-wise convolution
        [3, 3, (1, 1), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, True), True],
        [5, 5, (2, 2), (1, 1), (1, 1), 20, (20, 20, 20), (False, True, False), True],
        [3, 3, (2, 2), (2, 2), (1, 1), 14, (10, 10, 14), (True, False, False), True],
        [5, 5, (0, 0), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, False), True],
        [3, 3, (1, 1), (2, 2), (1, 1), 14, (10, 10, 14), (False, True, True), True],
    ],
)
def test_codegen_conv2d(trial):
    if skip_codegen_test():
        return

    dtype = "float32"

    (
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        out_channels,
        shape,
        composite,
        is_depthwise,
    ) = trial
    shape = (1, *shape)
    if is_depthwise:
        groups = shape[3]
    else:
        groups = 1
    inputs = {"a"}

    args = (shape, kernel_h, kernel_w, pad, stride, dilation, groups, dtype, out_channels)

    func, params = _get_model(
        *args,
        var_names=iter(inputs),
        has_pad=composite[0],
        has_bias=composite[1],
        has_activation=composite[2],
    )
    exp_codegen = _get_expected_codegen(*args, has_bias=composite[1], has_activation=composite[2])
    verify_codegen(func, exp_codegen, 1)


@pytest.mark.parametrize(
    "trial",
    [
        # Normal convolution
        [2, 2, (1, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, False, False), False],
        [2, 1, (2, 2), (1, 1), (1, 1), 7, (12, 15, 16), (False, False, True), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, True, False), False],
        [3, 3, (1, 1), (1, 1), (1, 1), 16, (12, 15, 16), (False, False, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [1, 3, (1, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, True), False],
        [2, 2, (2, 2), (1, 1), (1, 1), 4, (20, 20, 20), (False, True, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, False), False],
        [3, 3, (1, 1), (2, 2), (1, 1), 16, (10, 10, 14), (False, True, True), False],
        # Depth-wise convolution
        [3, 3, (1, 1), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, True), True],
        [5, 5, (2, 2), (1, 1), (1, 1), 20, (20, 20, 20), (False, True, False), True],
        [3, 3, (2, 2), (2, 2), (1, 1), 14, (10, 10, 14), (True, False, False), True],
        [5, 5, (0, 0), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, False), True],
        [3, 3, (1, 1), (2, 2), (1, 1), 14, (10, 10, 14), (False, True, True), True],
    ],
)
@pytest.mark.parametrize("dtype", QNN_DTYPES)
def test_qnn_conv2d(trial, dtype):
    Device.load("test_config.json")

    if skip_runtime_test():
        return

    device = Device()
    np.random.seed(0)

    (
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        out_channels,
        shape,
        composite,
        is_depthwise,
    ) = trial
    shape = (1, *shape)
    if is_depthwise:
        groups = shape[3]
    else:
        groups = 1
    outputs = []
    inputs = {"a": tvm.nd.array(np.random.uniform(0, 255, shape).astype(dtype))}

    input_zp = 100
    input_sc = 0.5
    kernel_zp = 25
    kernel_sc = 0.03
    output_zp, output_sc = _get_qnn_params(
        input_zp, input_sc, kernel_zp, kernel_sc, kernel_h, kernel_w, shape[3]
    )

    func, params = _get_qnn_model(
        shape,
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        groups,
        dtype,
        out_channels,
        input_zp,
        input_sc,
        kernel_zp,
        kernel_sc,
        output_zp,
        output_sc,
        iter(inputs),
        has_pad=composite[0],
        has_bias=composite[1],
        has_activation=composite[2],
    )
    for acl in [False, True]:
        outputs.append(build_and_run(func, inputs, 1, params, device, enable_acl=acl)[0])

    config = {
        "shape": shape,
        "groups": groups,
        "kernel size": (kernel_h, kernel_w),
        "padding": pad,
        "stride": stride,
        "dilation": dilation,
        "out channels": out_channels,
        "composite operators (pad, bias, activation)": composite,
        "input scale": input_sc,
        "input zero point": input_zp,
        "kernel scale": kernel_sc,
        "kernel zero point": kernel_zp,
        "output scale": output_sc,
        "output zero point": output_zp,
    }

    atol = 2 if is_depthwise else 1
    verify(outputs, atol=atol, rtol=0, config=config, verify_saturation=True)


@pytest.mark.parametrize("dtype", QNN_DTYPES)
@pytest.mark.parametrize(
    "trial",
    [
        # Normal convolution
        [2, 2, (1, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, False, False), False],
        [2, 1, (2, 2), (1, 1), (1, 1), 7, (12, 15, 16), (False, False, True), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 4, (10, 10, 14), (False, True, False), False],
        [3, 3, (1, 1), (1, 1), (1, 1), 16, (12, 15, 16), (False, False, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [1, 3, (1, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, True), False],
        [2, 2, (2, 2), (1, 1), (1, 1), 4, (20, 20, 20), (False, True, False), False],
        [5, 5, (1, 1), (2, 2), (1, 1), 4, (10, 10, 14), (True, False, False), False],
        [3, 3, (2, 1), (1, 1), (1, 1), 7, (20, 20, 20), (False, False, False), False],
        [3, 3, (1, 1), (2, 2), (1, 1), 16, (10, 10, 14), (False, True, True), False],
        # Depth-wise convolution
        [3, 3, (1, 1), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, True), True],
        [5, 5, (2, 2), (1, 1), (1, 1), 20, (20, 20, 20), (False, True, False), True],
        [3, 3, (2, 2), (2, 2), (1, 1), 14, (10, 10, 14), (True, False, False), True],
        [5, 5, (0, 0), (1, 1), (1, 1), 20, (20, 20, 20), (False, False, False), True],
        [3, 3, (1, 1), (2, 2), (1, 1), 14, (10, 10, 14), (False, True, True), True],
    ],
)
def test_codegen_qnn_conv2d(trial, dtype):
    if skip_codegen_test():
        return

    (
        kernel_h,
        kernel_w,
        pad,
        stride,
        dilation,
        out_channels,
        shape,
        composite,
        is_depthwise,
    ) = trial
    shape = (1, *shape)
    if is_depthwise:
        groups = shape[3]
    else:
        groups = 1
    inputs = {"a"}

    input_zp = 100
    input_sc = 0.5
    kernel_zp = 25
    kernel_sc = 0.03
    output_zp, output_sc = _get_qnn_params(
        input_zp, input_sc, kernel_zp, kernel_sc, kernel_h, kernel_w, shape[3]
    )

    args = (shape, kernel_h, kernel_w, pad, stride, dilation, groups, dtype, out_channels)

    func, params = _get_qnn_model(
        *args,
        input_zp=input_zp,
        input_sc=input_sc,
        kernel_zp=kernel_zp,
        kernel_sc=kernel_sc,
        output_zp=output_zp,
        output_sc=output_sc,
        var_names=iter(inputs),
        has_pad=composite[0],
        has_bias=composite[1],
        has_activation=composite[2],
    )
    exp_codegen = _get_expected_codegen(*args, has_bias=composite[1], has_activation=composite[2])
    verify_codegen(func, exp_codegen, 1)


@pytest.mark.parametrize(
    "param",
    ["kernel_sc", "kernel_zp"],
)
def test_codegen_qnn_conv2d_per_channel_quantization(param):
    if skip_codegen_test():
        return

    dtype = "int8"
    kernel_h = 2
    kernel_w = 2
    pad = (1, 1)
    stride = (1, 1)
    dilation = (1, 1)
    out_channels = 4
    shape = (1, 10, 10, 14)
    composite = (False, False, False)
    groups = 1
    inputs = {"a"}

    qnn_params = {
        "input_zp": 1,
        "input_sc": 1,
        "kernel_zp": 1,
        "kernel_sc": 1,
        "output_zp": 1,
        "output_sc": 1,
    }
    qnn_params[param] = [1, 1, 1, 1]

    args = (shape, kernel_h, kernel_w, pad, stride, dilation, groups, dtype, out_channels)

    func, params = _get_qnn_model(
        *args,
        input_zp=qnn_params["input_zp"],
        input_sc=qnn_params["input_sc"],
        kernel_zp=qnn_params["kernel_zp"],
        kernel_sc=qnn_params["kernel_sc"],
        output_zp=qnn_params["output_zp"],
        output_sc=qnn_params["output_sc"],
        var_names=iter(inputs),
        has_pad=composite[0],
        has_bias=composite[1],
        has_activation=composite[2],
    )
    exp_codegen = _get_expected_codegen(*args, has_bias=composite[1], has_activation=composite[2])
    verify_codegen(func, exp_codegen, num_acl_modules=0, tvm_ops=2)


if __name__ == "__main__":
    test_conv2d()
    test_qnn_conv2d()
    test_codegen_conv2d()
    test_codegen_qnn_conv2d()
    test_codegen_qnn_conv2d_per_channel_quantization()
