#!/usr/bin/env -S python3 -B

# Copyright (c) 2023 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import relative_importer  # isort: split # noqa: F401

import asyncio
import importlib
import os
import sys
import traceback
from dataclasses import dataclass

import click
from matter_yamltests.definitions import SpecDefinitionsFromPaths
from matter_yamltests.parser import TestParserConfig
from matter_yamltests.parser_builder import TestParserBuilderConfig
from matter_yamltests.parser_config import TestConfigParser
from matter_yamltests.pseudo_clusters.pseudo_clusters import PseudoClusters, get_default_pseudo_clusters
from matter_yamltests.runner import TestRunner, TestRunnerConfig, TestRunnerOptions
from matter_yamltests.websocket_runner import WebSocketRunner, WebSocketRunnerConfig
from paths_finder import PathsFinder
from tests_finder import TestsFinder
from tests_logger import TestParserLogger, TestRunnerLogger, WebSocketRunnerLogger

#
# Options
#

_DEFAULT_CONFIG_NAME = TestsFinder.get_default_configuration_name()
_DEFAULT_CONFIG_DIR = TestsFinder.get_default_configuration_directory()
_DEFAULT_SPECIFICATIONS_DIR = 'src/app/zap-templates/zcl/data-model/chip/*.xml'
_DEFAULT_PICS_FILE = 'src/app/tests/suites/certification/ci-pics-values'


def get_custom_pseudo_clusters(additional_pseudo_clusters_directory: str):
    clusters = get_default_pseudo_clusters()

    if additional_pseudo_clusters_directory:
        sys.path.insert(0, additional_pseudo_clusters_directory)
        for filepath in os.listdir(additional_pseudo_clusters_directory):
            if filepath != '__init__.py' and filepath[-3:] == '.py':
                module = importlib.import_module(f'{filepath[:-3]}')
                constructor = getattr(module, module.__name__)
                if constructor:
                    clusters.add(constructor())

    return clusters


def test_parser_options(f):
    f = click.option('--configuration_name', type=str, show_default=True, default=_DEFAULT_CONFIG_NAME,
                     help='Name of the collection configuration json file to use.')(f)
    f = click.option('--configuration_directory', type=click.Path(exists=True), show_default=True, default=_DEFAULT_CONFIG_DIR,
                     help='Path to the directory containing the tests configuration.')(f)
    f = click.option('--specifications_paths', type=click.Path(), show_default=True, default=_DEFAULT_SPECIFICATIONS_DIR,
                     help='Path to a set of files containing clusters definitions.')(f)
    f = click.option('--PICS', type=click.Path(exists=True), show_default=True, default=_DEFAULT_PICS_FILE,
                     help='Path to the PICS file to use.')(f)
    f = click.option('--stop_on_error', type=bool, show_default=True, default=True,
                     help='Stop parsing on first error.')(f)
    f = click.option('--use_default_pseudo_clusters', type=bool, show_default=True, default=True,
                     help='If enable this option use the set of default clusters provided by the matter_yamltests package.')(f)
    f = click.option('--additional_pseudo_clusters_directory', type=click.Path(), show_default=True, default=None,
                     help='Path to a directory containing additional pseudo clusters.')(f)
    return f


def test_runner_options(f):
    f = click.option('--adapter', type=str, default=None, required=True, show_default=True,
                     help='The adapter to run the test with.')(f)
    f = click.option('--stop_on_error', type=bool, default=True, show_default=True,
                     help='Stop the test suite on first error.')(f)
    f = click.option('--stop_on_warning', type=bool, default=False, show_default=True,
                     help='Stop the test suite on first warning.')(f)
    f = click.option('--stop_at_number', type=int, default=-1, show_default=True,
                     help='Stop the the test suite at the specified test number.')(f)
    f = click.option('--show_adapter_logs', type=bool, default=False, show_default=True,
                     help='Show additional logs provided by the adapter.')(f)
    f = click.option('--show_adapter_logs_on_error', type=bool, default=True, show_default=True,
                     help='Show additional logs provided by the adapter on error.')(f)
    f = click.option('--use_test_harness_log_format', type=bool, default=False, show_default=True,
                     help='Use the test harness log format.')(f)
    f = click.option('--delay-in-ms', type=int, default=0, show_default=True,
                     help='Add a delay between test suite steps.')(f)
    return f


def websocket_runner_options(f):
    f = click.option('--server_address', type=str, default='localhost', show_default=True,
                     help='The websocket server address to connect to.')(f)
    f = click.option('--server_port', type=int, default=9002, show_default=True,
                     help='The websocket server port to connect to.')(f)
    f = click.option('--server_name', type=str, default=None,
                     help='Name of a websocket server to run at launch.')(f)
    f = click.option('--server_path', type=click.Path(exists=True), default=None,
                     help='Path to a websocket server to run at launch.')(f)
    f = click.option('--server_arguments', type=str, default=None,
                     help='Optional arguments to pass to the websocket server at launch.')(f)
    return f


def chip_repl_runner_options(f):
    f = click.option('--repl_storage_path', type=str, default='/tmp/repl-storage.json',
                     help='Path to persistent storage configuration file.')(f)
    f = click.option('--commission_on_network_dut', type=bool, default=False,
                     help='Prior to running test should we try to commission DUT on network.')(f)
    f = click.option('--runner', type=str, default=None, show_default=True,
                     help='The runner to run the test with.')(f)
    return f


@dataclass
class ParserGroup:
    builder_config: TestParserBuilderConfig
    pseudo_clusters: PseudoClusters


pass_parser_group = click.make_pass_decorator(ParserGroup)


# YAML test file contains configurable options defined in their config section.
#
# Those options are test specific and as such can not be listed directly here but
# instead are retrieved from the target test file (if there is a single file) and
# are exposed to click dynamically.
#
# The following code extracts those and, to make it easy for the user to see
# which options are available, list them in a custom section when --help
# is invoked.

class YamlTestParserGroup(click.Group):
    def format_options(self, ctx, formatter):
        """Writes all the options into the formatter if they exist."""
        if ctx.custom_options:
            params_copy = self.params
            non_custom_params = list(filter(lambda x: x.name not in ctx.custom_options, self.params))
            custom_params = list(filter(lambda x: x.name in ctx.custom_options, self.params))

            self.params = non_custom_params
            super().format_options(ctx, formatter)
            self.params = params_copy

            opts = []
            for param in custom_params:
                rv = param.get_help_record(ctx)
                if rv is not None:
                    opts.append(rv)

            if opts:
                custom_section_title = ctx.params.get('test_name') + ' Options'
                with formatter.section(custom_section_title):
                    formatter.write_dl(opts)
        else:
            super().format_options(ctx, formatter)

    def parse_args(self, ctx, args):
        # Run the parser on the supported arguments first in order to get a
        # the necessary informations to get read the test file and add
        # the potential additional arguments.
        supported_args = self.__remove_custom_args(ctx, args)
        super().parse_args(ctx, supported_args)

        # Add the potential new arguments to the list of supported params and
        # run the parser a new time to read those.
        self.__add_custom_params(ctx)
        return super().parse_args(ctx, args)

    def __remove_custom_args(self, ctx, args):
        # Remove all the unsupported options from the command line string.
        params_name = [param.name for param in self.params]

        supported_args = []
        skipArgument = False
        for arg in args:
            if arg.startswith('--') and arg not in params_name:
                skipArgument = True
                continue
            if skipArgument:
                skipArgument = False
                continue
            supported_args.append(arg)

        return supported_args

    def __add_custom_params(self, ctx):
        tests_finder = TestsFinder(ctx.params.get('configuration_directory'), ctx.params.get('configuration_name'))
        tests = tests_finder.get(ctx.params.get('test_name'))

        custom_options = {}

        # There is a single test, extract the custom config
        if len(tests) == 1:
            try:
                custom_options = TestConfigParser.get_config(tests[0])
            except Exception:
                pass
            for key, value in custom_options.items():
                param = click.Option(['--' + key], default=value, show_default=True)
                # click converts parameter name to lowercase internally, so we need to override
                # this behavior in order to override the correct key.
                param.name = key
                self.params.append(param)

        ctx.custom_options = custom_options


CONTEXT_SETTINGS = dict(
    default_map={
        'chiptool': {
            'adapter': 'matter_chip_tool_adapter.adapter',
            'server_name': 'chip-tool',
            'server_arguments': 'interactive server',
        },
        'app1': {
            'configuration_directory': 'examples/placeholder/linux/apps/app1',
            'adapter': 'matter_placeholder_adapter.adapter',
            'server_name': 'chip-app1',
            'server_arguments': '--interactive',
        },
        'app2': {
            'configuration_directory': 'examples/placeholder/linux/apps/app2',
            'adapter': 'matter_placeholder_adapter.adapter',
            'server_name': 'chip-app2',
            'server_arguments': '--interactive',
        },
        'chip-repl': {
            'adapter': 'matter_yamltest_repl_adapter.adapter',
            'runner': 'matter_yamltest_repl_adapter.runner',
        },
    },
    max_content_width=120,
)


@click.group(cls=YamlTestParserGroup, context_settings=CONTEXT_SETTINGS)
@click.argument('test_name')
@test_parser_options
@click.pass_context
def runner_base(ctx, configuration_directory: str, test_name: str, configuration_name: str, pics: str, specifications_paths: str, stop_on_error: bool, use_default_pseudo_clusters: bool, additional_pseudo_clusters_directory: str, **kwargs):
    pseudo_clusters = get_custom_pseudo_clusters(
        additional_pseudo_clusters_directory) if use_default_pseudo_clusters else PseudoClusters([])
    specifications = SpecDefinitionsFromPaths(specifications_paths.split(','), pseudo_clusters)
    tests_finder = TestsFinder(configuration_directory, configuration_name)

    parser_config = TestParserConfig(pics, specifications, kwargs)
    parser_builder_config = TestParserBuilderConfig(tests_finder.get(test_name), parser_config, hooks=TestParserLogger())
    parser_builder_config.options.stop_on_error = stop_on_error
    while ctx:
        ctx.obj = ParserGroup(parser_builder_config, pseudo_clusters)
        ctx = ctx.parent


@runner_base.command()
@pass_parser_group
def parse(parser_group: ParserGroup):
    """Parse the test suite."""
    runner_config = None

    runner = TestRunner()
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))


@runner_base.command()
@pass_parser_group
def dry_run(parser_group: ParserGroup):
    """Simulate a run of the test suite."""
    runner_config = TestRunnerConfig(hooks=TestRunnerLogger())

    runner = TestRunner()
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))


@runner_base.command()
@test_runner_options
@pass_parser_group
def run(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int):
    """Run the test suite."""
    adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
    runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
    runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
    runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)

    runner = TestRunner()
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))


@runner_base.command()
@test_runner_options
@websocket_runner_options
@pass_parser_group
def websocket(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int, server_address: str, server_port: int, server_path: str, server_name: str, server_arguments: str):
    """Run the test suite using websockets."""
    adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
    runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
    runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
    runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)

    if server_path is None and server_name:
        paths_finder = PathsFinder()
        server_path = paths_finder.get(server_name)

    websocket_runner_hooks = WebSocketRunnerLogger()
    websocket_runner_config = WebSocketRunnerConfig(
        server_address, server_port, server_path, server_arguments, websocket_runner_hooks)

    runner = WebSocketRunner(websocket_runner_config)
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))


@runner_base.command()
@test_runner_options
@chip_repl_runner_options
@pass_parser_group
def chip_repl(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int, runner: str, repl_storage_path: str, commission_on_network_dut: bool):
    """Run the test suite using chip-repl."""
    adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
    runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
    runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
    runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)

    runner = __import__(runner, fromlist=[None]).Runner(repl_storage_path, commission_on_network_dut)
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))


@runner_base.command()
@test_runner_options
@websocket_runner_options
@click.pass_context
def chiptool(ctx, *args, **kwargs):
    """Run the test suite using chip-tool."""
    return ctx.forward(websocket)


@runner_base.command()
@test_runner_options
@websocket_runner_options
@click.pass_context
def app1(ctx, *args, **kwargs):
    """Run the test suite using app1."""
    return ctx.forward(websocket)


@runner_base.command()
@test_runner_options
@websocket_runner_options
@click.pass_context
def app2(ctx, *args, **kwargs):
    """Run the test suite using app2."""
    return ctx.forward(websocket)


if __name__ == '__main__':
    success = False
    try:
        # By default click runs in standalone mode and it will handle exceptions and the
        # different commands return values for us. For example it will set sys.exit to
        # 0 if the test runs fails unless an exception is raised. Simple test failure
        # does not raise exception but we want to set the exit code to 1.
        # So standalone_mode is set to False to let us manage this exit behavior.
        success = runner_base(standalone_mode=False)
    except Exception:
        print('')
        traceback.print_exc()

    sys.exit(0 if success else 1)
