# Copyright (c) Microsoft. All rights reserved.

from pytest import mark, raises

from semantic_kernel.exceptions import (
    CodeBlockRenderException,
    CodeBlockSyntaxError,
    CodeBlockTokenError,
    FunctionIdBlockSyntaxError,
    NamedArgBlockSyntaxError,
    ValBlockSyntaxError,
    VarBlockSyntaxError,
)
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel import Kernel
from semantic_kernel.template_engine.blocks.block_types import BlockTypes
from semantic_kernel.template_engine.blocks.code_block import CodeBlock
from semantic_kernel.template_engine.blocks.function_id_block import FunctionIdBlock
from semantic_kernel.template_engine.blocks.named_arg_block import NamedArgBlock
from semantic_kernel.template_engine.blocks.val_block import ValBlock
from semantic_kernel.template_engine.blocks.var_block import VarBlock


def test_init():
    target = CodeBlock(
        content="plugin.function 'value'  arg1=$arg1",
    )
    assert len(target.tokens) == 3
    assert target.tokens[0] == FunctionIdBlock(content="plugin.function")
    assert target.tokens[1] == ValBlock(content="'value'")
    assert target.tokens[2] == NamedArgBlock(content="arg1=$arg1")
    assert target.type == BlockTypes.CODE


class TestCodeBlockRendering:
    async def test_it_throws_if_a_plugins_are_empty(self, kernel: Kernel):
        target = CodeBlock(
            content="functionName",
        )
        assert target.tokens[0].type == BlockTypes.FUNCTION_ID
        with raises(CodeBlockRenderException, match="Function `functionName` not found"):
            await target.render_code(kernel, KernelArguments())

    async def test_it_throws_if_a_function_doesnt_exist(self, kernel: Kernel):
        target = CodeBlock(
            content="functionName",
        )
        assert target.tokens[0].type == BlockTypes.FUNCTION_ID
        kernel.add_plugin(KernelPlugin(name="test", functions=[]))
        with raises(CodeBlockRenderException, match="Function `functionName` not found"):
            await target.render_code(kernel, KernelArguments())

    async def test_it_throws_if_a_function_call_throws(self, kernel: Kernel):
        @kernel_function(name="funcName")
        def invoke():
            raise Exception("function exception")

        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_function(plugin_name="test", function=function)

        target = CodeBlock(
            content="test.funcName",
        )

        with raises(CodeBlockRenderException, match="test.funcName"):
            await target.render_code(kernel, KernelArguments())

    async def test_it_renders_code_block_consisting_of_just_a_var_block1(self, kernel: Kernel):
        code_block = CodeBlock(
            content="$var",
        )
        result = await code_block.render_code(kernel, KernelArguments(var="foo"))

        assert result == "foo"

    async def test_it_renders_code_block_consisting_of_just_a_val_block1(self, kernel: Kernel):
        code_block = CodeBlock(
            content="'ciao'",
        )
        result = await code_block.render_code(kernel, KernelArguments())

        assert result == "ciao"

    async def test_it_invokes_function_cloning_all_variables(self, kernel: Kernel):
        # Set up initial context variables
        arguments = KernelArguments(input="zero", var1="uno", var2="due")

        # Create a FunctionIdBlock with the function name
        func_id = FunctionIdBlock(content="test.funcName")

        # Set up a canary dictionary to track changes in the context variables
        canary = {"input": "", "var1": "", "var2": ""}

        # Define the function to be invoked, which modifies the canary
        # and context variables
        @kernel_function(name="funcName")
        def invoke(arguments: KernelArguments):
            nonlocal canary
            canary["input"] = arguments["input"]
            canary["var1"] = arguments["var1"]
            canary["var2"] = arguments["var2"]

            arguments["input"] = "overridden"
            arguments["var1"] = "overridden"
            arguments["var2"] = "overridden"

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and render it with the context
        code_block = CodeBlock(
            tokens=[func_id],
            content="",
        )
        await code_block.render_code(kernel, arguments)

        # Check that the canary values match the original context variables
        assert canary["input"] == "zero"
        assert canary["var1"] == "uno"
        assert canary["var2"] == "due"

        # Check that the original context variables were not modified
        assert arguments["input"] == "zero"
        assert arguments["var1"] == "uno"
        assert arguments["var2"] == "due"

    async def test_it_invokes_function_with_custom_variable(self, kernel: Kernel):
        # Define custom variable name and value
        VAR_NAME = "varName"
        VAR_VALUE = "varValue"

        # Set up initial context variables
        arguments = KernelArguments()
        arguments[VAR_NAME] = VAR_VALUE

        # Create a FunctionIdBlock with the function name and a
        # VarBlock with the custom variable
        func_id = FunctionIdBlock(content="test.funcName")
        var_block = VarBlock(content=f"${VAR_NAME}")

        # Set up a canary variable to track changes in the context input
        canary = ""

        # Define the function to be invoked, which modifies the canary variable
        @kernel_function(name="funcName")
        def invoke(arguments: "KernelArguments"):
            nonlocal canary
            canary = arguments["varName"]
            return arguments["varName"]

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and VarBlock,
        # and render it with the context
        code_block = CodeBlock(
            tokens=[func_id, var_block],
            content="",
        )
        result = await code_block.render_code(kernel, arguments)

        # Check that the result matches the custom variable value
        assert result == VAR_VALUE
        # Check that the canary value matches the custom variable value
        assert canary == VAR_VALUE

    async def test_it_invokes_function_with_custom_value(self, kernel: Kernel):
        # Define a value to be used in the test
        VALUE = "value"

        # Create a FunctionIdBlock with the function name and a ValBlock with the value
        func_id = FunctionIdBlock(content="test.funcName")
        val_block = ValBlock(content=f"'{VALUE}'")

        # Set up a canary variable to track changes in the context input
        canary = ""

        # Define the function to be invoked, which modifies the canary variable
        @kernel_function(name="funcName")
        def invoke(arguments):
            nonlocal canary
            canary = arguments["input"]
            return arguments["input"]

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and ValBlock,
        # and render it with the context
        code_block = CodeBlock(
            tokens=[func_id, val_block],
            content="",
        )
        result = await code_block.render_code(kernel, KernelArguments(input="value"))

        # Check that the result matches the value
        assert str(result) == VALUE
        # Check that the canary value matches the value
        assert canary == VALUE

    async def test_it_invokes_function_with_multiple_arguments(self, kernel: Kernel):
        # Define a value to be used in the test
        VALUE = "value"

        code_block = CodeBlock(
            content=" ",
            tokens=[
                FunctionIdBlock(content="test.funcName", plugin_name="test", function_name="funcName", validated=True),
                ValBlock(content=f'"{VALUE}"'),
                NamedArgBlock(content="arg1=$arg1"),
                NamedArgBlock(content='arg2="arg2"'),
            ],
        )
        # Set up a canary variable to track changes in the context input
        canary = ""

        # Define the function to be invoked, which modifies the canary variable
        @kernel_function(name="funcName")
        def invoke(input, arg1, arg2):
            nonlocal canary
            canary = f"{input} {arg1} {arg2}"
            return input

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and ValBlock,
        # and render it with the context
        result = await code_block.render_code(kernel, KernelArguments(arg1="arg1"))

        # Check that the result matches the value
        assert str(result) == VALUE
        # Check that the canary value matches the value
        assert canary == f"{VALUE} arg1 arg2"

    async def test_it_invokes_function_with_only_named_arguments(self, kernel: Kernel):
        code_block = CodeBlock(
            content=" ",
            tokens=[
                FunctionIdBlock(content="test.funcName", plugin_name="test", function_name="funcName"),
                NamedArgBlock(content="arg1=$arg1"),
                NamedArgBlock(content='arg2="arg2"'),
            ],
        )
        # Set up a canary variable to track changes in the context input
        canary = ""

        # Define the function to be invoked, which modifies the canary variable
        @kernel_function(name="funcName")
        def invoke(arg1, arg2):
            nonlocal canary
            canary = f"{arg1} {arg2}"
            return arg1

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="pluginName",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and ValBlock,
        # and render it with the context
        result = await code_block.render_code(kernel, KernelArguments(arg1="arg1"))

        # Check that the result matches the value
        assert str(result) == "arg1"
        # Check that the canary value matches the value
        assert canary == "arg1 arg2"

    async def test_it_fails_on_function_without_args(self, kernel: Kernel):
        code_block = CodeBlock(
            content=" ",
            tokens=[
                FunctionIdBlock(content="test.funcName", plugin_name="test", function_name="funcName"),
                NamedArgBlock(content="arg1=$arg1"),
                NamedArgBlock(content='arg2="arg2"'),
            ],
        )

        @kernel_function(name="funcName")
        def invoke():
            return "function without args"

        # Create an KernelFunction with the invoke function as its delegate
        function = KernelFunctionFromMethod(
            method=invoke,
            plugin_name="test",
        )

        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        # Create a CodeBlock with the FunctionIdBlock and ValBlock,
        # and render it with the context
        with raises(
            CodeBlockRenderException,
            match="Function test.funcName does not take any arguments \
but it is being called in the template with 2 arguments.",
        ):
            await code_block.render_code(kernel, KernelArguments(arg1="arg1"))


@mark.parametrize(
    "token2",
    [
        "",
        "arg2=$arg!2",
        "arg2='va\"l'",
    ],
    ids=[
        "empty",
        "invalid_named_arg",
        "invalid_named_arg_val",
    ],
)
@mark.parametrize(
    "token1",
    [
        "",
        "$var!",
        "\"val'",
        "arg1=$arg!1",
        "arg1='va\"l'",
    ],
    ids=[
        "empty",
        "invalid_var",
        "invalid_val",
        "invalid_named_arg",
        "invalid_named_arg_val",
    ],
)
@mark.parametrize(
    "token0",
    [
        "plugin.func.test",
        "$var!",
        '"va"l"',
    ],
    ids=[
        "invalid_func",
        "invalid_var",
        "invalid_val",
    ],
)
def test_block_validation(token0, token1, token2):
    with raises((
        FunctionIdBlockSyntaxError,
        VarBlockSyntaxError,
        ValBlockSyntaxError,
        NamedArgBlockSyntaxError,
        CodeBlockSyntaxError,
    )):
        CodeBlock(
            content=f"{token0} {token1} {token2}",
        )


@mark.parametrize(
    "token2, token2valid",
    [
        ("", True),
        ("plugin.func", False),
        ("$var", False),
        ('"val"', False),
        ("arg1=$arg1", True),
        ("arg1='val'", True),
    ],
    ids=[
        "empty",
        "func_invalid",
        "invalid_var",
        "invalid_val",
        "valid_named_arg",
        "valid_named_arg_val",
    ],
)
@mark.parametrize(
    "token1, token1valid",
    [
        ("", True),
        ("plugin.func", False),
        ("$var", True),
        ('"val"', True),
        ("arg1=$arg1", True),
        ("arg1='val'", True),
    ],
    ids=[
        "empty",
        "func_invalid",
        "var",
        "val",
        "valid_named_arg",
        "valid_named_arg_val",
    ],
)
@mark.parametrize(
    "token0, token0valid",
    [
        ("func", True),
        ("plugin.func", True),
        ("$var", True),
        ('"val"', True),
        ("arg1=$arg1", False),
        ("arg1='val'", False),
    ],
    ids=[
        "single_name_func",
        "FQN_func",
        "var",
        "val",
        "invalid_named_arg",
        "invalid_named_arg_val",
    ],
)
def test_positional_validation(token0, token0valid, token1, token1valid, token2, token2valid):
    if not token1 and not token2valid:
        mark.skipif(f"{token0} {token1} {token2}", reason="Not applicable")
        return
    valid = token0valid and token1valid and token2valid
    if token0 in ["$var", '"val"']:
        valid = True
    content = f"{token0} {token1} {token2}"
    if valid:
        target = CodeBlock(
            content=content,
        )
        assert target.content == content.strip()
    else:
        with raises(CodeBlockTokenError):
            CodeBlock(
                content=content,
            )


@mark.parametrize(
    "case, result",
    [
        (r"{$a", False),
    ],
)
def test_edge_cases(case, result):
    if result:
        target = CodeBlock(
            content=case,
        )
        assert target.content == case
    else:
        with raises(FunctionIdBlockSyntaxError):
            CodeBlock(
                content=case,
            )


def test_no_tokens():
    with raises(CodeBlockTokenError):
        CodeBlock(content="", tokens=[])


class TestNonStringArguments:
    """Test that non-string KernelArguments are preserved when passed to functions in templates."""

    async def test_function_receives_int_type(self, kernel: Kernel):
        """Test that an integer argument is passed as int, not converted to string."""
        received_value = None
        received_type = None

        @kernel_function(name="check_type")
        def check_type(value: int):
            nonlocal received_value, received_type
            received_value = value
            received_type = type(value)
            return f"Received {type(value).__name__}: {value}"

        function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        code_block = CodeBlock(content="test.check_type value=$my_int")
        arguments = KernelArguments(my_int=42)

        await code_block.render_code(kernel, arguments)

        assert received_value == 42
        assert isinstance(received_value, int), f"Expected int but got {received_type}"

    async def test_function_receives_list_type(self, kernel: Kernel):
        """Test that a list argument is passed as list, not converted to string."""
        received_value = None
        received_type = None

        @kernel_function(name="check_type")
        def check_type(items: list):
            nonlocal received_value, received_type
            received_value = items
            received_type = type(items)
            return f"Received {len(items)} items"

        function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        code_block = CodeBlock(content="test.check_type items=$my_list")
        arguments = KernelArguments(my_list=[1, 2, 3])

        await code_block.render_code(kernel, arguments)

        assert received_value == [1, 2, 3]
        assert isinstance(received_value, list), f"Expected list but got {received_type}"

    async def test_function_receives_dict_type(self, kernel: Kernel):
        """Test that a dict argument is passed as dict, not converted to string."""
        received_value = None
        received_type = None

        @kernel_function(name="check_type")
        def check_type(data: dict):
            nonlocal received_value, received_type
            received_value = data
            received_type = type(data)
            return f"Received dict with keys: {list(data.keys())}"

        function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        code_block = CodeBlock(content="test.check_type data=$my_dict")
        arguments = KernelArguments(my_dict={"key": "value", "num": 123})

        await code_block.render_code(kernel, arguments)

        assert received_value == {"key": "value", "num": 123}
        assert isinstance(received_value, dict), f"Expected dict but got {received_type}"

    async def test_named_arg_with_non_string_type(self, kernel: Kernel):
        """Test that named arguments with non-string types are preserved."""
        received_count = None
        received_type = None

        @kernel_function(name="process")
        def process(text: str, count: int):
            nonlocal received_count, received_type
            received_count = count
            received_type = type(count)
            return f"{text} x {count}"

        function = KernelFunctionFromMethod(method=process, plugin_name="test")
        kernel.add_plugin(KernelPlugin(name="test", functions=[function]))

        code_block = CodeBlock(content="test.process 'hello' count=$repetitions")
        arguments = KernelArguments(repetitions=5)

        await code_block.render_code(kernel, arguments)

        assert received_count == 5
        assert isinstance(received_count, int), f"Expected int but got {received_type}"
