# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test module for actions utilities that modify configuration.

Verify that privileged actions perform as expected. See actions.py for a full
description of the expectations.

"""

import os
import typing
from unittest.mock import Mock, call, patch

import pytest

from plinth import actions
from plinth.actions import privileged, secret_str

actions_name = 'actions'


@pytest.fixture(name='popen')
def fixture_popen():
    """A fixture to patch subprocess.Popen called by privileged action."""

    with patch('subprocess.Popen') as popen:

        def call_popen(command, **kwargs):
            write_fd = int(command[8])
            if not isinstance(popen.called_with_write_fd, list):
                popen.called_with_write_fd = []

            popen.called_with_write_fd.append(write_fd)
            os.write(write_fd, bytes(popen.return_value, encoding='utf-8'))
            proc = Mock()
            proc.communicate.return_value = (b'', b'')
            proc.returncode = 0
            return proc

        popen.side_effect = call_popen
        yield popen


def test_privileged_properties():
    """Test that privileged decorator sets proper properties on the method."""

    def func():
        return

    wrapped_func = privileged(func)
    assert wrapped_func._privileged
    assert wrapped_func.__wrapped__ == func


def test_privileged_argument_vararg_check():
    """Test that privileged decorator checks for simple arguments."""

    def func_with_varargs(*_args):
        return

    def func_with_kwargs(**_kwargs):
        return

    def func_with_kwonlyargs(*_args, _kwarg):
        return

    def func_with_kwonlydefaults(*_args, _kwargs='foo'):
        return

    for func in (func_with_varargs, func_with_kwargs, func_with_kwonlyargs,
                 func_with_kwonlydefaults):
        with pytest.raises(SyntaxError):
            privileged(func)


def test_privileged_argument_annotation_check():
    """Test that privileged decorator checks for annotations to arguments."""

    def func1(_a):
        return

    def func2(_a: int, _b):
        return

    # Parameter with 'password' in the name should be typed 'secret_str'
    def func3(_password: str):
        return

    def func1_valid(_a: int, _b: dict[int, str]):
        return

    def func2_valid(_password: secret_str):
        return

    for func in (func1, func2, func3):
        with pytest.raises(SyntaxError):
            privileged(func)

    privileged(func1_valid)
    privileged(func2_valid)


@patch('plinth.actions._read_from_server')
@patch('plinth.actions._request_to_server')
@patch('plinth.actions._get_privileged_action_module_name')
def test_privileged_method_call(get_module_name, request_to_server,
                                read_from_server):
    """Test that privileged method calls the superuser action properly."""

    def func_with_args(_a: int, _b: str, _c: int = 1, _d: str = 'dval',
                       _e: str = 'eval'):
        return

    get_module_name.return_value = 'tests'
    read_from_server.return_value = {"result": "success", "return": "bar"}
    wrapped_func = privileged(func_with_args)
    return_value = wrapped_func(1, 'bval', None, _d='dnewval')
    assert return_value == 'bar'
    assert request_to_server.mock_calls == [
        call({
            'module': 'tests',
            'action': 'func_with_args',
            'args': (1, 'bval', None),
            'kwargs': {
                '_d': 'dnewval'
            }
        }),
        call().close()
    ]


@patch('plinth.actions._read_from_server')
@patch('plinth.actions._request_to_server')
@patch('plinth.actions._get_privileged_action_module_name')
def test_privileged_method_exceptions(get_module_name, request_to_server,
                                      read_from_server):
    """Test that exceptions on privileged methods are return properly."""

    def func_with_exception():
        raise TypeError('type error')

    get_module_name.return_value = 'tests'
    read_from_server.return_value = {
        'result': 'exception',
        'exception': {
            'module': 'builtins',
            'name': 'TypeError',
            'args': ['type error'],
            'traceback': [''],
            'stdout': '',
            'stderr': ''
        }
    }
    wrapped_func = privileged(func_with_exception)
    with pytest.raises(TypeError, match='type error'):
        wrapped_func()


@patch('importlib.import_module')
@patch('plinth.module_loader.get_module_import_path')
@patch('os.getuid')
def test_call_syntax_checks(getuid, get_module_import_path, import_module):
    """Test that calling a method results in proper syntax checks."""
    privileged_call = actions._privileged_call

    # Module name validation
    getuid.return_value = 0
    with pytest.raises(SyntaxError, match='Invalid module name'):
        privileged_call('foo.bar', 'x-action', {})

    # Module import test
    get_module_import_path.return_value = 'plinth.modules.test_module'
    import_module.side_effect = ModuleNotFoundError
    with pytest.raises(SyntaxError, match='Specified module not found'):
        privileged_call('test_module', 'x-action', {})

    import_module.assert_has_calls(
        [call('plinth.modules.test_module.privileged')])

    # Finding action in a module
    module = type('', (), {})
    import_module.side_effect = None
    import_module.return_value = module
    with pytest.raises(SyntaxError, match='Specified action not found'):
        privileged_call('test_module', 'x-action', {})

    # Checking if action is privileged
    def unprivileged_func():
        pass

    setattr(module, 'func', unprivileged_func)
    with pytest.raises(SyntaxError,
                       match='Specified action is not privileged action'):
        privileged_call('test-module', 'func', {})

    # Argument validation
    @actions.privileged
    def func():
        return 'foo'

    setattr(module, 'func', func)
    with pytest.raises(SyntaxError, match='Invalid arguments format'):
        privileged_call('test-module', 'func', {})

    # Successful call
    return_value = privileged_call('test-module', 'func', {
        'args': [],
        'kwargs': {}
    })
    assert return_value == {'result': 'success', 'return': 'foo'}

    # Exception call
    @actions.privileged
    def exception_func():
        raise RuntimeError('foo exception')

    setattr(module, 'func', exception_func)
    return_value = privileged_call('test-module', 'func', {
        'args': [],
        'kwargs': {}
    })
    assert return_value['result'] == 'exception'
    assert return_value['exception']['module'] == 'builtins'
    assert return_value['exception']['name'] == 'RuntimeError'
    assert return_value['exception']['args'] == ('foo exception', )
    assert isinstance(return_value['exception']['traceback'], list)
    for line in return_value['exception']['traceback']:
        assert isinstance(line, str)


def test_assert_valid_arguments():
    """Test that checking valid arguments works."""
    assert_valid = actions._privileged_assert_valid_arguments

    values = [
        None, [], 10, {}, {
            'args': []
        }, {
            'kwargs': {}
        }, {
            'args': {},
            'kwargs': {}
        }, {
            'args': [],
            'kwargs': []
        }
    ]
    for value in values:
        with pytest.raises(SyntaxError, match='Invalid arguments format'):
            assert_valid(lambda: None, value)

    def func(a: int, b: str, c: int = 3, d: str = 'foo'):
        pass

    with pytest.raises(SyntaxError, match='Too many arguments'):
        assert_valid(func, {'args': [1, 2, 3], 'kwargs': {'c': 3, 'd': 4}})

    with pytest.raises(SyntaxError, match='Too many arguments'):
        assert_valid(func, {'args': [1, 2, 3, 4, 5], 'kwargs': {}})

    with pytest.raises(SyntaxError, match='Too many arguments'):
        assert_valid(func, {
            'args': [],
            'kwargs': {
                'a': 1,
                'b': '2',
                'c': 3,
                'd': '4',
                'e': 5
            }
        })

    with pytest.raises(SyntaxError, match='Argument not provided: b'):
        assert_valid(func, {'args': [1], 'kwargs': {}})

    with pytest.raises(SyntaxError, match='Unknown argument: e'):
        assert_valid(func, {'args': [1, '2'], 'kwargs': {'e': 5}})

    with pytest.raises(SyntaxError, match='Duplicate argument: c'):
        assert_valid(func, {'args': [1, '2', 3], 'kwargs': {'c': 4}})

    with pytest.raises(TypeError, match='Expected type str for arg #1'):
        assert_valid(func, {'args': [1, 2], 'kwargs': {}})

    with pytest.raises(TypeError, match='Expected type int for arg c'):
        assert_valid(func, {'args': [1, '2'], 'kwargs': {'c': '3'}})


def test_assert_valid_type():
    """Test that type validation works as expected."""
    assert_valid = actions._privileged_assert_valid_type

    assert_valid(None, None, typing.Any)

    # Invalid values for int, str, float and Optional
    values = [[1, bool], ['foo', int], [1, str], [1, secret_str], [1, float],
              [1, typing.Optional[str]], [1, str | None],
              [1.1, typing.Union[int, str]], [1.1, int | str], [1, list],
              [1, dict], [[1], list[str]], [{
                  'a': 'b'
              }, dict[int, str]], [{
                  1: 2
              }, dict[int, str]]]
    for value in values:
        with pytest.raises(TypeError):
            assert_valid('arg', *value)

    # Valid values
    assert_valid('arg', True, bool)
    assert_valid('arg', 1, int)
    assert_valid('arg', '1', str)
    assert_valid('arg', '1', secret_str)
    assert_valid('arg', 1.1, float)
    assert_valid('arg', None, typing.Optional[int])
    assert_valid('arg', 1, typing.Optional[int])
    assert_valid('arg', None, int | None)
    assert_valid('arg', 1, int | None)
    assert_valid('arg', 1, typing.Union[int, str])
    assert_valid('arg', '1', typing.Union[int, str])
    assert_valid('arg', 1, int | str)
    assert_valid('arg', '1', int | str)
    assert_valid('arg', [], list[int])
    assert_valid('arg', ['foo'], list[str])
    assert_valid('arg', {}, dict[int, str])
    assert_valid('arg', {1: 'foo'}, dict[int, str])
