from collections.abc import Iterator
from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast

import click
from fmf.utils import listed

import tmt
from tmt.container import container, field, key_to_option

if TYPE_CHECKING:
    import tmt.cli
    import tmt.options
    import tmt.steps

import tmt.base
import tmt.steps
import tmt.utils
from tmt.options import option
from tmt.plugins import PluginRegistry
from tmt.steps import Action
from tmt.utils import GeneralError, Path


@container
class TestOrigin:
    """
    Describes the origin of a test.
    """

    #: Name of the ``discover`` phase that added the test.
    phase: str

    #: The test in question.
    test: 'tmt.Test'


@container
class DiscoverStepData(tmt.steps.WhereableStepData, tmt.steps.StepData):
    dist_git_source: bool = field(
        default=False,
        option='--dist-git-source',
        is_flag=True,
        help='Download DistGit sources and ``rpmbuild -bp`` them (can be skipped).',
    )

    # TODO: use enum!
    dist_git_type: Optional[str] = field(
        default=None,
        option='--dist-git-type',
        choices=tmt.utils.git.get_distgit_handler_names,
        help="""
            Use the provided DistGit handler instead of the auto detection.
            Useful when running from forked repositories.
            """,
    )

    dist_git_download_only: bool = field(
        default=False,
        option="--dist-git-download-only",
        is_flag=True,
        help="Just download the sources. No ``rpmbuild -bp``, "
        "nor installation of require or buildddeps happens.",
    )

    dist_git_install_builddeps: bool = field(
        default=False,
        option="--dist-git-install-builddeps",
        is_flag=True,
        help="Install package build dependencies according to the specfile.",
    )

    dist_git_require: list['tmt.base.DependencySimple'] = field(
        default_factory=list,
        option="--dist-git-require",
        metavar='PACKAGE',
        multiple=True,
        help="""
            Additional required package to be present before sources are prepared.
            The ``rpm-build`` package itself is installed automatically.
            """,
        # *simple* requirements only
        normalize=lambda key_address, value, logger: tmt.base.assert_simple_dependencies(
            tmt.base.normalize_require(key_address, value, logger),
            "'dist_git_require' can be simple packages only",
            logger,
        ),
        serialize=lambda packages: [package.to_spec() for package in packages],
        unserialize=lambda serialized: [
            tmt.base.DependencySimple.from_spec(package) for package in serialized
        ],
    )

    require_test: list[str] = field(
        default_factory=list,
        option=('--require-test'),
        metavar='NAMES',
        multiple=True,
        help="""
            A list of test names that must be discovered during the run. If an execute
            step is present, these tests must also be executed. If any of the
            specified tests are not discovered or executed, an exception is raised.
            """,
        normalize=tmt.utils.normalize_string_list,
    )


DiscoverStepDataT = TypeVar('DiscoverStepDataT', bound=DiscoverStepData)


class DiscoverPlugin(tmt.steps.GuestlessPlugin[DiscoverStepDataT, None]):
    """
    Common parent of discover plugins
    """

    # ignore[assignment]: as a base class, DiscoverStepData is not included in
    # DiscoverStepDataT.
    _data_class = DiscoverStepData  # type: ignore[assignment]

    # Methods ("how: ..." implementations) registered for the same step.
    _supported_methods: PluginRegistry[tmt.steps.Method] = PluginRegistry('step.discover')

    @classmethod
    def base_command(
        cls,
        usage: str,
        method_class: Optional[type[click.Command]] = None,
    ) -> click.Command:
        """
        Create base click command (common for all discover plugins)
        """

        # Prepare general usage message for the step
        if method_class:
            usage = Discover.usage(method_overview=usage)

        # Create the command
        @click.command(cls=method_class, help=usage)
        @click.pass_context
        @option('-h', '--how', metavar='METHOD', help='Use specified method to discover tests.')
        @tmt.steps.PHASE_OPTIONS
        def discover(context: 'tmt.cli.Context', **kwargs: Any) -> None:
            context.obj.steps.add('discover')
            Discover.store_cli_invocation(context)

        return discover

    def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
        """
        Perform actions shared among plugins when beginning their tasks
        """

        self.go_prolog(logger or self._logger)

    def tests(
        self, *, phase_name: Optional[str] = None, enabled: Optional[bool] = None
    ) -> list['TestOrigin']:
        """
        Return discovered tests.

        :param phase_name: if set, return only tests discovered by the
            phase of this name. Otherwise, all tests discovered by the
            phase are returned.

            .. note::

               This parameter exists to present unified interface with
               :py:meth:`tmt.steps.discover.Discover.tests` API, but it
               has no interesting effect in case of individual phases:

               * left unset, all tests discovered by the phase are
                 returned,
               * set to a phase name, tests discovered by that phase
                 should be returned. But a phase does not have access to
                 other phases' tests, therefore setting it to anything
                 but this phase name would produce an empty list.
        :param enabled: if set, return only tests that are enabled
            (``enabled=True``) or disabled (``enabled=False``). Otherwise,
            all tests are returned.
        :returns: a list of phase name and test pairs.
        """

        raise NotImplementedError

    def download_distgit_source(
        self,
        distgit_dir: Path,
        target_dir: Path,
        handler_name: Optional[str] = None,
    ) -> None:
        """
        Download sources to the target_dir

        distgit_dir is path to the DistGit repository
        """

        tmt.utils.git.distgit_download(
            distgit_dir=distgit_dir,
            target_dir=target_dir,
            handler_name=handler_name,
            caller=self,
            logger=self._logger,
        )

    def log_import_plan_details(self) -> None:
        """
        Log details about the imported plan
        """

        parent = cast(Optional[Discover], self.parent)
        if (
            parent
            and parent.plan._original_plan
            and parent.plan._original_plan._imported_plan_references
        ):
            for remote_plan_id in parent.plan._original_plan._imported_plan_references:
                # FIXME: cast() - https://github.com/python/mypy/issues/7981
                # Note the missing Optional for values - to_minimal_dict() would
                # not include unset keys, therefore all values should be valid.
                for key, value in cast(dict[str, str], remote_plan_id.to_minimal_spec()).items():
                    self.verbose(f'import {key}', value, 'green')

    def post_dist_git(self, created_content: list[Path]) -> None:
        """
        Discover tests after dist-git applied patches
        """


class Discover(tmt.steps.Step):
    """
    Gather information about test cases to be executed.
    """

    _plugin_base_class = DiscoverPlugin

    def __init__(
        self,
        *,
        plan: 'tmt.base.Plan',
        data: tmt.steps.RawStepDataArgument,
        logger: tmt.log.Logger,
    ) -> None:
        """
        Store supported attributes, check for sanity
        """

        super().__init__(plan=plan, data=data, logger=logger)

        # Collection of discovered tests
        self._tests: dict[str, list[tmt.Test]] = {}
        self._failed_tests: dict[str, list[tmt.Test]] = {}

        # Collection of required tests per discover step phase
        self._required_test_names: dict[str, list[str]] = {}

        # Test will be (re)discovered in other phases/steps
        self.extract_tests_later: bool = False

    @property
    def _preserved_workdir_members(self) -> set[str]:
        """
        A set of members of the step workdir that should not be removed.
        """

        return {*super()._preserved_workdir_members, 'tests.yaml'}

    @property
    def required_tests(self) -> list[TestOrigin]:
        """
        The list of required tests gathered from all phases
        """

        tests = []
        for phase_name, required_test_names in self._required_test_names.items():
            tests += [
                test_origin
                for test_origin in self.tests(phase_name=phase_name)
                if test_origin.test.name in required_test_names
            ]
        return tests

    def load(self) -> None:
        """
        Load step data from the workdir
        """

        if self.should_run_again:
            self.debug('Run discover again when reexecuting to capture changes in plans')
        else:
            super().load()

        try:
            raw_test_data = tmt.utils.yaml_to_list(self.read(Path('tests.yaml')))

            self._tests = {}

            for raw_test_datum in raw_test_data:
                # The name of `discover` phases providing the test was added in 1.24.
                # Unfortunately, the field is required for correct work of `execute`,
                # now when it is parallel in nature. Without it, it's not possible
                # to pick the right `discover` phase which then provides the list
                # of tests to execute. Therefore raising an error instead of guessing
                # what the phase could be.
                if key_to_option('discover_phase') not in raw_test_datum:
                    # TODO: there should be a method for creating workdir-aware paths...
                    path = (
                        self.workdir / Path('tests.yaml') if self.workdir else Path('tests.yaml')
                    )

                    raise tmt.utils.BackwardIncompatibleDataError(
                        f"Could not load '{path}' whose format is not compatible "
                        "with tmt 1.24 and newer."
                    )

                phase_name = raw_test_datum.pop(key_to_option('discover_phase'))

                if phase_name not in self._tests:
                    self._tests[phase_name] = []

                self._tests[phase_name].append(
                    tmt.Test.from_dict(
                        logger=self._logger,
                        mapping=raw_test_datum,
                        name=raw_test_datum['name'],
                        skip_validation=True,
                    )
                )

        except tmt.utils.FileError:
            self.debug('Discovered tests not found.', level=2)

    def save(self) -> None:
        """
        Save step data to the workdir
        """

        super().save()

        # Create tests.yaml with the full test data
        raw_test_data: list[tmt.export._RawExportedInstance] = []

        for phase_name, phase_tests in self._tests.items():
            for test in phase_tests:
                if test.enabled is not True:
                    continue

                exported_test = test._export(include_internal=True)
                exported_test[key_to_option('discover_phase')] = phase_name

                raw_test_data.append(exported_test)

        self.write(Path('tests.yaml'), tmt.utils.dict_to_yaml(raw_test_data))

    def _discover_from_execute(self) -> None:
        """
        Check the execute step for possible shell script tests
        """

        # Check scripts for command line and data, convert to list if needed
        scripts = self.plan.execute.opt('script')
        if not scripts:
            scripts = getattr(self.plan.execute.data[0], 'script', [])
        if not scripts:
            return
        if isinstance(scripts, str):
            scripts = [scripts]

        # Avoid circular imports
        from tmt.steps.discover.shell import DiscoverShellData, TestDescription

        # Give a warning when discover step defined as well
        if self.data and not all(datum.is_bare for datum in self.data):
            raise tmt.utils.DiscoverError(
                "Use either 'discover' or 'execute' step to define tests, but not both."
            )

        if not isinstance(self.data[0], DiscoverShellData):
            # TODO: or should we rather create a new `shell` discovery step data,
            # and fill it with our tests? Before step data patch, `tests` attribute
            # was simply created as a list, with no check whether the step data and
            # plugin even support `data.tests`. Which e.g. `internal` does not.
            # Or should we find the first DiscoverShellData instance, use it, and
            # create a new one when no such entry exists yet?
            raise GeneralError(
                f'Cannot append tests from execute to non-shell step "{self.data[0].how}"'
            )

        discover_step_data = self.data[0]

        # Check the execute step for possible custom duration limit
        # FIXME: cast() - https://github.com/teemtee/tmt/issues/1540
        duration = cast(
            str, getattr(self.plan.execute.data[0], 'duration', tmt.base.DEFAULT_TEST_DURATION_L2)
        )

        # Prepare the list of tests
        for index, script in enumerate(scripts):
            name = f'script-{str(index).zfill(2)}'
            discover_step_data.tests.append(
                TestDescription(name=name, test=script, duration=duration)
            )

    def wake(self) -> None:
        """
        Wake up the step (process workdir and command line)
        """

        super().wake()

        # Check execute step for possible tests (unless already done)
        if self.status() is None:
            self._discover_from_execute()

        # Choose the right plugin and wake it up
        for data in self.data:
            # FIXME: cast() - see https://github.com/teemtee/tmt/issues/1599
            plugin = cast(
                DiscoverPlugin[DiscoverStepData], DiscoverPlugin.delegate(self, data=data)
            )
            self._phases.append(plugin)
            plugin.wake()

        # Nothing more to do if already done and not asked to run again
        if self.status() == 'done' and not self.should_run_again:
            self.debug('Discover wake up complete (already done before).', level=2)
        # Save status and step data (now we know what to do)
        else:
            self.status('todo')
            self.save()

    def summary(self) -> None:
        """
        Give a concise summary of the discovery
        """

        # Summary of selected tests
        text = listed(len(self.tests(enabled=True)), 'test') + ' selected'
        self.info('summary', text, 'green', shift=1)
        # Test list in verbose mode
        for test_origin in self.tests(enabled=True):
            self.verbose(test_origin.test.name, color='red', shift=2)

    def go(self, force: bool = False) -> None:
        """
        Discover all tests
        """

        super().go(force=force)

        # Nothing more to do if already done
        if self.status() == 'done':
            self.info('status', 'done', 'green', shift=1)
            self.summary()
            self.actions()
            return

        # Perform test discovery, gather discovered tests
        for phase in self.phases(classes=(Action, DiscoverPlugin)):
            if isinstance(phase, Action):
                phase.go()

            elif isinstance(phase, DiscoverPlugin):
                if not phase.enabled_by_when:
                    continue

                # Go and discover tests
                phase.go()

                self._tests[phase.name] = []

                # Prefix test name only if multiple plugins configured
                prefix = f'/{phase.name}' if len(self.phases()) > 1 else ''

                self._required_test_names[phase.name] = [
                    f"{prefix}{test_name}"
                    for test_name in cast(DiscoverStepData, phase.data).require_test
                ]

                # Check discovered tests, modify test name/path
                for test_origin in phase.tests(enabled=True):
                    test = test_origin.test

                    test.name = f"{prefix}{test.name}"
                    test.path = Path(f"/{phase.safe_name}{test.path}")
                    # Update test environment with plan environment
                    test.environment.update(self.plan.environment)
                    self._tests[phase.name].append(test)

            else:
                raise GeneralError(f'Unexpected phase in discover step: {phase}')

        for test_origin in self.tests():
            test_origin.test.serial_number = self.plan.draw_test_serial_number(test_origin.test)

        # Show fmf identifiers for tests discovered in plan
        # TODO: This part should go into the 'fmf.py' module
        if self.opt('fmf_id'):
            if self.tests(enabled=True):
                export_fmf_ids: list[str] = []

                for test_origin in self.tests(enabled=True):
                    fmf_id = test_origin.test.fmf_id

                    if not fmf_id.url:
                        raise tmt.utils.DiscoverError(
                            f"`tmt run discover --fmf-id` without `url` option "
                            f"in plan `{self.plan}` can be used only within git repo."
                        )

                    exported = test_origin.test.fmf_id.to_minimal_spec()

                    if fmf_id.default_branch and fmf_id.ref == fmf_id.default_branch:
                        exported.pop('ref')

                    export_fmf_ids.append(tmt.utils.dict_to_yaml(exported, start=True))

                click.echo(''.join(export_fmf_ids), nl=False)
            return

        if self.should_run_again and tmt.base.Test._opt('failed_only'):
            failed_results: list[tmt.base.Result] = []
            assert self.parent is not None  # narrow type
            assert isinstance(self.parent, tmt.base.Plan)  # narrow type

            # Get failed results from previous run execute
            failed_results = [
                result
                for result in self.parent.execute._results
                if (
                    result.result is not tmt.result.ResultOutcome.PASS
                    and result.result is not tmt.result.ResultOutcome.INFO
                )
            ]

            # Filter existing tests into another variable which is then used by tests() method
            for test_phase in self._tests:
                self._failed_tests[test_phase] = []
                for test in self._tests[test_phase]:
                    for result in failed_results:
                        if test.name == result.name and test.serial_number == result.serial_number:
                            self._failed_tests[test_phase].append(test)

        # Assert that all required tests were discovered
        for phase_name, required_test_names in self._required_test_names.items():
            for required_test in required_test_names:
                if not any(required_test == test.name for test in self._tests.get(phase_name, [])):
                    raise tmt.utils.DiscoverError(
                        f"Required test '{required_test}' not discovered in phase '{phase_name}'."
                    )

        # Give a summary, update status and save
        self.summary()
        self.status('done')
        self.save()

    def tests(
        self, *, phase_name: Optional[str] = None, enabled: Optional[bool] = None
    ) -> list['TestOrigin']:
        """
        Return discovered tests.

        :param phase_name: if set, return only tests discovered by the
            phase of this name. Otherwise, tests discovered by all
            phases are returned.
        :param enabled: if set, return only tests that are enabled
            (``enabled=True``) or disabled (``enabled=False``). Otherwise,
            all tests are returned.
        :returns: a list of phase name and test pairs.
        """

        from tmt.steps.discover import TestOrigin

        suitable_tests = self._failed_tests if self._failed_tests else self._tests
        suitable_phases = [phase_name] if phase_name is not None else list(self._tests.keys())

        def _iter_tests() -> Iterator['TestOrigin']:
            # PLR1704: this redefinition of `phase_name` is acceptable, the original
            # value is not longer needed as it has been turned into `suitable_phases`.
            for phase_name, phase_tests in suitable_tests.items():  # noqa: PLR1704
                if phase_name not in suitable_phases:
                    continue

                for test in phase_tests:
                    yield TestOrigin(test=test, phase=phase_name)

        if enabled is None:
            return list(_iter_tests())

        return [
            test_origin for test_origin in _iter_tests() if test_origin.test.enabled is enabled
        ]
