# Copyright 2018-2026 The Khronos Group Inc.
# Copyright (c) 2018-2019 Collabora, Ltd.
#
# SPDX-License-Identifier: Apache-2.0
#
# Author(s):    Rylie Pavlik <rylie.pavlik@collabora.com>
"""Types, constants, and utility functions used by multiple sub-modules in spec_tools."""

import platform
from collections import namedtuple
from enum import Enum
from inspect import getframeinfo
from pathlib import Path
from sys import stdout

# if we have termcolor and we know our stdout is a TTY,
# pull it in and use it.
if hasattr(stdout, 'isatty') and stdout.isatty():
    try:
        from termcolor import colored as colored_impl
        HAVE_COLOR = True
    except ImportError:
        HAVE_COLOR = False
elif platform.system() == 'Windows':
    try:
        from termcolor import colored as colored_impl
        import colorama
        colorama.init()
        HAVE_COLOR = True
    except ImportError:
        HAVE_COLOR = False

else:
    HAVE_COLOR = False


def colored(s, color=None, attrs=None):
    """Call termcolor.colored with same arguments if this is a tty and it is available."""
    if HAVE_COLOR:
        return colored_impl(s, color, attrs=attrs)
    return s


###
# Constants used in multiple places.
AUTO_FIX_STRING = 'Note: Auto-fix available.'
EXTENSION_CATEGORY = 'extension'
CATEGORIES_WITH_VALIDITY = set(('protos', 'structs'))
NON_EXISTENT_MACROS = set(('plink', 'ttext', 'dtext'))

###
# MessageContext: All the information about where a message relates to.
MessageContext = namedtuple('MessageContext',
                            ['filename', 'lineNum', 'line',
                             'match', 'group'])


def getInterestedRange(message_context):
    """Return a (start, end) pair of character index for the match in a MessageContext."""
    if not message_context.match:
        # whole line
        return (0, len(message_context.line))
    return (message_context.match.start(), message_context.match.end())


def getHighlightedRange(message_context):
    """Return a (start, end) pair of character index for the highlighted range in a MessageContext."""
    if message_context.group is not None and message_context.match is not None:
        return (message_context.match.start(message_context.group),
                message_context.match.end(message_context.group))
    # no group (whole match) or no match (whole line)
    return getInterestedRange(message_context)


def toNameAndLine(context, root_path=None):
    """Convert MessageContext into a simple filename:line string."""
    my_fn = Path(context.filename)
    if root_path:
        my_fn = my_fn.relative_to(root_path)
    return f'{str(my_fn)}:{context.lineNum}'


def generateInclude(dir_traverse, generated_type, category, entity):
    """Create an include:: directive for generated api or validity from the various pieces."""
    return f'include::{dir_traverse}{generated_type}/{category}/{entity}.adoc[]'


# Data stored per entity (function, struct, enumerant type, enumerant, extension, etc.)
EntityData = namedtuple(
    'EntityData', ['entity', 'macro', 'elem', 'filename', 'category', 'directory'])


class MessageType(Enum):
    """Type of a message."""

    WARNING = 1
    ERROR = 2
    NOTE = 3

    def __str__(self):
        """Format a MessageType as a lowercase string."""
        return str(self.name).lower()

    def formattedWithColon(self):
        """Format a MessageType as a colored, lowercase string followed by a colon."""
        if self == MessageType.WARNING:
            return colored(f"{str(self)}:", 'magenta', attrs=['bold'])
        if self == MessageType.ERROR:
            return colored(f"{str(self)}:", 'red', attrs=['bold'])
        return f"{str(self)}:"


class MessageId(Enum):
    # Disable bogus pylint warnings in this enum
    # pylint: disable=no-member
    """Enumerates the varieties of messages that can be generated.

    Control over enabled messages with -Wbla or -Wno_bla is per-MessageId.
    """

    MISSING_TEXT = 1
    LEGACY = 2
    WRONG_MACRO = 3
    MISSING_MACRO = 4
    BAD_ENTITY = 5
    BAD_ENUMERANT = 6
    BAD_MACRO = 7
    UNRECOGNIZED_CONTEXT = 8
    UNKNOWN_MEMBER = 9
    DUPLICATE_INCLUDE = 10
    UNKNOWN_INCLUDE = 11
    API_VALIDITY_ORDER = 12
    UNDOCUMENTED_MEMBER = 13
    MEMBER_PNAME_MISSING = 14
    MISSING_VALIDITY_INCLUDE = 15
    MISSING_API_INCLUDE = 16
    MISUSED_TEXT = 17
    EXTENSION = 18
    REFPAGE_TAG = 19
    REFPAGE_MISSING_DESC = 20
    REFPAGE_XREFS = 21
    REFPAGE_XREFS_COMMA = 22
    REFPAGE_TYPE = 23
    REFPAGE_NAME = 24
    REFPAGE_BLOCK = 25
    REFPAGE_MISSING = 26
    REFPAGE_MISMATCH = 27
    REFPAGE_UNKNOWN_ATTRIB = 28
    REFPAGE_SELF_XREF = 29
    REFPAGE_XREF_DUPE = 30
    REFPAGE_WHITESPACE = 31
    REFPAGE_DUPLICATE = 32
    UNCLOSED_BLOCK = 33
    MISSING_INCLUDE_PATH_ATTRIBUTE = 34
    UNKNOWN_MEMBER_FULLY_QUALIFIED = 35

    def __str__(self):
        """Format as a lowercase string."""
        return self.name.lower()

    def enable_arg(self):
        """Return the corresponding Wbla string to make the 'enable this message' argument."""
        return f'W{self.name.lower()}'

    def disable_arg(self):
        """Return the corresponding Wno_bla string to make the 'enable this message' argument."""
        return f'Wno_{self.name.lower()}'

    def desc(self):
        """Return a brief description of the MessageId suitable for use in --help."""
        return _MESSAGE_DESCRIPTIONS[self]


_MESSAGE_DESCRIPTIONS = {
    MessageId.MISSING_TEXT: "a *text: macro is expected but not found",
    MessageId.LEGACY: "legacy usage of *name: macro when *link: is applicable",
    MessageId.WRONG_MACRO: "wrong macro used for an entity",
    MessageId.MISSING_MACRO: "a macro might be missing",
    MessageId.BAD_ENTITY: "entity not recognized, etc.",
    MessageId.BAD_ENUMERANT: "unrecognized enumerant value used in ename:",
    MessageId.BAD_MACRO: "unrecognized macro used",
    MessageId.UNRECOGNIZED_CONTEXT: "pname used with an unrecognized context",
    MessageId.UNKNOWN_MEMBER: "pname used but member/argument by that name not found",
    MessageId.DUPLICATE_INCLUDE: "duplicated include line",
    MessageId.UNKNOWN_INCLUDE: "include line specified file we wouldn't expect to exists",
    MessageId.API_VALIDITY_ORDER: "saw API include after validity include",
    MessageId.UNDOCUMENTED_MEMBER: "saw an apparent struct/function documentation, but missing a member",
    MessageId.MEMBER_PNAME_MISSING: "pname: missing from a 'scope' operator",
    MessageId.MISSING_VALIDITY_INCLUDE: "missing validity include",
    MessageId.MISSING_API_INCLUDE: "missing API include",
    MessageId.MISUSED_TEXT: "a *text: macro is found but not expected",
    MessageId.EXTENSION: "an extension name is incorrectly marked",
    MessageId.REFPAGE_TAG: "a refpage tag is missing an expected field",
    MessageId.REFPAGE_MISSING_DESC: "a refpage tag has an empty description",
    MessageId.REFPAGE_XREFS: "an unrecognized entity is mentioned in xrefs of a refpage tag",
    MessageId.REFPAGE_XREFS_COMMA: "a comma was founds in xrefs of a refpage tag, which is space-delimited",
    MessageId.REFPAGE_TYPE: "a refpage tag has an incorrect type field",
    MessageId.REFPAGE_NAME: "a refpage tag has an unrecognized entity name in its refpage field",
    MessageId.REFPAGE_BLOCK: "a refpage block is not correctly opened or closed.",
    MessageId.REFPAGE_MISSING: "an API include was found outside of a refpage block.",
    MessageId.REFPAGE_MISMATCH: "an API or validity include was found in a non-matching refpage block.",
    MessageId.REFPAGE_UNKNOWN_ATTRIB: "a refpage tag has an unrecognized attribute",
    MessageId.REFPAGE_SELF_XREF: "a refpage tag has itself in the list of cross-references",
    MessageId.REFPAGE_XREF_DUPE: "a refpage cross-references list has at least one duplicate",
    MessageId.REFPAGE_WHITESPACE: "a refpage cross-references list has non-minimal whitespace",
    MessageId.REFPAGE_DUPLICATE: "a refpage tag has been seen for a single entity for a second time",
    MessageId.UNCLOSED_BLOCK: "one or more blocks remain unclosed at the end of a file",
    MessageId.MISSING_INCLUDE_PATH_ATTRIBUTE: "include:: directives must begin with a recognized path attribute macro",
    MessageId.UNKNOWN_MEMBER_FULLY_QUALIFIED: "fully qualified form slink:___::pname: used but member/argument by that name not found",
}


class Message(object):
    """An Error, Warning, or Note with a MessageContext, MessageId, and message text.

    May optionally have a replacement, a see_also array, an auto-fix,
    and a stack frame where the message was created.
    """

    def __init__(self, message_id, message_type, message, context,
                 replacement=None, see_also=None, fix=None, frame=None):
        """Construct a Message.

        Typically called by MacroCheckerFile.diag().
        """
        self.message_id = message_id

        self.message_type = message_type

        if isinstance(message, str):
            self.message = [message]
        else:
            self.message = message

        self.context = context
        if context is not None and context.match is not None and context.group is not None:
            if context.group not in context.match.groupdict():
                raise RuntimeError(
                    f'Group "{context.group}" does not exist in the match')

        self.replacement = replacement

        self.fix = fix

        if see_also is None:
            self.see_also = None
        elif isinstance(see_also, MessageContext):
            self.see_also = [see_also]
        else:
            self.see_also = see_also

        self.script_location = None
        if frame:
            try:
                frameinfo = getframeinfo(frame)
                self.script_location = f"{frameinfo.filename}:{frameinfo.lineno}"
            finally:
                del frame
