"""
Provides a formatter that provides an overview of available step definitions
(step implementations).
"""

import inspect
from ast import (
    literal_eval,
)
from inspect import (
    getsourcelines,
)
from itertools import (
    groupby,
)
from operator import (
    attrgetter,
)
from typing import (
    List,
    Tuple,
)

from behave_bo import (
    i18n,
)
from behave_bo.formatter.base import (
    Formatter,
)
from behave_bo.matchers import (
    ParseMatcher,
)
from behave_bo.step_registry import (
    StepRegistry,
    registry,
)
from behave_bo.textutil import (
    compute_words_maxsize,
    indent,
    make_indentation,
    text as _text,
)
from behave_bo.userdata import (
    UserDataNamespace,
)


# -----------------------------------------------------------------------------
# CLASS: AbstractStepsFormatter
# -----------------------------------------------------------------------------
class AbstractStepsFormatter(Formatter):
    """
    Provides a formatter base class that provides the common functionality
    for formatter classes that operate on step definitions (implementations).

    .. note::
        Supports behave dry-run mode.
    """
    step_types = ("given", "when", "then", "step_template", "step",)

    def __init__(self, stream_opener, config):
        super().__init__(stream_opener, config)
        self.step_registry = None
        self.current_feature = None
        self.shows_location = config.show_source

    def reset(self):
        self.step_registry = None
        self.current_feature = None

    def discover_step_definitions(self):
        if self.step_registry is None:
            self.step_registry = StepRegistry()

        for step_type in registry.steps:
            if step_type == 'step':
                step_definitions = registry.steps[step_type]
                for _, step_definition in step_definitions.items():
                    step_definition.step_type = step_type
            else:
                step_definitions = tuple(registry.steps[step_type])
                for step_definition in step_definitions:
                    step_definition.step_type = step_type

            self.step_registry.steps[step_type] = step_definitions

    # -- FORMATTER API:
    def feature(self, feature):
        self.current_feature = feature
        if not self.step_registry:
            # -- ONLY-ONCE:
            self.discover_step_definitions()

    def eof(self):
        """Called at end of a feature."""
        self.current_feature = None

    def close(self):
        """Called at end of test run."""
        if not self.step_registry:
            self.discover_step_definitions()

        if self.step_registry:
            # -- ENSURE: Output stream is open.
            self.stream = self.open()
            self.report()

        # -- FINALLY:
        self.close_stream()

    # -- REPORT SPECIFIC-API:
    def report(self):
        raise NotImplementedError()

    # pylint: disable=no-self-use
    def describe_step_definition(self, step_definition, step_type=None):
        if not step_type:
            step_type = step_definition.step_type
        assert step_type
        return f"@{step_type}('{step_definition.pattern}')"


# -----------------------------------------------------------------------------
# CLASS: StepsFormatter
# -----------------------------------------------------------------------------
class StepsFormatter(AbstractStepsFormatter):
    """
    Provides formatter class that provides an overview
    which step definitions are available.

    EXAMPLE:
        $ behave --dry-run -f steps features/
        GIVEN STEP DEFINITIONS[21]:
          Given a new working directory
          Given I use the current directory as working directory
          Given a file named "{filename}" with
          ...
          Given a step passes
          Given a step fails

        WHEN STEP DEFINITIONS[14]:
          When I run "{command}"
          ...
          When a step passes
          When a step fails

        THEN STEP DEFINITIONS[45]:
          Then the command should fail with returncode="{result:int}"
          Then it should pass with
          Then it should fail with
          Then the command output should contain "{text}"
          ...
          Then a step passes
          Then a step fails

        GENERIC STEP DEFINITIONS[13]:
          * I remove the directory "{directory}"
          * a file named "{filename}" exists
          * a file named "{filename}" does not exist
          ...
          * a step passes
          * a step fails

    .. note::
        Supports behave dry-run mode.
    """
    name = "steps"
    description = "Shows step definitions (step implementations)."
    shows_location = True
    min_location_column = 40

    # -- REPORT SPECIFIC-API:
    def report(self):
        self.report_steps_by_type()

    def report_steps_by_type(self):
        """Show an overview of the existing step implementations per step type.
        """
        # pylint: disable=too-many-branches
        assert set(self.step_types) == set(self.step_registry.steps.keys())
        language = self.config.lang or "en"
        language_keywords = i18n.languages[language]

        for step_type in self.step_types:
            steps = list(self.step_registry.steps[step_type])
            if step_type != "step" and step_type != "step_template":
                steps.extend(self.step_registry.steps["step_template"])
                steps.extend(self.step_registry.steps["step"].values())

            if not steps:
                continue

            # -- PREPARE REPORT: For a step-type.
            step_type_name = step_type.upper()
            if step_type == "step":
                step_keyword = "*"
                step_type_name = "GENERIC"
            else:
                # step_keyword = step_type.capitalize()
                keywords = language_keywords[step_type]
                if keywords[0] == "*":
                    assert len(keywords) > 1
                    step_keyword = keywords[1]
                else:
                    step_keyword = keywords[0]

            steps_text = [f"{step_keyword} {step.pattern}"
                          for step in steps]
            if self.shows_location:
                max_size = compute_words_maxsize(steps_text)
                if max_size < self.min_location_column:
                    max_size = self.min_location_column
                schema = "  %-" + _text(max_size) + "s  # %s\n"
            else:
                schema = "  %s\n"

            # -- REPORT:
            message = "%s STEP DEFINITIONS[%s]:\n"
            self.stream.write(message % (step_type_name, len(steps)))
            for step, step_text in zip(steps, steps_text):
                if self.shows_location:
                    self.stream.write(schema % (step_text, step.location))
                else:
                    self.stream.write(schema % step_text)
            self.stream.write("\n")


# -----------------------------------------------------------------------------
# CLASS: StepsDocFormatter
# -----------------------------------------------------------------------------
class StepsDocFormatter(AbstractStepsFormatter):
    """
    Provides formatter class that shows the documentation of all registered
    step definitions. The primary purpose is to provide help for a test writer.

    EXAMPLE:
        $ behave --dry-run -f steps.doc features/
        @given('a file named "{filename}" with')
          Function: step_a_file_named_filename_with()
          Location: behave4cmd0/command_steps.py:50
            Creates a textual file with the content provided as docstring.

        @when('I run "{command}"')
          Function: step_i_run_command()
          Location: behave4cmd0/command_steps.py:80
            Run a command as subprocess, collect its output and returncode.

        @step('a file named "{filename}" exists')
          Function: step_file_named_filename_exists()
          Location: behave4cmd0/command_steps.py:305
            Verifies that a file with this filename exists.

            .. code-block:: gherkin

                Given a file named "abc.txt" exists
                 When a file named "abc.txt" exists
        ...

    .. note::
        Supports behave dry-run mode.
    """
    name = "steps.doc"
    description = "Shows documentation for step definitions."
    shows_location = True
    shows_function_name = True
    ordered_by_location = True
    doc_prefix = make_indentation(4)

    # -- REPORT SPECIFIC-API:
    def report(self):
        self.report_step_definition_docs()
        self.stream.write("\n")

    def report_step_definition_docs(self):
        step_definitions = []
        for step_type in self.step_types:
            if step_type == 'step':
                step_registry_definitions = self.step_registry.steps[step_type].values()
            else:
                step_registry_definitions = self.step_registry.steps[step_type]

            for step_definition in step_registry_definitions:
                # step_definition.step_type = step_type
                assert step_definition.step_type is not None
                step_definitions.append(step_definition)

        if self.ordered_by_location:
            step_definitions = sorted(step_definitions,
                                      key=attrgetter("location"))

        for step_definition in step_definitions:
            self.write_step_definition(step_definition)

    def write_step_definition(self, step_definition):
        step_definition_text = self.describe_step_definition(step_definition)
        self.stream.write("%s\n" % step_definition_text)
        doc = inspect.getdoc(step_definition.func)
        func_name = step_definition.func.__name__
        if self.shows_function_name and func_name not in ("step", "impl"):
            self.stream.write("  Function: %s()\n" % func_name)
        if self.shows_location:
            self.stream.write("  Location: %s\n" % step_definition.location)
        if doc:
            doc = doc.strip()
            self.stream.write(indent(doc, self.doc_prefix))
            self.stream.write("\n")
        self.stream.write("\n")


# -----------------------------------------------------------------------------
# CLASS: StepsCatalogFormatter
# -----------------------------------------------------------------------------
class StepsCatalogFormatter(StepsDocFormatter):
    """
    Provides formatter class that shows the documentation of all registered
    step definitions. The primary purpose is to provide help for a test writer.

    In order to ease work for non-programmer testers, the technical details of
    the steps (i.e. function name, source location) are ommited and the
    steps are shown as they would apprear in a feature file (no noisy '@',
    or '(', etc.).

    Also, the output is sorted by step type (Given, When, Then)

    Generic step definitions are listed with all three step types.

    EXAMPLE:
        $ behave --dry-run -f steps.catalog features/
        Given a file named "{filename}" with
            Creates a textual file with the content provided as docstring.

        When I run "{command}"
            Run a command as subprocess, collect its output and returncode.

        Given a file named "{filename}" exists
         When a file named "{filename}" exists
         Then a file named "{filename}" exists
            Verifies that a file with this filename exists.

            .. code-block:: gherkin

                Given a file named "abc.txt" exists
                 When a file named "abc.txt" exists
        ...

    .. note::
        Supports behave dry-run mode.
    """
    name = "steps.catalog"
    description = "Shows non-technical documentation for step definitions."
    shows_location = False
    shows_function_name = False
    ordered_by_location = False
    doc_prefix = make_indentation(4)

    def describe_step_definition(self, step_definition, step_type=None):
        if not step_type:
            step_type = step_definition.step_type
        assert step_type
        desc = []
        if step_type == "step":
            for step_type1 in self.step_types[:-1]:
                text = "%5s %s" % (step_type1.title(), step_definition.pattern)
                desc.append(text)
        else:
            desc.append(f"{step_type.title()} {step_definition.pattern}")

        return '\n'.join(desc)


# -----------------------------------------------------------------------------
# CLASS: StepsUsageFormatter
# -----------------------------------------------------------------------------
class StepsUsageFormatter(AbstractStepsFormatter):
    """
    Provides formatter class that shows how step definitions are used by steps.

    EXAMPLE:
        $ behave --dry-run -f steps.usage features/
        ...

    .. note::
        Supports behave dry-run mode.
    """
    name = "steps.usage"
    description = "Shows how step definitions are used by steps."
    doc_prefix = make_indentation(4)
    min_location_column = 40

    def __init__(self, stream_opener, config):
        super().__init__(stream_opener, config)
        self.step_usage_database = {}
        self.undefined_steps = []

    def reset(self):
        super().reset()
        self.step_usage_database = {}
        self.undefined_steps = []

    # pylint: disable=invalid-name
    def get_step_type_for_step_definition(self, step_definition):
        step_type = step_definition.step_type
        if not step_type:
            # -- DETERMINE STEP-TYPE FROM STEP-REGISTRY:
            assert self.step_registry
            for step_type, values in self.step_registry.steps.items():
                if step_definition in values:
                    return step_type
            # -- OTHERWISE:
            step_type = "step"
        return step_type
    # pylint: enable=invalid-name

    def select_unused_step_definitions(self):
        step_definitions = set()
        for step_type, values in self.step_registry.steps.items():
            if step_type == 'step':
                step_definitions.update(values.values())
            else:
                step_definitions.update(values)
        used_step_definitions = set(self.step_usage_database.keys())
        unused_step_definitions = step_definitions - used_step_definitions
        return unused_step_definitions

    def update_usage_database(self, step_definition, step):
        matching_steps = self.step_usage_database.get(step_definition, None)
        if matching_steps is None:
            assert step_definition.step_type is not None
            matching_steps = self.step_usage_database[step_definition] = []
        # -- AVOID DUPLICATES: From Scenario Outlines
        if not steps_contain(matching_steps, step):
            matching_steps.append(step)

    def update_usage_database_for_step(self, step):
        step_definition = self.step_registry.find_step_definition(step)
        if step_definition:
            self.update_usage_database(step_definition, step)
        # elif step not in self.undefined_steps:
        elif not steps_contain(self.undefined_steps, step):
            # -- AVOID DUPLICATES: From Scenario Outlines
            self.undefined_steps.append(step)

    # pylint: disable=invalid-name
    def update_usage_database_for_feature(self, feature):
        # -- PROCESS BACKGROUND (if exists): Use Background steps only once.
        if feature.background:
            for step in feature.background.steps:
                self.update_usage_database_for_step(step)

        # -- PROCESS SCENARIOS: Without background steps.
        for scenario in feature.walk_scenarios():
            for step in scenario.steps:
                self.update_usage_database_for_step(step)
    # pylint: enable=invalid-name

    # -- FORMATTER API:
    def feature(self, feature):
        super().feature(feature)
        self.update_usage_database_for_feature(feature)

    # -- REPORT API:
    def report(self):
        self.report_used_step_definitions()
        self.report_unused_step_definitions()
        self.report_undefined_steps()
        self.stream.write("\n")

    # -- REPORT SPECIFIC-API:
    def report_used_step_definitions(self):
        # -- STEP: Used step definitions.
        # ORDERING: Sort step definitions by file location.
        get_location = lambda x: x[0].location
        step_definition_items = self.step_usage_database.items()
        step_definition_items = sorted(step_definition_items, key=get_location)

        for step_definition, steps in step_definition_items:
            stepdef_text = self.describe_step_definition(step_definition)
            steps_text = [f"  {step.keyword} {step.name}"
                          for step in steps]
            steps_text.append(stepdef_text)
            max_size = compute_words_maxsize(steps_text)
            if max_size < self.min_location_column:
                max_size = self.min_location_column

            schema = "%-" + _text(max_size) + "s  # %s\n"
            self.stream.write(schema % (stepdef_text, step_definition.location))
            schema = "%-" + _text(max_size) + "s  # %s\n"
            for step, step_text in zip(steps, steps_text):
                self.stream.write(schema % (step_text, step.location))
            self.stream.write("\n")

    def report_unused_step_definitions(self):
        unused_step_definitions = self.select_unused_step_definitions()
        if not unused_step_definitions:
            return

        # -- STEP: Prepare report for unused step definitions.
        # ORDERING: Sort step definitions by file location.
        get_location = lambda x: x.location
        step_definitions = sorted(unused_step_definitions, key=get_location)
        step_texts = [self.describe_step_definition(step_definition)
                      for step_definition in step_definitions]

        max_size = compute_words_maxsize(step_texts)
        if max_size < self.min_location_column-2:
            max_size = self.min_location_column-2

        # -- STEP: Write report.
        schema = "  %-" + _text(max_size) + "s  # %s\n"
        self.stream.write("UNUSED STEP DEFINITIONS[%d]:\n" % len(step_texts))
        for step_definition, step_text in zip(step_definitions, step_texts):
            self.stream.write(schema % (step_text, step_definition.location))

    def report_undefined_steps(self):
        if not self.undefined_steps:
            return

        # -- STEP: Undefined steps.
        undefined_steps = sorted(self.undefined_steps,
                                 key=attrgetter("location"))

        steps_text = [f"  {step.keyword} {step.name}"
                      for step in undefined_steps]
        max_size = compute_words_maxsize(steps_text)
        if max_size < self.min_location_column:
            max_size = self.min_location_column

        self.stream.write("\nUNDEFINED STEPS[%d]:\n" % len(steps_text))
        schema = "%-" + _text(max_size) + "s  # %s\n"
        for step, step_text in zip(undefined_steps, steps_text):
            self.stream.write(schema % (step_text, step.location))


# -----------------------------------------------------------------------------
# CLASS: UnusedStepsRemoveFormatter
# -----------------------------------------------------------------------------
class UnusedStepsRemoveFormatter(StepsUsageFormatter):
    """Форматер для формирования отчета только по неиспользуемым шагам, с возможностью их удаления.

    $ behave --all --dry-run -f steps.remove_unused -D formatter.steps.remove_unused.repo_name=web-bb-salary \
        -D formatter.steps.remove_unused.with_removing=false
    """
    name = "steps.remove_unused"
    description = "Shows unused step definitions."
    userdata_scope = "formatter.steps.remove_unused"

    def __init__(self, stream_opener, config):
        super().__init__(stream_opener, config)
        self.unused_step_definitions = None
        self.with_removing = False
        self.repo_name = ''
        self.setup_with_userdata(config.userdata)

    def setup_with_userdata(self, userdata):
        config = UserDataNamespace(self.userdata_scope, userdata)
        self.with_removing = config.getbool("with_removing", self.with_removing)
        self.repo_name = config.get("repo_name", self.repo_name)

    def report(self):
        self.unused_step_definitions = self.select_unused_step_definitions()

        if not self.unused_step_definitions:
            self.stream.write(f'Unused step definitions not found.\n')
        else:
            self.report_unused_step_definitions()

        self.stream.write("\n")

    def report_unused_step_definitions(self):
        unused_step_definitions = sorted(self.unused_step_definitions, key=lambda x: x.location)
        self.stream.write(f'UNUSED STEP DEFINITIONS[{len(unused_step_definitions)}]:\n')

        for filepath, step_definitions in groupby(unused_step_definitions, key=lambda x: x.location.filename):
            if self.repo_name not in filepath:
                continue

            step_definitions = list(step_definitions)
            steps = list(self.step_registry.steps['step'].values())
            steps.extend(list(self.step_registry.steps['step_template']))

            left_count = len([step for step in steps if step.location.filename == filepath])

            slices, parse_failed_definitions = self.prepare_slices_to_remove(step_definitions)
            rm_count = len(step_definitions) - len(parse_failed_definitions)

            if self.with_removing and slices:
                self.remove_step_definitions(filepath, slices)
                self.stream.write(f'{filepath}:\n')
                self.stream.write(f'{rm_count} from {left_count} step definitions REMOVED.\n')
            else:
                self.stream.write(f'{filepath}\n')

                if parse_failed_definitions:
                    step_definitions = set(step_definitions).symmetric_difference(set(parse_failed_definitions))
                    step_definitions = sorted(step_definitions, key=lambda x: x.location.line)

                self.stream.write(f'{rm_count} from {left_count} step definitions CAN BE REMOVED.\n')

                for definition in step_definitions:
                    self.stream.write(f'{definition.location}\n')

            if parse_failed_definitions:
                self.stream.write(f'{len(parse_failed_definitions)} step definitions FAILED TO PARSE:\n')
                for definition in parse_failed_definitions:
                    self.stream.write(f'{definition.pattern}\n')
                    self.stream.write(f'{definition.location}\n')

            self.stream.write('\n')

    def remove_step_definitions(self, filepath: str, slices: List[slice]) -> None:
        """Удаляет строки кода из py файла, содержащего определения шагов.

        Args:
            filepath: путь до файла.
            slices: срезы для удаления.

        """
        with open(filepath) as f:
            file_lines = f.readlines()

        for sl in sorted(slices, reverse=True, key=lambda s: s.start):
            file_lines[sl] = []

        code_separator = '\n\n\n'
        file_parts = ''.join(file_lines).split(code_separator)
        # Удаление пустых строк между блоками кода.
        file_parts = filter(bool, (part.strip() for part in file_parts))
        result_file_text = code_separator.join(file_parts) + '\n'

        with open(filepath, 'w') as f:
            f.write(result_file_text)

    def prepare_slices_to_remove(self, step_definitions: List[ParseMatcher]) -> Tuple[List[slice], List[ParseMatcher]]:
        """Формирует срезы для удаления из файла.

        Args:
            step_definitions: Список определений шагов.

        Returns:
            (Список срезов, список не найденных шагов).

        """
        slices = []
        not_found_definitions = []

        for func, step_definitions in groupby(step_definitions, key=lambda x: x.func):
            step_definitions = list(step_definitions)
            group_len = len(step_definitions)
            func_lines, first_line_num = getsourcelines(func)
            first_line_index = first_line_num - 1
            step_decorators_count = len([line for line in func_lines if line.startswith('@step(') or line.startswith('@step_template(')])

            if group_len == step_decorators_count:
                last_line_index = first_line_index + len(func_lines)
                removing_slice = slice(first_line_index, last_line_index)
                slices.append(removing_slice)
            else:
                definitions_pattern_map = self.make_definitions_pattern_map(func_lines, first_line_index)
                for step_definition in step_definitions:
                    pattern = step_definition.pattern
                    if pattern in definitions_pattern_map:
                        slices.append(slice(*definitions_pattern_map[pattern]))
                    else:
                        not_found_definitions.append(step_definition)

        return slices, not_found_definitions

    def make_definitions_pattern_map(self, func_lines: List[str], offset: int) -> dict:
        """Формирует словарь, где ключ - шаблон шага из декоратора step,
        значение - пара индексов (start, end) расположения строк декоратора в коде модуля.

        Args:
            func_lines: Список строк функции шага.
            offset: Смещение (индекс первой строки функции в коде модуля).

        Returns:
            Словарь соответствия шаблона расположению в коде.

        """
        indices = self.get_decorator_indices(func_lines)
        pattern_map = {}
        for start, end in indices:
            pattern = literal_eval(''.join(func_lines[start:end]).replace('@step_template', '').replace('@step', ''))
            pattern_map[pattern] = start + offset, end + offset

        return pattern_map

    def get_decorator_indices(self, lines: List) -> List[Tuple[int, int]]:
        """Формирует список индексов расположения строк декоратора в коде функции.

        Args:
            lines: Список строк функции шага.

        Returns:
            Список пар индексов.

        """
        start_indices = [i for i, string in enumerate(lines) if string.startswith('@step(') or string.startswith('@step_template(')]
        end_indices = []
        for i in start_indices:
            for index, line in enumerate(lines[i + 1:], i + 1):
                if line.startswith('@step(') or line.startswith('@step_template(') or line.startswith('#') or line.startswith('def '):
                    end_indices.append(index)
                    break

        return list(zip(start_indices, end_indices))


# -----------------------------------------------------------------------------
# UTILITY FUNCTIONS:
# -----------------------------------------------------------------------------
def steps_contain(steps, step):
    for other_step in steps:
        if step == other_step and step.location == other_step.location:
            # -- NOTE: Step comparison does not take location into account.
            return True
    # -- OTHERWISE: Not contained yet (or step in other location).
    return False
