from __future__ import annotations

import os
import sys
import textwrap
from functools import reduce
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import Mock, patch

import pytest
from docutils.core import publish_string
from docutils.utils import SystemMessage
from flow.record import RecordDescriptor

from dissect.target.exceptions import PluginError, UnsupportedPluginError
from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
from dissect.target.helpers.record import EmptyRecord, create_extended_descriptor
from dissect.target.plugin import (
    FunctionDescriptor,
    FunctionDescriptorLookup,
    InternalNamespacePlugin,
    InternalPlugin,
    NamespacePlugin,
    OSPlugin,
    Plugin,
    PluginDescriptor,
    PluginDescriptorLookup,
    PluginRegistry,
    _find_py_files,
    _save_plugin_import_failure,
    alias,
    environment_variable_paths,
    export,
    find_functions,
    find_functions_by_record_field_type,
    functions,
    get_external_module_paths,
    load_modules_from_paths,
    lookup,
    plugins,
)
from dissect.target.plugins.apps.other.env import EnvironmentFilePlugin
from dissect.target.plugins.general.users import UsersPlugin
from dissect.target.plugins.os.default._os import DefaultOSPlugin
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
from dissect.target.plugins.os.unix.linux.debian._os import DebianPlugin
from dissect.target.plugins.os.unix.linux.fortios._os import FortiOSPlugin
from dissect.target.plugins.os.windows._os import WindowsPlugin
from dissect.target.target import Target

if TYPE_CHECKING:
    from collections.abc import Iterator

    from flow.record import Record
    from pytest_benchmark.fixture import BenchmarkFixture


def test_save_plugin_import_failure() -> None:
    test_trace = ["test-trace"]
    test_module_name = "test-module"

    with (
        patch("traceback.format_exception", Mock(return_value=test_trace)),
        patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry) as mock_plugins,
    ):
        _save_plugin_import_failure(test_module_name)

        assert len(mock_plugins.__failed__) == 1
        assert mock_plugins.__failed__[0].module == test_module_name
        assert mock_plugins.__failed__[0].stacktrace == test_trace


@pytest.mark.parametrize(
    ("env_value", "expected_output"),
    [
        (None, []),
        ("", []),
        (":", [Path(), Path()]),
    ],
)
def test_load_environment_variable(env_value: str | None, expected_output: list[Path]) -> None:
    with patch.object(os, "environ", {"DISSECT_PLUGINS": env_value}):
        assert environment_variable_paths() == expected_output


def test_load_module_paths() -> None:
    assert get_external_module_paths([Path(), Path()]) == [Path()]


def test_load_paths_with_env() -> None:
    with patch.object(os, "environ", {"DISSECT_PLUGINS": ":"}):
        assert get_external_module_paths([Path(), Path()]) == [Path()]


def test_load_environment_variable_empty_string() -> None:
    with patch("dissect.target.plugin._find_py_files") as mocked_find_py_files:
        load_modules_from_paths([])
        mocked_find_py_files.assert_not_called()


def test_load_environment_variable_comma_seperated_string() -> None:
    with patch("dissect.target.plugin._find_py_files") as mocked_find_py_files:
        load_modules_from_paths([Path(), Path()])
        mocked_find_py_files.assert_called_with(Path())


def test_filter_file(tmp_path: Path) -> None:
    file = tmp_path / "hello.py"
    file.touch()

    assert list(_find_py_files(file)) == [file]

    test_file = tmp_path / "non_existent_file"
    assert list(_find_py_files(test_file)) == []

    test_file = tmp_path / "__init__.py"
    test_file.touch()
    assert list(_find_py_files(test_file)) == []


@pytest.mark.parametrize(
    ("filename", "empty_list"),
    [
        ("__init__.py", True),
        ("__pycache__/help.pyc", True),
        ("hello/test.py", False),
    ],
)
def test_filter_directory(tmp_path: Path, filename: str, empty_list: bool) -> None:
    file = tmp_path / filename
    file.parent.mkdir(parents=True, exist_ok=True)
    file.touch()

    if empty_list:
        assert list(_find_py_files(tmp_path)) == []
    else:
        assert file in list(_find_py_files(tmp_path))


@pytest.mark.parametrize(
    ("filename", "expected_module"),
    [
        ("test.py", "test"),
        ("hello_world/help.py", "hello_world.help"),
        ("path/to/file.py", "path.to.file"),
    ],
)
def test_filesystem_module_registration(tmp_path: Path, filename: str, expected_module: str) -> None:
    path = tmp_path / filename
    path.parent.mkdir(parents=True, exist_ok=True)
    path.touch()

    load_modules_from_paths([tmp_path])

    assert expected_module in sys.modules


def test_plugin_registration(tmp_path: Path) -> None:
    code = """
        from dissect.target.plugin import Plugin, export


        class TestPlugin(Plugin):
            __register__ = False

            def check_compatible(self) -> None:
                return None

            @export(output="default")
            def hello_world(self):
                for x in self.target.fs.iterdir(""):
                    print(f"hello {x}")
    """
    (tmp_path / "plugin.py").write_text(textwrap.dedent(code))

    with patch("dissect.target.plugin.register") as mock_register:
        load_modules_from_paths([tmp_path])

        mock_register.assert_called_once()
        assert mock_register.call_args[0][0].__name__ == "TestPlugin"

    with patch("dissect.target.plugin.register") as mock_register:
        load_modules_from_paths([tmp_path / "plugin.py"])

        mock_register.assert_called_once()
        assert mock_register.call_args[0][0].__name__ == "TestPlugin"


@patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry)
def test_plugin_directory(mock_plugins: PluginRegistry, tmp_path: Path) -> None:
    code = """
        from dissect.target.plugin import Plugin, export

        class MyPlugin(Plugin):
            __namespace__ = {!r}

            @export
            def my_function(self):
                return "My function"
    """

    (tmp_path / "myplugin").mkdir()
    (tmp_path / "myplugin" / "__init__.py").write_text("")
    (tmp_path / "myplugin" / "_plugin.py").write_text(textwrap.dedent(code.format(None)))

    (tmp_path / "mypluginns").mkdir()
    (tmp_path / "mypluginns" / "__init__.py").write_text("")
    (tmp_path / "mypluginns" / "_plugin.py").write_text(textwrap.dedent(code.format("myns")))

    load_modules_from_paths([tmp_path])

    assert mock_plugins.__functions__.__regular__ == {
        "my_function": {
            "myplugin.MyPlugin": FunctionDescriptor(
                name="my_function",
                namespace=None,
                path="myplugin.my_function",
                exported=True,
                internal=False,
                findable=True,
                alias=False,
                output="default",
                method_name="my_function",
                module="myplugin._plugin",
                qualname="MyPlugin",
            )
        },
        "myns": {
            "mypluginns.MyPlugin": FunctionDescriptor(
                name="myns",
                namespace="myns",
                path="mypluginns",
                exported=True,
                internal=False,
                findable=True,
                alias=False,
                output="record",
                method_name="__call__",
                module="mypluginns._plugin",
                qualname="MyPlugin",
            )
        },
        "myns.my_function": {
            "mypluginns.MyPlugin": FunctionDescriptor(
                name="myns.my_function",
                namespace="myns",
                path="mypluginns.my_function",
                exported=True,
                internal=False,
                findable=True,
                alias=False,
                output="default",
                method_name="my_function",
                module="mypluginns._plugin",
                qualname="MyPlugin",
            )
        },
    }

    assert mock_plugins.__plugins__.__regular__ == {
        "myplugin.MyPlugin": PluginDescriptor(
            module="myplugin._plugin",
            qualname="MyPlugin",
            namespace=None,
            path="myplugin",
            findable=True,
            functions=["my_function"],
            exports=["my_function"],
        ),
        "mypluginns.MyPlugin": PluginDescriptor(
            module="mypluginns._plugin",
            qualname="MyPlugin",
            namespace="myns",
            path="mypluginns",
            findable=True,
            functions=["my_function", "__call__"],
            exports=["my_function", "__call__"],
        ),
    }


TestRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
    "application/test",
    [
        ("string", "value"),
    ],
)

TestRecord2 = create_extended_descriptor([UserRecordDescriptorExtension])(
    "application/test_other",
    [
        ("varint", "value"),
    ],
)


class _TestNSPlugin(NamespacePlugin):
    __namespace__ = "NS"
    __register__ = False

    @export(record=TestRecord)
    def test_all(self) -> Iterator[TestRecord]:
        # Iterate all functions of all subclasses
        yield from self.test()


class _TestSubPlugin1(_TestNSPlugin):
    __namespace__ = "t1"
    __register__ = False

    @export(record=TestRecord)
    def test(self) -> Iterator[TestRecord]:
        yield TestRecord(value="test1")


class _TestSubPlugin2(_TestNSPlugin):
    __namespace__ = "t2"
    __register__ = False

    @export(record=TestRecord)
    def test(self) -> Iterator[TestRecord]:
        yield TestRecord(value="test2")


class _TestSubPlugin3(_TestSubPlugin2):
    __namespace__ = "t3"
    __register__ = False

    # Override the test() function of t2
    @export(record=TestRecord)
    def test(self) -> Iterator[TestRecord]:
        yield TestRecord(value=self._value())

    def _value(self) -> str:
        return "test3"


class _TestSubPlugin4(_TestSubPlugin3):
    __namespace__ = "t4"
    __register__ = False

    # Do not override the test() function of t3, but change the _value function instead.
    def _value(self) -> str:
        return "test4"

    @export(record=TestRecord)
    def test_other(self) -> Iterator[TestRecord]:
        yield TestRecord(value="test4-other")

    @export(record=TestRecord)
    def test_all(self) -> Iterator[TestRecord]:
        yield TestRecord(value="overridden")


class _TestSubPlugin5(_TestNSPlugin):
    __namespace__ = "t5"
    __register__ = False

    @export(record=TestRecord2)
    def test_other(self) -> Iterator[TestRecord2]:
        yield TestRecord2(value=69)


def test_namespace_plugin(target_win: Target) -> None:
    assert "__subplugins__" in dir(_TestNSPlugin)
    # Rename the test functions to protect them from being filtered by NS

    target_win._register_plugin_functions(_TestSubPlugin1(target_win))
    target_win._register_plugin_functions(_TestSubPlugin2(target_win))
    target_win._register_plugin_functions(_TestSubPlugin3(target_win))
    target_win._register_plugin_functions(_TestSubPlugin4(target_win))
    target_win._register_plugin_functions(_TestSubPlugin5(target_win))
    target_win._register_plugin_functions(_TestNSPlugin(target_win))

    assert len(target_win.NS.__subplugins__) == 5
    assert len(target_win.NS.test.__subplugins__) == 4
    assert target_win.NS.test.__doc__ == "Return test for: t1, t2, t3, t4"
    assert [rd.name for rd in target_win.NS.test.__record__] == ["application/test"]

    assert target_win.NS.test_other.__doc__ == "Return test_other for: t4, t5"
    assert sorted([rd.name for rd in target_win.NS.test_other.__record__]) == [
        "application/test",
        "application/test_other",
    ]
    assert len(target_win.NS.test_other.__subplugins__) == 2

    assert len(list(target_win.NS.test())) == 4
    assert sorted([item.value for item in target_win.NS.test()]) == ["test1", "test2", "test3", "test4"]
    assert sorted([item.value for item in target_win.t1.test()]) == ["test1"]
    assert sorted([item.value for item in target_win.t2.test()]) == ["test2"]
    assert sorted([item.value for item in target_win.t3.test()]) == ["test3"]
    assert sorted([item.value for item in target_win.t4.test()]) == ["test4"]

    # Check whether we can access all subclass functions from the superclass
    assert sorted([item.value for item in target_win.NS.test_all()]) == ["test1", "test2", "test3", "test4"]

    # Check whether we can access the overridden function when explicitly accessing the subplugin
    assert next(target_win.t4.test_all()).value == "overridden"

    with pytest.raises(PluginError, match="Cannot merge namespace methods with different output types"):

        class _TestSubPluginFaulty(_TestNSPlugin):
            __namespace__ = "faulty"
            __register__ = False

            @export(output="yield")
            def test(self) -> Iterator[str]:
                yield "faulty"


@patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry)
def test_namespace_plugin_registration(mock_plugins: PluginRegistry) -> None:
    class _TestNSPlugin(NamespacePlugin):
        __namespace__ = "NS"

    class _TestSubPlugin1(_TestNSPlugin):
        __namespace__ = "t1"

        @export(record=TestRecord)
        def test(self) -> None: ...

    assert next(lookup("NS")).exported
    assert next(lookup("NS.test")).exported
    assert next(lookup("t1")).exported
    assert next(lookup("t1.test")).exported


@patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry)
def test_namesplace_plugin_multiple_same_module(mock_plugins: PluginRegistry) -> None:
    class NS(NamespacePlugin):
        __namespace__ = "ns"

        def check_compatible(self) -> None:
            return None

    class Foo(NS):
        __namespace__ = "foo"

        @export(output="yield")
        def baz(self) -> Iterator[str]:
            yield from ["foo"]

    class Bar(NS):
        __namespace__ = "bar"

        @export(output="yield")
        def baz(self) -> Iterator[str]:
            yield from ["bar"]

    result, _ = find_functions("*.baz")
    assert len(result) == 2
    assert sorted(desc.name for desc in result) == ["bar.baz", "foo.baz"]


@patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry)
def test_nested_namespace_getattr(mock_plugins: PluginRegistry, target_bare: Target) -> None:
    class Foo(Plugin):
        __namespace__ = "foo"

        @export(output="yield")
        def buzz(self) -> Iterator[str]:
            yield from ["buzz"]

    class FooBar(Plugin):
        __namespace__ = "foo.bar"

        @export(output="yield")
        def bazz(self) -> Iterator[str]:
            yield from ["bazz1"]

        @export(output="yield")
        def bar(self) -> Iterator[str]:
            yield from ["bar1"]

    class FooBaz(Plugin):
        __namespace__ = "foo.baz"

        @export(output="yield")
        def bazz(self) -> Iterator[str]:
            yield from ["bazz2"]

        @export(output="yield")
        def bar(self) -> Iterator[str]:
            yield from ["bar2"]

    for plugin in [Foo, FooBar, FooBaz]:
        target_bare._register_plugin_functions(plugin(target_bare))

    assert isinstance(target_bare.foo, Foo)
    assert isinstance(target_bare.foo.bar, FooBar)
    assert isinstance(target_bare.foo.baz, FooBaz)
    assert hasattr(target_bare.foo.bar, "bazz")

    with pytest.raises(AttributeError):
        target_bare.foo.bazz()

    with pytest.raises(AttributeError):
        target_bare.foo.bar.foo()

    # Test whether we can access the plugin this way
    assert next(target_bare.foo.bar.bazz()) == "bazz1"
    assert next(target_bare.foo.bar.bar()) == "bar1"
    assert next(target_bare.foo.baz.bazz()) == "bazz2"
    assert next(target_bare.foo.baz.bar()) == "bar2"
    assert next(target_bare.foo.buzz()) == "buzz"


def test_find_plugin_function_default(target_default: Target) -> None:
    found, _ = find_functions("services", target_default)

    assert len(found) == 2
    names = [item.name for item in found]
    assert "services" in names
    assert "services" in names
    paths = [item.path for item in found]
    assert "os.unix.linux.services.services" in paths
    assert "os.windows.services.services" in paths

    found, _ = find_functions("mcafee.msc", target_default)
    assert len(found) == 1
    assert found[0].path == "apps.av.mcafee.msc"

    found, _ = find_functions("webserver.access", target_default)
    assert len(found) == 1
    assert found[0].path == "apps.webserver.webserver.access"


@pytest.mark.parametrize(
    "pattern",
    [
        ("version,ips,hostname"),
        ("ips,version,hostname"),
        ("hostname,ips,version"),
        ("users,osinfo"),
        ("osinfo,users"),
    ],
)
def test_find_plugin_function_order(target_win: Target, pattern: str) -> None:
    found = ",".join(reduce(lambda rs, el: [*rs, el.method_name], find_functions(pattern, target_win)[0], []))
    assert found == pattern


class _TestIncompatiblePlugin(Plugin):
    def check_compatible(self) -> None:
        raise UnsupportedPluginError("My incompatible plugin error")


def test_incompatible_plugin(target_bare: Target) -> None:
    with pytest.raises(UnsupportedPluginError, match="My incompatible plugin error"):
        target_bare.add_plugin(_TestIncompatiblePlugin)


# A realistic slimmed down mock of a PluginRegistry
MOCK_PLUGINS = PluginRegistry(
    __plugins__=PluginDescriptorLookup(
        __regular__={
            # Non-OS, regular
            "general.osinfo.OSInfoPlugin": PluginDescriptor(
                module="dissect.target.plugins.general.osinfo",
                qualname="OSInfoPlugin",
                namespace=None,
                path="general.osinfo",
                findable=True,
                functions=["osinfo"],
                exports=["osinfo"],
            ),
            # Non-OS, with namespace
            "apps.chat.chat.ChatPlugin": PluginDescriptor(
                module="dissect.target.plugins.apps.chat.chat",
                qualname="ChatPlugin",
                namespace="chat",
                path="apps.chat.chat",
                findable=False,
                functions=["history", "__call__"],
                exports=["history", "__call__"],
            ),
            "apps.chat.msn.MSNPlugin": PluginDescriptor(
                module="dissect.target.plugins.apps.chat.msn",
                qualname="MSNPlugin",
                namespace="msn",
                path="apps.chat.msn",
                findable=True,
                functions=["history", "__call__"],
                exports=["history", "__call__"],
            ),
            # OS, regular
            "os.windows.generic.GenericPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.windows.generic",
                qualname="GenericPlugin",
                namespace=None,
                path="os.windows.generic",
                findable=True,
                functions=["domain"],
                exports=["domain"],
            ),
            "os.unix.cronjobs.CronjobPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.cronjobs",
                qualname="CronjobPlugin",
                namespace=None,
                path="os.unix.cronjobs",
                findable=True,
                functions=["cronjobs"],
                exports=["cronjobs"],
            ),
            "os.windows.regf.runkeys.RunKeysPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.windows.regf.runkeys",
                qualname="RunKeysPlugin",
                namespace=None,
                path="os.windows.regf.runkeys",
                findable=True,
                functions=["runkeys"],
                exports=["runkeys"],
            ),
            # OS, shared name
            "os.unix.linux.services.ServicesPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.linux.services",
                qualname="ServicesPlugin",
                namespace=None,
                path="os.unix.linux.services",
                findable=True,
                functions=["initd", "services", "systemd"],
                exports=["services"],
            ),
            "os.windows.services.ServicesPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.windows.services",
                qualname="ServicesPlugin",
                namespace=None,
                path="os.windows.services",
                findable=True,
                functions=["services"],
                exports=["services"],
            ),
            # OS, with default
            "os.default.locale.LocalePlugin": PluginDescriptor(
                module="dissect.target.plugins.os.default.locale",
                qualname="LocalePlugin",
                namespace=None,
                path="os.default.locale",
                findable=True,
                functions=["timezone"],
                exports=["timezone"],
            ),
            "os.unix.locale.UnixLocalePlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.locale",
                qualname="UnixLocalePlugin",
                namespace=None,
                path="os.unix.locale",
                findable=True,
                functions=["timezone"],
                exports=["timezone"],
            ),
            "os.unix.linux.fortios.locale.FortiOSLocalePlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.linux.fortios.locale",
                qualname="FortiOSLocalePlugin",
                namespace=None,
                path="os.unix.linux.fortios.locale",
                findable=True,
                functions=["timezone"],
                exports=["timezone"],
            ),
            "os.windows.locale.WindowsLocalePlugin": PluginDescriptor(
                module="dissect.target.plugins.os.windows.locale",
                qualname="WindowsLocalePlugin",
                namespace=None,
                path="os.windows.locale",
                findable=True,
                functions=["timezone"],
                exports=["timezone"],
            ),
        },
        __os__={
            "os.default._os.DefaultOSPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.default._os",
                qualname="DefaultOSPlugin",
                namespace=None,
                path="os.default._os",
                findable=True,
                functions=["hostname", "os", "os_tree"],
                exports=["hostname", "os"],
            ),
            "os.unix._os.UnixPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix._os",
                qualname="UnixPlugin",
                namespace=None,
                path="os.unix._os",
                findable=True,
                functions=["hostname", "domain", "os", "os_tree"],
                exports=["hostname", "domain", "os"],
            ),
            "os.unix.linux._os.LinuxPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.linux._os",
                qualname="LinuxPlugin",
                namespace=None,
                path="os.unix.linux._os",
                findable=True,
                functions=["hostname", "domain", "os", "os_tree"],
                exports=["hostname", "domain", "os"],
            ),
            "os.unix.linux.fortios._os.FortiOSPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.unix.linux.fortios._os",
                qualname="FortiOSPlugin",
                namespace=None,
                path="os.unix.linux.fortios._os",
                findable=True,
                functions=["hostname", "domain", "os", "os_tree"],
                exports=["hostname", "domain", "os"],
            ),
            "os.windows._os.WindowsPlugin": PluginDescriptor(
                module="dissect.target.plugins.os.windows._os",
                qualname="WindowsPlugin",
                namespace=None,
                path="os.windows._os",
                findable=True,
                functions=["hostname", "os", "os_tree"],
                exports=["hostname", "os"],
            ),
        },
        __child__={},
    ),
    __functions__=FunctionDescriptorLookup(
        __regular__={
            # Non-OS, regular
            "osinfo": {
                "general.osinfo.OSInfoPlugin": FunctionDescriptor(
                    name="osinfo",
                    namespace=None,
                    path="general.osinfo.osinfo",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="osinfo",
                    module="dissect.target.plugins.general.osinfo",
                    qualname="OSInfoPlugin",
                )
            },
            # Non-OS, with namespace
            "chat": {
                "apps.chat.chat.ChatPlugin": FunctionDescriptor(
                    name="chat",
                    namespace="chat",
                    path="apps.chat.chat",
                    exported=True,
                    internal=False,
                    findable=False,
                    alias=False,
                    output="record",
                    method_name="__call__",
                    module="dissect.target.plugins.apps.chat.chat",
                    qualname="ChatPlugin",
                )
            },
            "chat.history": {
                "apps.chat.chat.ChatPlugin": FunctionDescriptor(
                    name="chat.history",
                    namespace="chat",
                    path="apps.chat.chat.history",
                    exported=True,
                    internal=False,
                    findable=False,
                    alias=False,
                    output="record",
                    method_name="history",
                    module="dissect.target.plugins.apps.chat.chat",
                    qualname="ChatPlugin",
                )
            },
            "msn": {
                "apps.chat.msn.MSNPlugin": FunctionDescriptor(
                    name="msn",
                    namespace="msn",
                    path="apps.chat.msn",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="__call__",
                    module="dissect.target.plugins.apps.chat.msn",
                    qualname="MSNPlugin",
                )
            },
            "msn.history": {
                "apps.chat.msn.MSNPlugin": FunctionDescriptor(
                    name="msn.history",
                    namespace="msn",
                    path="apps.chat.msn.history",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="history",
                    module="dissect.target.plugins.apps.chat.msn",
                    qualname="MSNPlugin",
                )
            },
            # OS, regular
            "domain": {
                "os.windows.generic.GenericPlugin": FunctionDescriptor(
                    name="domain",
                    namespace=None,
                    path="os.windows.generic.domain",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="domain",
                    module="dissect.target.plugins.os.windows.generic",
                    qualname="GenericPlugin",
                )
            },
            "cronjobs": {
                "os.unix.cronjobs.CronjobPlugin": FunctionDescriptor(
                    name="cronjobs",
                    namespace=None,
                    path="os.unix.cronjobs.cronjobs",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="cronjobs",
                    module="dissect.target.plugins.os.unix.cronjobs",
                    qualname="CronjobPlugin",
                )
            },
            "runkeys": {
                "os.windows.regf.runkeys.RunKeysPlugin": FunctionDescriptor(
                    name="runkeys",
                    namespace=None,
                    path="os.windows.regf.runkeys.runkeys",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="runkeys",
                    module="dissect.target.plugins.os.windows.regf.runkeys",
                    qualname="RunKeysPlugin",
                )
            },
            # OS, shared name
            "services": {
                "os.windows.services.ServicesPlugin": FunctionDescriptor(
                    name="services",
                    namespace=None,
                    path="os.windows.services.services",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="services",
                    module="dissect.target.plugins.os.windows.services",
                    qualname="ServicesPlugin",
                ),
                "os.unix.linux.services.ServicesPlugin": FunctionDescriptor(
                    name="services",
                    namespace=None,
                    path="os.unix.linux.services.services",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="record",
                    method_name="services",
                    module="dissect.target.plugins.os.unix.linux.services",
                    qualname="ServicesPlugin",
                ),
            },
            # OS, with default
            "timezone": {
                "os.default.locale.LocalePlugin": FunctionDescriptor(
                    name="timezone",
                    namespace=None,
                    path="os.default.locale.timezone",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="timezone",
                    module="dissect.target.plugins.os.default.locale",
                    qualname="LocalePlugin",
                ),
                "os.unix.locale.UnixLocalePlugin": FunctionDescriptor(
                    name="timezone",
                    namespace=None,
                    path="os.unix.locale.timezone",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="timezone",
                    module="dissect.target.plugins.os.unix.locale",
                    qualname="UnixLocalePlugin",
                ),
                "os.unix.linux.fortios.locale.FortiOSLocalePlugin": FunctionDescriptor(
                    name="timezone",
                    namespace=None,
                    path="os.unix.linux.fortios.locale.timezone",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="timezone",
                    module="dissect.target.plugins.os.unix.linux.fortios.locale",
                    qualname="FortiOSLocalePlugin",
                ),
                "os.windows.locale.WindowsLocalePlugin": FunctionDescriptor(
                    name="timezone",
                    namespace=None,
                    path="os.windows.locale.timezone",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="timezone",
                    module="dissect.target.plugins.os.windows.locale",
                    qualname="WindowsLocalePlugin",
                ),
            },
        },
        __os__={
            "hostname": {
                "os.default._os.DefaultOSPlugin": FunctionDescriptor(
                    name="hostname",
                    namespace=None,
                    path="os.default._os.hostname",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="hostname",
                    module="dissect.target.plugins.os.default._os",
                    qualname="DefaultOSPlugin",
                ),
                "os.unix._os.UnixPlugin": FunctionDescriptor(
                    name="hostname",
                    namespace=None,
                    path="os.unix._os.hostname",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="hostname",
                    module="dissect.target.plugins.os.unix._os",
                    qualname="UnixPlugin",
                ),
                "os.unix.linux._os.LinuxPlugin": FunctionDescriptor(
                    name="hostname",
                    namespace=None,
                    path="os.unix.linux._os.hostname",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="hostname",
                    module="dissect.target.plugins.os.unix.linux._os",
                    qualname="LinuxPlugin",
                ),
                "os.unix.linux.fortios._os.FortiOSPlugin": FunctionDescriptor(
                    name="hostname",
                    namespace=None,
                    path="os.unix.linux.fortios._os.hostname",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="hostname",
                    module="dissect.target.plugins.os.unix.linux.fortios._os",
                    qualname="FortiOSPlugin",
                ),
                "os.windows._os.WindowsPlugin": FunctionDescriptor(
                    name="hostname",
                    namespace=None,
                    path="os.windows._os.hostname",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="hostname",
                    module="dissect.target.plugins.os.windows._os",
                    qualname="WindowsPlugin",
                ),
            },
            "os": {
                "os.default._os.DefaultOSPlugin": FunctionDescriptor(
                    name="os",
                    namespace=None,
                    path="os.default._os.os",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="os",
                    module="dissect.target.plugins.os.default._os",
                    qualname="DefaultOSPlugin",
                ),
                "os.unix._os.UnixPlugin": FunctionDescriptor(
                    name="os",
                    namespace=None,
                    path="os.unix._os.os",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="os",
                    module="dissect.target.plugins.os.unix._os",
                    qualname="UnixPlugin",
                ),
                "os.unix.linux._os.LinuxPlugin": FunctionDescriptor(
                    name="os",
                    namespace=None,
                    path="os.unix.linux._os.os",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="os",
                    module="dissect.target.plugins.os.unix.linux._os",
                    qualname="LinuxPlugin",
                ),
                "os.unix.linux.fortios._os.FortiOSPlugin": FunctionDescriptor(
                    name="os",
                    namespace=None,
                    path="os.unix.linux.fortios._os.os",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="os",
                    module="dissect.target.plugins.os.unix.linux.fortios._os",
                    qualname="FortiOSPlugin",
                ),
                "os.windows._os.WindowsPlugin": FunctionDescriptor(
                    name="os",
                    namespace=None,
                    path="os.windows._os.os",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="os",
                    module="dissect.target.plugins.os.windows._os",
                    qualname="WindowsPlugin",
                ),
            },
            "domain": {
                "os.unix._os.UnixPlugin": FunctionDescriptor(
                    name="domain",
                    namespace=None,
                    path="os.unix._os.domain",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="domain",
                    module="dissect.target.plugins.os.unix._os",
                    qualname="UnixPlugin",
                ),
                "os.unix.linux._os.LinuxPlugin": FunctionDescriptor(
                    name="domain",
                    namespace=None,
                    path="os.unix.linux._os.domain",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="domain",
                    module="dissect.target.plugins.os.unix.linux._os",
                    qualname="LinuxPlugin",
                ),
                "os.unix.linux.fortios._os.FortiOSPlugin": FunctionDescriptor(
                    name="domain",
                    namespace=None,
                    path="os.unix.linux.fortios._os.domain",
                    exported=True,
                    internal=False,
                    findable=True,
                    alias=False,
                    output="default",
                    method_name="domain",
                    module="dissect.target.plugins.os.unix.linux.fortios._os",
                    qualname="FortiOSPlugin",
                ),
            },
        },
        __child__={},
    ),
    __ostree__={
        "os": {
            "default": {},
            "unix": {
                "linux": {
                    "fortios": {},
                }
            },
            "windows": {},
        }
    },
    __failed__=[],
)


@pytest.mark.parametrize(
    ("osfilter", "index", "expected_plugins"),
    [
        (
            None,
            "__regular__",
            [
                "dissect.target.plugins.general.osinfo",
                "dissect.target.plugins.apps.chat.chat",
                "dissect.target.plugins.apps.chat.msn",
                "dissect.target.plugins.os.windows.generic",
                "dissect.target.plugins.os.unix.cronjobs",
                "dissect.target.plugins.os.windows.regf.runkeys",
                "dissect.target.plugins.os.windows.services",
                "dissect.target.plugins.os.unix.linux.services",
                "dissect.target.plugins.os.default.locale",
                "dissect.target.plugins.os.unix.locale",
                "dissect.target.plugins.os.unix.linux.fortios.locale",
                "dissect.target.plugins.os.windows.locale",
            ],
        ),
        (
            None,
            "__os__",
            [
                "dissect.target.plugins.os.default._os",
                "dissect.target.plugins.os.unix._os",
                "dissect.target.plugins.os.unix.linux._os",
                "dissect.target.plugins.os.unix.linux.fortios._os",
                "dissect.target.plugins.os.windows._os",
            ],
        ),
        (
            "os.unix._os",
            "__regular__",
            [
                "dissect.target.plugins.general.osinfo",
                "dissect.target.plugins.apps.chat.chat",
                "dissect.target.plugins.apps.chat.msn",
                "dissect.target.plugins.os.unix.cronjobs",
                "dissect.target.plugins.os.unix.locale",
            ],
        ),
        (
            "os.unix.linux.fortios._os",
            "__regular__",
            [
                "dissect.target.plugins.general.osinfo",
                "dissect.target.plugins.apps.chat.chat",
                "dissect.target.plugins.apps.chat.msn",
                "dissect.target.plugins.os.unix.cronjobs",
                "dissect.target.plugins.os.unix.locale",
                "dissect.target.plugins.os.unix.linux.services",
                "dissect.target.plugins.os.unix.linux.fortios.locale",
            ],
        ),
        (
            "os.windows._os",
            "__regular__",
            [
                "dissect.target.plugins.general.osinfo",
                "dissect.target.plugins.apps.chat.chat",
                "dissect.target.plugins.apps.chat.msn",
                "dissect.target.plugins.os.windows.generic",
                "dissect.target.plugins.os.windows.regf.runkeys",
                "dissect.target.plugins.os.windows.services",
                "dissect.target.plugins.os.windows.locale",
            ],
        ),
        (
            "os.bar._os",
            "__regular__",
            [
                "dissect.target.plugins.general.osinfo",
                "dissect.target.plugins.apps.chat.chat",
                "dissect.target.plugins.apps.chat.msn",
            ],
        ),
        (
            "os.windows._os",
            "__os__",
            [],
        ),
    ],
)
def test_plugins(
    osfilter: str,
    index: str,
    expected_plugins: list[str],
) -> None:
    with (
        patch("dissect.target.plugin._get_plugins", return_value=MOCK_PLUGINS),
        patch("dissect.target.plugin._module_path", return_value=osfilter),
    ):
        if osfilter is not None:
            # osfilter must be a class or None
            osfilter = Mock

        plugin_descriptors = plugins(osfilter=osfilter, index=index)

        assert sorted(desc.module for desc in plugin_descriptors) == sorted(expected_plugins)


def test_plugins_default_plugin(target_default: Target) -> None:
    all_plugins = list(plugins())
    default_plugin_plugins = list(plugins(osfilter=target_default._os_plugin))

    assert default_plugin_plugins == all_plugins

    # The all_with_home is a sentinel function, which should be loaded for a
    # target with DefaultOSPlugin as OS plugin.
    sentinel_function = "all_with_home"
    has_sentinel_function = False
    for p in default_plugin_plugins:
        if sentinel_function in p.functions:
            has_sentinel_function = True
            break

    assert has_sentinel_function

    default_os_plugin_desc = plugins(osfilter=target_default._os_plugin, index="__os__")

    assert len(list(default_os_plugin_desc)) == 1


@pytest.mark.parametrize(
    ("os_plugin", "pattern", "expected_paths"),
    [
        # Direct match
        (DefaultOSPlugin, "osinfo", ["general.osinfo.osinfo"]),
        # Direct match with namespace, regardless of `findable` status
        (DefaultOSPlugin, "chat.history", ["apps.chat.chat.history"]),
        (DefaultOSPlugin, "msn.history", ["apps.chat.msn.history"]),
        # Find with tree search using wildcard
        (DefaultOSPlugin, "general.*", ["general.osinfo.osinfo"]),
        # Find with tree search using implicit wildcard
        (DefaultOSPlugin, "general.osinfo", ["general.osinfo.osinfo"]),
        # Find with exact tree match
        (DefaultOSPlugin, "general.osinfo.osinfo", ["general.osinfo.osinfo"]),
        # Find with tree search using more complicated patterns
        (DefaultOSPlugin, "general.[!o]*", []),
        (DefaultOSPlugin, "general.[!x]*", ["general.osinfo.osinfo"]),
        (DefaultOSPlugin, "general.??????.??????", ["general.osinfo.osinfo"]),
        # Namespaces do not match
        (DefaultOSPlugin, "chat.*", []),
        # Part of module paths do not match
        (DefaultOSPlugin, "generic", []),
        # OS direct match only matches within the same OS plugin
        (DefaultOSPlugin, "hostname", ["os.default._os.hostname"]),
        (WindowsPlugin, "hostname", ["os.windows._os.hostname"]),
        (LinuxPlugin, "hostname", ["os.unix.linux._os.hostname", "os.unix._os.hostname"]),
        # OS tree search only matches within the same OS plugin
        (DefaultOSPlugin, "os.windows._os.hostname", []),
        (WindowsPlugin, "os.default._os.hostname", []),
        (DefaultOSPlugin, "os.*.hostname", ["os.default._os.hostname"]),
        (WindowsPlugin, "os.*.hostname", ["os.windows._os.hostname"]),
        # # "os" hits the direct match, not the tree search
        (DefaultOSPlugin, "os", ["os.default._os.os"]),
        (WindowsPlugin, "os", ["os.windows._os.os"]),
        # Wildcard matches all regular functions and OS functions (within the same OS)
        (
            DefaultOSPlugin,
            "*",
            [
                "os.unix.linux.fortios.locale.timezone",
                "os.windows.regf.runkeys.runkeys",
                "os.unix.linux.services.services",
                "apps.chat.msn.history",
                "os.windows.generic.domain",
                "os.unix.cronjobs.cronjobs",
                "os.windows.services.services",
                "os.default.locale.timezone",
                "os.unix.locale.timezone",
                "os.windows.locale.timezone",
                "os.default._os.hostname",
                "os.default._os.os",
                "general.osinfo.osinfo",
            ],
        ),
        (
            WindowsPlugin,
            "*",
            [
                "os.windows.regf.runkeys.runkeys",
                "apps.chat.msn.history",
                "os.windows.generic.domain",
                "os.windows.services.services",
                "os.windows.locale.timezone",
                "os.windows._os.hostname",
                "os.windows._os.os",
                "general.osinfo.osinfo",
            ],
        ),
        # Some OS functions only exist on certain OS plugins
        (LinuxPlugin, "os.*.domain", ["os.unix.linux._os.domain", "os.unix._os.domain"]),
        # # Some plugins have complicated paths (i.e. LocalePlugin)
        (LinuxPlugin, "timezone", ["os.unix.locale.timezone"]),
        (FortiOSPlugin, "timezone", ["os.unix.linux.fortios.locale.timezone", "os.unix.locale.timezone"]),
        # Some plugins have overlapping names (i.e. "services")
        (FortiOSPlugin, "services", ["os.unix.linux.services.services"]),
        (WindowsPlugin, "services", ["os.windows.services.services"]),
    ],
)
def test_find_functions(os_plugin: type[OSPlugin], pattern: str, expected_paths: list[str]) -> None:
    with patch("dissect.target.plugin._get_plugins", return_value=MOCK_PLUGINS):
        target = Target()
        target._os_plugin = os_plugin

        found, _ = find_functions(pattern, target)
        assert [desc.path for desc in found] == expected_paths


def test_find_functions_windows(target_win: Target) -> None:
    found, _ = find_functions("services", target_win)

    assert len(found) == 1
    assert found[0].name == "services"
    assert found[0].path == "os.windows.services.services"


def test_find_functions_linux(target_linux: Target) -> None:
    found, _ = find_functions("services", target_linux)

    assert len(found) == 1
    assert found[0].name == "services"
    assert found[0].path == "os.unix.linux.services.services"


def test_find_functions_compatible_check(target_linux: Target) -> None:
    """Test if we correctly check for compatibility in ``find_functions`` and ``_filter_compatible``."""

    found, _ = find_functions("*", target_linux, compatibility=True)
    assert "os.unix.log.messages.syslog.syslog" not in [f"{f.path}.{f.name}" for f in found]

    with patch("dissect.target.plugins.apps.browser.chrome.ChromePlugin.check_compatible", return_value=None):
        found, _ = find_functions("*", target_linux, compatibility=True)
        functions = [f.path for f in found]
        assert "apps.browser.chrome.cookies" in functions
        assert "apps.browser.chrome.history" in functions


@pytest.mark.benchmark
def test_benchmark_functions_compatible_check(target_unix_users: Target, benchmark: BenchmarkFixture) -> None:
    """Benchmark ``_filter_compatible`` performance."""
    benchmark(lambda: find_functions("*", target_unix_users, compatibility=True))


def test_function_aliases(target_default: Target) -> None:
    """Test if alias functions are tagged as such correctly."""

    # function that is an alias should have an alias property set to True
    syslog_fd = find_functions("syslog", target_default)[0][0]
    assert syslog_fd
    assert syslog_fd.path == "os.unix.log.messages.syslog"
    assert syslog_fd.exported
    assert syslog_fd.alias

    # function that is not an alias should have an alias property set to False
    messages_fd = find_functions("messages", target_default)[0][0]
    assert messages_fd
    assert messages_fd.path == "os.unix.log.messages.messages"
    assert not messages_fd.alias


def test_function_required_arguments(target_default: Target) -> None:
    """Test if functions with required arguments are tagged as such correctly."""

    # function without any arguments should have an args property with an empty list
    syslog_fd = find_functions("syslog", target_default)[0][0]
    assert syslog_fd
    assert not syslog_fd.args

    # function with an argument should have an args property filled
    envfile_fd = find_functions("envfile", target_default)[0][0]
    assert envfile_fd
    assert envfile_fd.args == [
        (
            ("--env-path",),
            {
                "help": "path to scan environment files in",
                "required": True,
            },
        ),
        (
            ("--extension",),
            {
                "default": "env",
                "help": "extension of files to scan",
            },
        ),
    ]


def test_plugin_runtime_info() -> None:
    plugin_desc = next(p for p in plugins() if p.path == "general.users")
    assert plugin_desc.cls is UsersPlugin

    func_desc = next(p for p in functions() if p.path == "apps.other.env.envfile")
    assert func_desc.cls is EnvironmentFilePlugin
    assert func_desc.func is EnvironmentFilePlugin.envfile
    assert func_desc.record is EnvironmentFilePlugin.envfile.__record__
    assert func_desc.args == EnvironmentFilePlugin.envfile.__args__


def test_find_by_record_field_type(target_default: Target) -> None:
    assert "filesystem.walkfs.walkfs" in [desc.path for desc in find_functions_by_record_field_type("path")]
    assert "apps.other.env.envfile" in [
        desc.path for desc in find_functions_by_record_field_type("path", target_default, compatibility=True)
    ]

    with patch(
        "dissect.target.plugin.functions",
        return_value=[
            FunctionDescriptor(
                name="test",
                namespace=None,
                path="test",
                exported=True,
                internal=False,
                findable=True,
                alias=False,
                output="record",
                method_name="test",
                module="test",
                qualname="Test",
            )
        ],
    ):
        with pytest.raises(PluginError, match="An exception occurred while trying to load a plugin: test"):
            list(find_functions_by_record_field_type("path"))

        assert list(find_functions_by_record_field_type("path", ignore_load_errors=True)) == []


@pytest.mark.parametrize(
    "method_name",
    [
        "hostname",
        "ips",
        "version",
        "os",
        "architecture",
    ],
)
def test_os_plugin_property_methods(target_bare: Target, method_name: str) -> None:
    os_plugin = OSPlugin(target_bare)
    with pytest.raises(NotImplementedError):
        getattr(os_plugin, method_name)


class MockOS1(OSPlugin):
    __register__ = False

    @export(property=True)
    def hostname(self) -> str | None:
        pass

    @export(property=True)
    def ips(self) -> list[str]:
        pass

    @export(property=True)
    def version(self) -> str | None:
        pass

    @export(record=EmptyRecord)
    def users(self) -> list[Record]:
        pass

    @export(property=True)
    def os(self) -> str:
        pass

    @export(property=True)
    def architecture(self) -> str | None:
        pass


class MockOS2(OSPlugin):
    __register__ = False

    @export(property=True)
    def hostname(self) -> str | None:
        """Test docstring hostname."""

    @export(property=True)
    def ips(self) -> list[str]:
        """Test docstring ips."""

    @export(property=True)
    def version(self) -> str | None:
        """Test docstring version."""

    @export(record=EmptyRecord)
    def users(self) -> list[Record]:
        """Test docstring users."""

    @export(property=True)
    def os(self) -> str:
        """Test docstring os."""

    @export(property=True)
    def architecture(self) -> str | None:
        """Test docstring architecture."""


@pytest.mark.parametrize(
    ("subclass", "replaced"),
    [
        (MockOS1, True),
        (MockOS2, False),
    ],
)
def test_os_plugin___init_subclass__(subclass: type[OSPlugin], replaced: bool) -> None:
    exported_methods = {
        "hostname",
        "ips",
        "version",
        "users",
        "os",
        "architecture",
    }

    for method_name in exported_methods:
        os_method = getattr(OSPlugin, method_name)
        if isinstance(os_method, property):
            os_method = os_method.fget
        os_docstring = os_method.__doc__

        subclass_method = getattr(subclass, method_name)
        if isinstance(subclass_method, property):
            subclass_method = subclass_method.fget
        subclass_docstring = subclass_method.__doc__

        assert (os_docstring == subclass_docstring) is replaced
        if not replaced:
            assert subclass_docstring == f"Test docstring {method_name}."


@pytest.mark.parametrize(
    argnames=("os_plugin", "results"),
    argvalues=[
        (LinuxPlugin, ["linux", "unix"]),
        (FortiOSPlugin, ["fortios", "linux", "unix"]),
        (DebianPlugin, ["linux", "unix"]),
    ],
)
def test_os_tree(target_bare: Target, os_plugin: type[OSPlugin], results: list[str]) -> None:
    """Test if we correctly return the OS name tree."""
    target_bare._os_plugin = os_plugin
    target_bare.apply()
    os_funcs = [record.name for record in target_bare.osinfo()]

    # Ensure that os_tree is not present in os_info
    assert "os_tree" not in os_funcs
    assert target_bare.os_tree() == results


class _TestInternalPlugin(InternalPlugin):
    def test(self) -> None:
        pass


def test_internal_plugin() -> None:
    assert "test" not in _TestInternalPlugin.__exports__
    assert "test" in _TestInternalPlugin.__functions__


class _TestInternalNamespacePlugin(InternalNamespacePlugin):
    __namespace__ = "NS"

    def test(self) -> None:
        pass


def test_internal_namespace_plugin() -> None:
    assert "__subplugins__" in dir(_TestInternalNamespacePlugin)
    assert "test" not in _TestInternalNamespacePlugin.__exports__
    assert "test" in _TestInternalNamespacePlugin.__functions__


class ExampleFooPlugin(Plugin):
    """Example Foo Plugin."""

    __register__ = False

    def check_compatible(self) -> None:
        return

    @export
    @alias("bar")
    @alias(name="baz")
    def foo(self) -> Iterator[str]:
        """Yield foo!."""
        yield "foo!"


def test_plugin_alias(target_bare: Target) -> None:
    """Test ``@alias`` decorator behaviour."""
    target_bare.add_plugin(ExampleFooPlugin)
    assert target_bare.has_function("foo")
    assert target_bare.foo.__aliases__ == ["baz", "bar"]
    assert target_bare.has_function("bar")
    assert target_bare.has_function("baz")
    assert list(target_bare.foo()) == list(target_bare.bar()) == list(target_bare.baz())


@pytest.mark.parametrize(
    "descriptor",
    find_functions("*", Target(), compatibility=False, show_hidden=True)[0],
    ids=lambda d: d.path,
)
def test_exported_plugin_format(descriptor: FunctionDescriptor) -> None:
    """This test checks plugin style guide conformity for all exported plugins.

    References:
        - https://docs.dissect.tools/en/latest/contributing/style-guide.html
    """
    # Ignore DefaultOSPlugin, NamespacePlugin and OSPlugin instances
    if issubclass(descriptor.cls, (NamespacePlugin, OSPlugin)) or descriptor.cls is DefaultOSPlugin:
        return

    # Plugin method should specify what it returns
    assert descriptor.output in ["record", "yield", "default", "none"], (
        f"Invalid output_type for function {descriptor.func.__qualname__}"
    )

    annotations = None

    if hasattr(descriptor.func, "__annotations__"):
        annotations = descriptor.func.__annotations__

    elif isinstance(descriptor.func, property):
        annotations = descriptor.func.fget.__annotations__

    # Plugin method should have a return annotation
    assert annotations
    assert "return" in annotations, f"No return type annotation for function {descriptor.func.__qualname__}"

    # TODO: Check if the annotations make sense with the provided output_type

    # Plugin method should have a docstring
    method_doc_str = descriptor.func.__doc__
    assert isinstance(method_doc_str, str), f"No docstring for function {descriptor.func.__qualname__}"
    assert method_doc_str != "", f"Empty docstring for function {descriptor.func.__qualname__}"

    # The method docstring should compile to rst without warnings
    assert_valid_rst(method_doc_str)

    # The method docstring should follow our conventions
    assert_compliant_rst(method_doc_str)

    # Plugin class should have a docstring
    class_doc_str = descriptor.cls.__doc__
    assert isinstance(class_doc_str, str), f"No docstring for class {descriptor.cls.__name__}"
    assert class_doc_str != "", f"Empty docstring for class {descriptor.cls.__name__}"

    # The class docstring should compile to rst without warnings
    assert_valid_rst(class_doc_str)

    # The class docstring should follow our conventions
    assert_compliant_rst(class_doc_str)

    # Arguments of the plugin should define their type and if they are required (explicitly or implicitly).
    for arg in descriptor.args:
        names, settings = arg
        is_bool_action = settings.get("action", "") in (
            "store_true",
            "store_false",
        )

        assert names, f"No argument names for argument of function {descriptor.func.__qualname__}"
        assert sorted(names, key=len) == list(names), (
            f"Argument names {names!r} for function {descriptor.func.__qualname__} should specify short form first"
        )

        assert settings.get("default", 1) is not None, (
            f"Superfluous default of None for argument {names[0]} in function {descriptor.func.__qualname__}: "
            "default is implied as None already."
        )

        assert settings.get("help"), f"No help text for argument {names[0]} in function {descriptor.func.__qualname__}"

        dest = settings.get("dest") or names[-1].strip("-").replace("-", "_")
        assert dest in annotations, (
            f"Missing type annotation for argument {dest} in function {descriptor.func.__qualname__}"
        )

        # TODO: More strictly check type annotation, use a contains right now to also match optionals
        type_ = "bool" if is_bool_action else getattr(settings.get("type"), "__name__", "str")
        assert type_ in annotations[dest], (
            f"Invalid type annotation for argument {dest} in function {descriptor.func.__qualname__} "
            f"({annotations[dest]} instead of {type_})"
        )

        assert settings.get("type") is not str, (
            f"Superfluous type of str for argument {names[0]} in function {descriptor.func.__qualname__}: "
            "type is implied as str by default."
        )

        # Inverse checks

        if settings.get("required"):
            assert not settings.get("default"), (
                "It does not make sense to set an argument to required and have a default value"
                f"in {names[0]} in function {descriptor.func.__qualname__}"
            )

        if "required" in settings:
            assert settings.get("required"), (
                f"Superfluous required of False for argument {names[0]} in function {descriptor.func.__qualname__}: "
                "required is implied as False already."
            )

        if is_bool_action:
            assert "type" not in settings, (
                f"Type should not be set for store_true or store_false in {names[0]} in "
                f"function {descriptor.func.__qualname__}: "
                "type is implied as boolean already."
            )

            assert "default" not in settings, (
                f"Default should not be set for store_true or store_false in {names[0]} in "
                f"function {descriptor.func.__qualname__}: "
                "default is implied as opposite boolean already."
            )


def test_plugin_record_field_and_name_consistency() -> None:
    """Test that ensures that record fields with the same name across different plugins translate to the
    same normalized type, and that no two records share the same name.

    Consider the following TargetRecordDescriptors for plugins X, Y, Z, A and B::

        # Field consistency example
        RecordX = TargetRecordDescriptor("record/x", [("varint", "shared_field")])
        RecordY = TargetRecordDescriptor("record/y", [("path", "shared_field")])
        RecordZ = TargetRecordDescriptor("record/z", [("string", "shared_field")])

        # Record name consistency example
        RecordA = TargetRecordDescriptor("record/a", [("string", "field1")])
        RecordB = TargetRecordDescriptor("record/a", [("varint", "field2")])

    In the first example, ``RecordX`` will fail the test, as the field ``shared_field`` cannot be ``varint`` in
    one record also being used as ``string``/``path`` in others. ``RecordY`` and ``RecordZ`` do
    not conflict, as both translate to the same ``wildcard`` type.

    In the second example, ``RecordA`` and ``RecordB`` will fail the test because they both define the name
    ``record/a``. Duplicate record names may cause collisions in export formats or downstream processing.

    Field type normalization is based on ``FIELD_TYPES_MAP``, loosely aligned with flow.record and
    ElasticSearch type mappings.

    References:
        - https://elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html
        - https://github.com/fox-it/flow.record/tree/main/flow/record/fieldtypes
        - https://github.com/JSCU-NL/dissect-elastic
    """
    seen_records: set[RecordDescriptor] = set()
    seen_field_names: set[str] = set()
    seen_field_types: dict[str, tuple[str | None, RecordDescriptor]] = {}
    seen_record_names: set[str] = set()
    inconsistencies: set[str] = set()

    FIELD_TYPES_MAP = {
        # strings
        "string": "string",
        "stringlist": "string",
        "wstring": "string",
        "path": "string",
        "uri": "string",
        "command": "string",
        "dynamic": "string",
        # ints
        "varint": "int",
        "filesize": "int",
        "uint32": "int",
        "uint16": "int",
        "float": "float",
        # ip / cidr
        "net.ipaddress": "ip",
        "net.ipnetwork": "ip_range",
        "net.ipinterface": "ip_range",
        # dates
        "datetime": "datetime",
        # other
        "boolean": "boolean",
        "bytes": "binary",
        "digest": "keyword",
    }

    for descriptor in find_functions("*", Target(), compatibility=False, show_hidden=True)[0]:
        # Test if plugin function record fields make sense and do not conflict with other records.
        if descriptor.output != "record" or not hasattr(descriptor, "record"):
            continue

        # Functions can yield a single record or a list of records.
        records = descriptor.record if isinstance(descriptor.record, list) else [descriptor.record]

        for record in records:
            assert isinstance(record, RecordDescriptor), (
                f"{record!r} of function {descriptor!r} is not of type RecordDescriptor"
            )
            if record.name != "empty":
                assert record.fields, f"{record!r} has no fields"

            if record in seen_records:
                continue

            seen_records.add(record)
            # Detect duplicate record names
            if record.name in seen_record_names:
                inconsistencies.add(f"<{record.name}> has duplicates")
            else:
                seen_record_names.add(record.name)

            for name, field in record.fields.items():
                # Make sure field names have the same type when translated. This check does not save multiple field
                # name and typenames, this is a bare-minumum check only.

                # We only care about the field type, not if it is a list of that type.
                field_typename = field.typename.replace("[]", "")

                assert field_typename in FIELD_TYPES_MAP, (
                    f"Field type {field_typename} is not mapped in FIELD_TYPES_MAP, please add it manually."
                )

                if name in seen_field_names:
                    seen_typename, seen_record = seen_field_types[name]
                    if FIELD_TYPES_MAP[seen_typename] != FIELD_TYPES_MAP[field_typename]:
                        inconsistencies.add(
                            f"<{record.name} ({field.typename!r}, '{name}')> is duplicate mismatch of <{seen_record.name} ({seen_typename!r}, '{name}')>"  # noqa: E501
                        )

                else:
                    seen_field_names.add(name)
                    seen_field_types[name] = (field_typename, record)

    if inconsistencies:
        pytest.fail(
            f"Found {len(inconsistencies)} inconsistencies in RecordDescriptors:\n" + "\n".join(inconsistencies)
        )


def assert_valid_rst(src: str) -> None:
    """Attempts to compile the given string to rst."""

    try:
        publish_string(src, settings_overrides={"halt_level": 2})

    except SystemMessage as e:
        # Limited context was provided to docutils, so some exceptions could incorrectly be raised.
        # We can assume that if the rst is truly invalid this will also be caught by `tox -e build-docs`.
        if "Unknown interpreted text role" not in str(e):
            pytest.fail(f"Invalid rst: {e}", pytrace=False)


def assert_compliant_rst(src: str) -> None:
    """Makes sure that the given rst docstring follows the project's conventions."""

    # Explicit message stating we want References instead of Resources to prevent confusion
    if "Resources:\n" in src:
        pytest.fail(f"Invalid rst: docstring contains 'Resources' instead of 'References': {src!r}", pytrace=False)

    # Generic message stating lists should start with References (assumes lists always have at least one 'http')
    if "- http" in src and "References:\n" not in src:
        pytest.fail(f"Invalid rst: docstring contains list but does not mention 'References': {src!r}", pytrace=False)

    # Make sure we use stripes instead of bullets (assumes lists always have at least one 'http')
    if "* http" in src:
        pytest.fail(f"Invalid rst: docstring contains bullet instead of dash in list: {src!r}", pytrace=False)


@pytest.mark.parametrize(
    "descriptor",
    [descriptor for descriptor in plugins() if descriptor.namespace and "." in descriptor.namespace],
    ids=lambda descriptor: descriptor.namespace,
)
def test_nested_namespace_consistency(descriptor: PluginDescriptor) -> None:
    """Test whether all parts of nested namespaces exist and that there are no conflicts with other functions."""

    parts = descriptor.namespace.split(".")
    for i in range(len(parts)):
        part = ".".join(parts[: i + 1])
        result = list(lookup(part))

        if not result:
            pytest.fail(f"Unreachable namespace {descriptor.namespace!r}, namespace {part!r} does not exist.")

        if len(result) > 1:
            conflicts = ", ".join(
                f"{desc.name} ({desc.module}.{desc.qualname})" for desc in result if desc.namespace != part
            )
            pytest.fail(f"Namespace name {descriptor.namespace!r} has conflicts with function name: {conflicts}")


@pytest.mark.parametrize(
    "descriptor",
    # Match plugin classes which are a *direct* base of NamespacePlugin only using :meth:`Plugin.__bases__`,
    # instead of using ``issubclass`` which would also yield indirectly inherited Plugin classes.
    [descriptor for descriptor in plugins() if NamespacePlugin in descriptor.cls.__bases__],
    ids=lambda descriptor: descriptor.qualname,
)
def test_namespace_class_usage(descriptor: PluginDescriptor) -> None:
    """This test checks if :class:`NamespacePlugin` usage is correct.

    :class:`NamespacePlugin` is reserved for "grouping" other plugins of the same category. See for
    example :class:`BrowserPlugin` or :class:`WebserverPlugin`.

    If you want to expose plugin functions under a shared name, e.g. ``foo.bar`` and ``foo.baz``,
    you should use :class:`Plugin` with ``Plugin.__namespace__ = "foo"`` instead.

    References:
        - https://github.com/fox-it/dissect.target/issues/1180
    """

    assert descriptor.cls.__subclasses__(), (
        f"NamespacePlugin {descriptor.module}.{descriptor.qualname} has no subclasses, are you sure you're using NamespacePlugin correctly?"  # noqa: E501
    )
