# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt

from __future__ import annotations

import tokenize
from collections import defaultdict
from typing import TYPE_CHECKING, Literal

from pylint import exceptions, interfaces
from pylint.constants import (
    MSG_STATE_CONFIDENCE,
    MSG_STATE_SCOPE_CONFIG,
    MSG_STATE_SCOPE_MODULE,
    MSG_TYPES,
    MSG_TYPES_LONG,
)
from pylint.interfaces import HIGH
from pylint.message import MessageDefinition
from pylint.typing import ManagedMessage, MessageDefinitionTuple
from pylint.utils.pragma_parser import (
    OPTION_PO,
    InvalidPragmaError,
    UnRecognizedOptionError,
    parse_pragma,
)

if TYPE_CHECKING:
    from pylint.lint.pylinter import PyLinter


class _MessageStateHandler:
    """Class that handles message disabling & enabling and processing of inline
    pragma's.
    """

    def __init__(self, linter: PyLinter) -> None:
        self.linter = linter
        self.default_enabled_messages: dict[str, MessageDefinitionTuple] = {
            k: v
            for k, v in self.linter.msgs.items()
            if len(v) == 3 or v[3].get("default_enabled", True)
        }
        self._msgs_state: dict[str, bool] = {}
        self._options_methods = {
            "enable": self.enable,
            "disable": self.disable,
            "disable-next": self.disable_next,
        }
        self._bw_options_methods = {
            "disable-msg": self._options_methods["disable"],
            "enable-msg": self._options_methods["enable"],
        }
        self._pragma_lineno: dict[str, int] = {}
        self._stashed_messages: defaultdict[
            tuple[str, str], list[tuple[str | None, str]]
        ] = defaultdict(list)
        """Some messages in the options (for --enable and --disable) are encountered
        too early to warn about them.

        i.e. before all option providers have been fully parsed. Thus, this dict stores
        option_value and msg_id needed to (later) emit the messages keyed on module names.
        """

    def _set_one_msg_status(
        self, scope: str, msg: MessageDefinition, line: int | None, enable: bool
    ) -> None:
        """Set the status of an individual message."""
        if scope in {"module", "line"}:
            assert isinstance(line, int)  # should always be int inside module scope

            self.linter.file_state.set_msg_status(msg, line, enable, scope)
            if not enable and msg.symbol != "locally-disabled":
                self.linter.add_message(
                    "locally-disabled", line=line, args=(msg.symbol, msg.msgid)
                )
        else:
            msgs = self._msgs_state
            msgs[msg.msgid] = enable

    def _get_messages_to_set(
        self, msgid: str, enable: bool, ignore_unknown: bool = False
    ) -> list[MessageDefinition]:
        """Do some tests and find the actual messages of which the status should be set."""
        message_definitions: list[MessageDefinition] = []
        if msgid == "all":
            for _msgid in MSG_TYPES:
                message_definitions.extend(
                    self._get_messages_to_set(_msgid, enable, ignore_unknown)
                )
            if not enable:
                # "all" should not disable pylint's own warnings
                message_definitions = list(
                    filter(
                        lambda m: m.msgid not in self.default_enabled_messages,
                        message_definitions,
                    )
                )
            return message_definitions

        # msgid is a category?
        category_id = msgid.upper()
        if category_id not in MSG_TYPES:
            category_id_formatted = MSG_TYPES_LONG.get(category_id)
        else:
            category_id_formatted = category_id
        if category_id_formatted is not None:
            for _msgid in self.linter.msgs_store._msgs_by_category[
                category_id_formatted
            ]:
                message_definitions.extend(
                    self._get_messages_to_set(_msgid, enable, ignore_unknown)
                )
            return message_definitions

        # msgid is a checker name?
        if msgid.lower() in self.linter._checkers:
            for checker in self.linter._checkers[msgid.lower()]:
                for _msgid in checker.msgs:
                    message_definitions.extend(
                        self._get_messages_to_set(_msgid, enable, ignore_unknown)
                    )
            return message_definitions

        # msgid is report id?
        if msgid.lower().startswith("rp"):
            if enable:
                self.linter.enable_report(msgid)
            else:
                self.linter.disable_report(msgid)
            return message_definitions

        try:
            # msgid is a symbolic or numeric msgid.
            message_definitions = self.linter.msgs_store.get_message_definitions(msgid)
        except exceptions.UnknownMessageError:
            if not ignore_unknown:
                raise
        return message_definitions

    def _set_msg_status(
        self,
        msgid: str,
        enable: bool,
        scope: str = "package",
        line: int | None = None,
        ignore_unknown: bool = False,
    ) -> None:
        """Do some tests and then iterate over message definitions to set state."""
        assert scope in {"package", "module", "line"}

        message_definitions = self._get_messages_to_set(msgid, enable, ignore_unknown)

        for message_definition in message_definitions:
            self._set_one_msg_status(scope, message_definition, line, enable)

        # sync configuration object
        self.linter.config.enable = []
        self.linter.config.disable = []
        for msgid_or_symbol, is_enabled in self._msgs_state.items():
            symbols = [
                m.symbol
                for m in self.linter.msgs_store.get_message_definitions(msgid_or_symbol)
            ]
            if is_enabled:
                self.linter.config.enable += symbols
            else:
                self.linter.config.disable += symbols

    def _register_by_id_managed_msg(
        self, msgid_or_symbol: str, line: int | None, is_disabled: bool = True
    ) -> None:
        """If the msgid is a numeric one, then register it to inform the user
        it could furnish instead a symbolic msgid.
        """
        if msgid_or_symbol[1:].isdigit():
            try:
                symbol = self.linter.msgs_store.message_id_store.get_symbol(
                    msgid=msgid_or_symbol
                )
            except exceptions.UnknownMessageError:
                return
            managed = ManagedMessage(
                self.linter.current_name, msgid_or_symbol, symbol, line, is_disabled
            )
            self.linter._by_id_managed_msgs.append(managed)

    def disable(
        self,
        msgid: str,
        scope: str = "package",
        line: int | None = None,
        ignore_unknown: bool = False,
    ) -> None:
        """Disable a message for a scope."""
        self._set_msg_status(
            msgid, enable=False, scope=scope, line=line, ignore_unknown=ignore_unknown
        )
        self._register_by_id_managed_msg(msgid, line)

    def disable_next(
        self,
        msgid: str,
        _: str = "package",
        line: int | None = None,
        ignore_unknown: bool = False,
    ) -> None:
        """Disable a message for the next line."""
        if not line:
            raise exceptions.NoLineSuppliedError
        self._set_msg_status(
            msgid,
            enable=False,
            scope="line",
            line=line + 1,
            ignore_unknown=ignore_unknown,
        )
        self._register_by_id_managed_msg(msgid, line + 1)

    def enable(
        self,
        msgid: str,
        scope: str = "package",
        line: int | None = None,
        ignore_unknown: bool = False,
    ) -> None:
        """Enable a message for a scope."""
        self._set_msg_status(
            msgid, enable=True, scope=scope, line=line, ignore_unknown=ignore_unknown
        )
        self._register_by_id_managed_msg(msgid, line, is_disabled=False)

    def disable_noerror_messages(self) -> None:
        """Disable message categories other than `error` and `fatal`."""
        for msgcat in self.linter.msgs_store._msgs_by_category:
            if msgcat in {"E", "F"}:
                continue
            self.disable(msgcat)

    def list_messages_enabled(self) -> None:
        emittable, non_emittable = self.linter.msgs_store.find_emittable_messages()
        enabled: list[str] = []
        disabled: list[str] = []
        for message in emittable:
            if self.is_message_enabled(message.msgid):
                enabled.append(f"  {message.symbol} ({message.msgid})")
            else:
                disabled.append(f"  {message.symbol} ({message.msgid})")
        print("Enabled messages:")
        for msg in enabled:
            print(msg)
        print("\nDisabled messages:")
        for msg in disabled:
            print(msg)
        print("\nNon-emittable messages with current interpreter:")
        for msg_def in non_emittable:
            print(f"  {msg_def.symbol} ({msg_def.msgid})")
        print("")

    def _get_message_state_scope(
        self,
        msgid: str,
        line: int | None = None,
        confidence: interfaces.Confidence | None = None,
    ) -> Literal[0, 1, 2] | None:
        """Returns the scope at which a message was enabled/disabled."""
        if confidence is None:
            confidence = interfaces.UNDEFINED
        if confidence.name not in self.linter.config.confidence:
            return MSG_STATE_CONFIDENCE  # type: ignore[return-value] # mypy does not infer Literal correctly
        try:
            if line in self.linter.file_state._module_msgs_state[msgid]:
                return MSG_STATE_SCOPE_MODULE  # type: ignore[return-value]
        except (KeyError, TypeError):
            return MSG_STATE_SCOPE_CONFIG  # type: ignore[return-value]
        return None

    def _is_one_message_enabled(self, msgid: str, line: int | None) -> bool:
        """Checks state of a single message for the current file.

        This function can't be cached as it depends on self.file_state which can
        change.
        """
        if line is None:
            return self._msgs_state.get(msgid, True)
        try:
            return self.linter.file_state._module_msgs_state[msgid][line]
        except KeyError:
            # Check if the message's line is after the maximum line existing in ast tree.
            # This line won't appear in the ast tree and won't be referred in
            # self.file_state._module_msgs_state
            # This happens for example with a commented line at the end of a module.
            max_line_number = self.linter.file_state.get_effective_max_line_number()
            if max_line_number and line > max_line_number:
                fallback = True
                lines = self.linter.file_state._raw_module_msgs_state.get(msgid, {})

                # Doesn't consider scopes, as a 'disable' can be in a
                # different scope than that of the current line.
                closest_lines = reversed(
                    [
                        (message_line, enable)
                        for message_line, enable in lines.items()
                        if message_line <= line
                    ]
                )
                _, fallback_iter = next(closest_lines, (None, None))
                if fallback_iter is not None:
                    fallback = fallback_iter

                return self._msgs_state.get(msgid, fallback)
            return self._msgs_state.get(msgid, True)

    def is_message_enabled(
        self,
        msg_descr: str,
        line: int | None = None,
        confidence: interfaces.Confidence | None = None,
    ) -> bool:
        """Is this message enabled for the current file ?

        Optionally, is it enabled for this line and confidence level ?

        The current file is implicit and mandatory. As a result this function
        can't be cached right now as the line is the line of the currently
        analysed file (self.file_state), if it changes, then the result for
        the same msg_descr/line might need to change.

        :param msg_descr: Either the msgid or the symbol for a MessageDefinition
        :param line: The line of the currently analysed file
        :param confidence: The confidence of the message
        """
        if confidence and confidence.name not in self.linter.config.confidence:
            return False
        try:
            msgids = self.linter.msgs_store.message_id_store.get_active_msgids(
                msg_descr
            )
        except exceptions.UnknownMessageError:
            # The linter checks for messages that are not registered
            # due to version mismatch, just treat them as message IDs
            # for now.
            msgids = [msg_descr]
        return any(self._is_one_message_enabled(msgid, line) for msgid in msgids)

    def process_tokens(self, tokens: list[tokenize.TokenInfo]) -> None:
        """Process tokens from the current module to search for module/block level
        options.

        See func_block_disable_msg.py test case for expected behaviour.
        """
        control_pragmas = {"disable", "disable-next", "enable"}
        prev_line = None
        saw_newline = True
        seen_newline = True
        for tok_type, content, start, _, _ in tokens:
            if prev_line and prev_line != start[0]:
                saw_newline = seen_newline
                seen_newline = False

            prev_line = start[0]
            if tok_type in (tokenize.NL, tokenize.NEWLINE):
                seen_newline = True

            if tok_type != tokenize.COMMENT:
                continue
            match = OPTION_PO.search(content)
            if match is None:
                continue
            try:  # pylint: disable = too-many-try-statements
                for pragma_repr in parse_pragma(match.group(2)):
                    if pragma_repr.action in {"disable-all", "skip-file"}:
                        if pragma_repr.action == "disable-all":
                            self.linter.add_message(
                                "deprecated-pragma",
                                line=start[0],
                                args=("disable-all", "skip-file"),
                            )
                        self.linter.add_message("file-ignored", line=start[0])
                        self._ignore_file: bool = True
                        return
                    try:
                        meth = self._options_methods[pragma_repr.action]
                    except KeyError:
                        meth = self._bw_options_methods[pragma_repr.action]
                        # found a "(dis|en)able-msg" pragma deprecated suppression
                        self.linter.add_message(
                            "deprecated-pragma",
                            line=start[0],
                            args=(
                                pragma_repr.action,
                                pragma_repr.action.replace("-msg", ""),
                            ),
                        )
                    for msgid in pragma_repr.messages:
                        # Add the line where a control pragma was encountered.
                        if pragma_repr.action in control_pragmas:
                            self._pragma_lineno[msgid] = start[0]

                        if (pragma_repr.action, msgid) == ("disable", "all"):
                            self.linter.add_message(
                                "deprecated-pragma",
                                line=start[0],
                                args=("disable=all", "skip-file"),
                            )
                            self.linter.add_message("file-ignored", line=start[0])
                            self._ignore_file = True
                            return
                            # If we did not see a newline between the previous line and now,
                            # we saw a backslash so treat the two lines as one.
                        l_start = start[0]
                        if not saw_newline:
                            l_start -= 1
                        try:
                            meth(msgid, "module", l_start)
                        except (
                            exceptions.DeletedMessageError,
                            exceptions.MessageBecameExtensionError,
                        ) as e:
                            self.linter.add_message(
                                "useless-option-value",
                                args=(pragma_repr.action, e),
                                line=start[0],
                                confidence=HIGH,
                            )
                        except exceptions.UnknownMessageError:
                            self.linter.add_message(
                                "unknown-option-value",
                                args=(pragma_repr.action, msgid),
                                line=start[0],
                                confidence=HIGH,
                            )

            except UnRecognizedOptionError as err:
                self.linter.add_message(
                    "unrecognized-inline-option", args=err.token, line=start[0]
                )
                continue
            except InvalidPragmaError as err:
                self.linter.add_message(
                    "bad-inline-option", args=err.token, line=start[0]
                )
                continue
