# cspell:ignore fqcn,FQCN

"""This action provides ansible-lint results through ansible-navigator.

Internally, it works by using ansible-runner to execute ansible-lint (optionally
in an execution environment). When running ansible-lint, it passes
``-f codeclimate`` which requests JSON output on stdout. The JSON output contains
a list of issues which we then report in the ansible-navigator UI.

We allow users to dig into an individual issue using the standard :<lineno>
commands, to learn more about the issue.

The full specification for ansible-lint's use of JSON (for the codeclimate
formatter) can be found in src/ansiblelint/formatters/__init__.py in the
ansible-lint codebase.
"""

from __future__ import annotations

import json
import shlex

from collections.abc import Mapping
from datetime import datetime
from datetime import timezone
from enum import IntEnum
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any

from ansible_navigator.action_base import ActionBase
from ansible_navigator.action_defs import RunStdoutReturn
from ansible_navigator.runner.command import Command
from ansible_navigator.steps import Step
from ansible_navigator.ui_framework import Color
from ansible_navigator.ui_framework import CursesLine
from ansible_navigator.ui_framework import CursesLinePart
from ansible_navigator.ui_framework import CursesLines
from ansible_navigator.ui_framework import Decoration
from ansible_navigator.ui_framework import Interaction
from ansible_navigator.ui_framework import error_notification
from ansible_navigator.ui_framework import nonblocking_notification
from ansible_navigator.ui_framework import success_notification
from ansible_navigator.utils.functions import expand_path
from ansible_navigator.utils.functions import remove_ansi
from ansible_navigator.utils.functions import time_stamp_for_file

from . import _actions as actions
from . import run_action


if TYPE_CHECKING:
    from ansible_navigator.app_public import AppPublic
    from ansible_navigator.configuration_subsystem.definitions import ApplicationConfiguration


class Severity(IntEnum):
    """A mapping from ansible-lint severity to an integer represented internally.

    Primarily used for sorting.
    """

    INFO = 10
    MINOR = 20
    MAJOR = 30
    CRITICAL = 40
    BLOCKER = 50

    # If ansible-lint ever gives us something we don't expect, return this.
    # In practice, we reverse-sort, so this will always be last.
    UNKNOWN = -1

    @classmethod
    def _missing_(cls, value: object) -> Severity:
        """Return unknown if ansible-lint ever returns something unexpected.

        Args:
            value: The value

        Returns:
            A severity unknown
        """
        return Severity.UNKNOWN


def severity_to_color(severity: str) -> int:
    """Convert severity to curses colors.

    Args:
        severity: The severity to convert to a color

    Returns:
        A color for the severity
    """
    if severity == "minor":
        return Color.MAGENTA
    if severity == "major":
        return Color.YELLOW
    if severity in ("critical", "blocker"):
        return Color.RED
    if severity == "info":
        return Color.CYAN
    return Color.BLACK


def color_menu(colno: int, colname: str, entry: dict[str, Any]) -> tuple[int, int]:
    """Color the menu.

    Args:
        colno: The column number
        colname: The column name
        entry: The current content entry

    Returns:
        The foreground and background color
    """
    return (severity_to_color(entry["severity"]), Color.BLACK)


def content_heading(obj: dict[Any, Any], screen_w: int) -> CursesLines:
    """Generate the content heading.

    Args:
        obj: The content for which the heading will be generated
        screen_w: The current screen width

    Returns:
        The content heading
    """
    check_line = f"Message: {obj['check_name']}"
    location = f"Location: {obj['issue_path']}"
    fill_characters = screen_w - len(location) + 1
    location_line = f"{location}{' ' * fill_characters}"

    line_1_part_1 = CursesLinePart(
        column=0,
        string=check_line,
        color=severity_to_color(obj["severity"]),
        decoration=Decoration.NORMAL,
    )
    line_2_part_1 = CursesLinePart(
        column=0,
        string=location_line,
        color=severity_to_color(obj["severity"]),
        decoration=Decoration.UNDERLINE,
    )
    return CursesLines((CursesLine((line_1_part_1,)), CursesLine((line_2_part_1,))))


def filter_content_keys(obj: dict[Any, Any]) -> dict[Any, Any]:
    """Filter out internal keys.

    Args:
        obj: The content from which the content keys will be filtered

    Returns:
        The content without the internal keys
    """
    ignored_keys = ("fingerprint",)
    return {k: v for k, v in obj.items() if not k.startswith("__") and k not in ignored_keys}


def massage_issue(issue: dict[Any, Any]) -> dict[Any, Any]:
    """Massage an issue by injecting some useful keys with strings for rendering.

    Args:
        issue: The issue reported

    Returns:
        The issue reformatted
    """
    massaged = issue.copy()
    massaged["__severity"] = massaged["severity"].capitalize()
    # Version 6.1 and before of ansible-lint used this syntax
    # "check_name": "[fqcn-builtins] Use FQCN for builtin actions."
    # Version 6.2 and later use this syntax
    # check_name": "fqcn-builtins", "description": "Use FQCN for builtin actions."
    if issue["check_name"].startswith("["):
        massaged["__message"] = issue["check_name"].split("]")[1].strip()
    else:
        massaged["__message"] = issue["description"]
    massaged["__path"] = expand_path(issue["location"]["path"])
    if "lines" in issue["location"] and isinstance(issue["location"]["lines"]["begin"], Mapping):
        massaged["__line"] = issue["location"]["lines"]["begin"]["line"]
    elif "positions" in issue["location"] and isinstance(
        issue["location"]["positions"]["begin"],
        Mapping,
    ):
        massaged["__line"] = issue["location"]["positions"]["begin"]
    else:
        massaged["__line"] = issue["location"]["lines"]["begin"]
    massaged["issue_path"] = f"{massaged['__path']}:{massaged['__line']}"
    return massaged


MENU_COLUMNS = [
    "__severity",
    "__message",
    "__path",
    "__line",
]


@actions.register
class Action(ActionBase):
    """Run the lint subcommand."""

    KEGEX = r"^lint(\s(?P<params>.*))?$"

    def __init__(self, args: ApplicationConfiguration) -> None:
        """Initialize the action.

        Args:
            args: The current application configuration
        """
        self._modification_times_last_updated: datetime
        self._success_notification_shown: bool = False
        self._issues_menu = Step(
            name="issues_menu",
            step_type="menu",
            columns=MENU_COLUMNS,
            value=[],
            select_func=self._build_issue_content,
        )
        self._current_issue_count = 0
        super().__init__(args=args, logger_name=__name__, name="lint")

    @property
    def is_interactive(self) -> bool:
        """Determine if interactive.

        Returns:
            An indication is running in interactive mode
        """
        return bool(self._args.mode == "interactive")

    def _fatal(self, msg: str) -> None:
        """Show a notification if a fatal error has occurred.

        Args:
            msg: The message to display

        Raises:
            RuntimeError: A runtime error if not mode interactive
        """
        self._logger.error(msg)

        if self.is_interactive:
            notification = error_notification(messages=[msg])
            self._interaction.ui.show_form(notification)
        else:
            raise RuntimeError(msg)

    def _run_runner(self) -> tuple[str, str, int]:
        """Spin up runner to run ansible-lint, either in an exec env or not.

        Returns:
            The output, errors and return code
        """
        kwargs = {
            "container_engine": self._args.container_engine,
            "execution_environment_image": self._args.execution_environment_image,
            "execution_environment": self._args.execution_environment,
            "navigator_mode": self._args.mode,
            "pass_environment_variable": self._args.pass_environment_variable,
            "set_environment_variable": self._args.set_environment_variable,
            "private_data_dir": self._args.ansible_runner_artifact_dir,
            "rotate_artifacts": self._args.ansible_runner_rotate_artifacts_count,
            "timeout": self._args.ansible_runner_timeout,
            "host_cwd": str(Path.cwd()),
        }

        if isinstance(self._args.execution_environment_volume_mounts, list):
            kwargs["container_volume_mounts"] = self._args.execution_environment_volume_mounts

        if isinstance(self._args.container_options, list):
            kwargs["container_options"] = self._args.container_options

        cmd_args = ["-qq", "--offline"]

        if self.is_interactive:
            cmd_args += [
                "--nocolor",
                "-f",
                "codeclimate",
            ]
        elif not self._args.display_color:
            cmd_args.append("--nocolor")

        if isinstance(self._args.lint_config, str):
            cmd_args += [
                "-c",
                self._args.lint_config,
            ]

        if isinstance(self._args.lintables, str):
            # Does it actually exist?
            if not Path(self._args.lintables).exists():
                self._fatal(f"The given path `{self._args.lintables}` does not exist.")
                return "", "", 1
            cmd_args.append(self._args.lintables)

        kwargs["cmdline"] = cmd_args

        runner = Command(executable_cmd="ansible-lint", **kwargs)
        return runner.run()

    def run_stdout(self) -> RunStdoutReturn:
        """Execute the ``doc`` request for mode stdout.

        Returns:
            A message and return code
        """
        self._logger.debug("lint requested in stdout mode")
        _output, _error, return_code = self._run_runner()
        return RunStdoutReturn(message="", return_code=return_code)

    @staticmethod
    def _pull_out_json_or_fatal(stdout: str) -> str | None:
        """Attempt to pull out JSON line from ansible-lint raw output.

        Note that stdout and stderr get munged together by docker/podman, so we
        need to do some trickery to try to figure out the actual JSON line vs,
        say, ansible warnings.

        Args:
            stdout: The stdout from the lint invocation

        Returns:
            The json string
        """
        # We want the last (non empty) line of output. This should hopefully be
        # the JSON we need.
        for line in reversed(stdout.splitlines()):
            if not line:
                continue
            return line
        return None

    def run(self, interaction: Interaction, app: AppPublic) -> Interaction | None:
        """Execute the ``lint`` request for mode interactive.

        Args:
            interaction: The interaction from the user
            app: The app instance

        Returns:
            The pending
            :class:`~ansible_navigator.ui_framework.ui.Interaction` or
            :data:`None`
        """
        self._logger.debug("lint requested")
        self._prepare_to_run(app, interaction)

        updated = self._update_args(
            ["lint"] + shlex.split(interaction.action.match.groupdict()["params"] or ""),
        )

        if not updated:
            self._prepare_to_exit(interaction)
            return None

        notification = nonblocking_notification(messages=["Linting, this may take a minute..."])
        interaction.ui.show_form(notification)

        self._build_issues_menu()
        if self._current_issue_count == 0:
            self._prepare_to_exit(interaction)
            return None

        self.steps.append(self._issues_menu)

        while True:
            self.update()

            if self._current_issue_count == 0:
                break

            self._interaction.ui.update_status(
                status=f"Issues: {self._current_issue_count}",
                status_color=self._max_severity_color,
            )

            self._take_step()

            if not self.steps:
                break

            if self.steps.current.name == "quit":
                return self.steps.current

        self._prepare_to_exit(interaction)
        return None

    def update(self) -> None:
        """Request calling app update, and update modification time if needed."""
        self._calling_app.update()

        # Do this only every 2 seconds
        if (datetime.now(timezone.utc) - self._modification_times_last_updated).total_seconds() > 2:
            rerun_lint = self._rerun_needed()
            if rerun_lint:
                self._build_issues_menu()

    @property
    def _max_severity_color(self) -> int:
        """Determine the color of the maximum severity issue.

        Returns:
            The color
        """
        max_severity = max(Severity[i["severity"].upper()].value for i in self._issues_menu.value)
        return severity_to_color(Severity(max_severity).name.lower())

    def _take_step(self) -> None:
        """Take a step based on the current step or step back."""
        result = None
        if isinstance(self.steps.current, Interaction):
            result = run_action(self.steps.current.name, self.app, self.steps.current)
        elif isinstance(self.steps.current, Step):
            if self.steps.current.show_func:
                current_index = self.steps.current.index
                self.steps.current.show_func()
                self.steps.current.index = current_index

            if self.steps.current.type == "menu":
                result = self._interaction.ui.show(
                    obj=self.steps.current.value,
                    columns=self.steps.current.columns,
                    color_menu_item=color_menu,
                )
            elif self.steps.current.type == "content":
                result = self._interaction.ui.show(
                    obj=self.steps.current.value,
                    index=self.steps.current.index,
                    content_heading=content_heading,
                    filter_content_keys=filter_content_keys,
                )

        if result is None:
            self.steps.back_one()
        else:
            self.steps.append(result)

    def _build_issues_menu(self) -> None:
        """Build the menu of all issues.

        Returns:
            Indication of success
        """
        output, _error, return_code = self._run_runner()
        self._logger.debug("Output from ansible-lint run (rc=%d): %s", return_code, output)

        # ansible-lint failed
        if return_code != 0 and "ansible-lint: No such file or directory" in output:
            installed_or_ee = (
                "in the execution environment you are using"
                if self._args.execution_environment
                else "installed"
            )
            self._fatal(
                "ansible-lint executable could not be found. Ensure 'ansible-lint' "
                f"is {installed_or_ee} and try again.",
            )
            self._current_issue_count = 0
            return

        # Could not extract json from response
        out_without_warnings = self._pull_out_json_or_fatal(output)
        if out_without_warnings is None:
            self._current_issue_count = 0
            return

        # De-serialization of json failed
        try:
            raw_issues = json.loads(out_without_warnings)
        except json.JSONDecodeError as exc:
            self._logger.debug("Failed to parse 'ansible-lint' JSON response: %s", str(exc))
            messages = ["Could not parse 'ansible-lint' output."]
            without_ansi = remove_ansi(output)
            messages.extend(without_ansi.splitlines())
            notification = error_notification(messages)
            self._interaction.ui.show_form(notification)
            self._current_issue_count = 0
            return

        # No issues were found
        if len(raw_issues) == 0:
            notification = success_notification(messages=["Congratulations, no lint issues found!"])
            self._interaction.ui.show_form(notification)

        # Update menu
        issues = [massage_issue(issue) for issue in raw_issues]
        self._current_issue_count = len(issues)
        self._issues_menu.value = sorted(
            issues,
            key=lambda i: Severity[i["severity"].upper()],
            reverse=True,
        )

        # Update new content timestamps
        _rerun_lint = self._rerun_needed()

    def _build_issue_content(self) -> Step:
        """Build the content for one plugin.

        Returns:
            The plugin's content
        """
        return Step(
            name="issue_content",
            step_type="content",
            value=self.steps.current.value,
            index=self.steps.current.index,
        )

    def _rerun_needed(self) -> bool:
        """Add a modification timestamp to each issue, check for changes.

        Returns:
            Indication if lint should be rerun
        """
        rerun_lint = False
        path_timestamps: dict[str, tuple[float | None, str | None]] = {}
        for issue in self._issues_menu.value:
            path = issue["__path"]
            if path not in path_timestamps:
                path_timestamps[path] = time_stamp_for_file(path, self._args.time_zone)

            unix_ts, iso_ts = path_timestamps[path]
            previous_ts = issue.get("__last_modified")

            issue["last_modified"] = iso_ts
            issue["__last_modified"] = unix_ts

            if unix_ts is not None and (previous_ts is None or unix_ts > previous_ts):
                rerun_lint = True

        self._modification_times_last_updated = datetime.now(timezone.utc)
        if rerun_lint:
            self._logger.debug("Files modified")
        return rerun_lint
