# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.


import contextlib
import copy
import re
from urllib.parse import urlparse

from glom import glom, assign


def int_or_none(data):
    try:
        return int(data)
    except (TypeError, ValueError):
        return None


def get_crashing_thread(crash_data):
    """
    Takes crash data and returns the crashing thread as an int.

    :arg crash_data: crash data structure that conforms to the schema

    :returns: crashing thread as an int

    """
    try:
        return int(crash_data.get("crashing_thread", 0))
    except (TypeError, ValueError):
        return 0


DOES_NOT_EXIST = object()


@contextlib.contextmanager
def override_values(crash_data, values):
    """
    Takes a dict of path -> value to override in the original crash data.
    After the context is over, the crash data will return to the original
    value.

    :arg crash_data: the crash data that conforms to the schema
    :arg values: dict of path -> value to override

    :yields: dict with overridden values

    """
    crash_data = copy.deepcopy(crash_data)

    for path, value in values.items():
        assign(crash_data, path, val=value, missing=dict)

    yield crash_data


def convert_to_crash_data(processed_crash):
    """
    Takes a processed crash (these are Socorro-centric data structures) and converts
    them to a crash data structure used by signature generation.

    :arg processed_crash: processed crash data from Socorro

    :returns: crash data structure that conforms to the schema

    """
    crash_data = {
        # text or None
        "hang": glom(processed_crash, "hang", default=None),
        # int or None
        "oom_allocation_size": int_or_none(
            glom(processed_crash, "oom_allocation_size", default=None)
        ),
        # str or None
        "js_large_allocation_failure": glom(
            processed_crash,
            "js_large_allocation_failure",
            default=None,
        ),
        # text or None
        "abort_message": glom(processed_crash, "abort_message", default=None),
        # text or None
        "mdsw_status_string": glom(processed_crash, "mdsw_status_string", default=None),
        # text json with "phase", "conditions" (complicated--see code) or None
        "async_shutdown_timeout": glom(
            processed_crash, "async_shutdown_timeout", default=None
        ),
        # text or None
        "ipc_channel_error": glom(processed_crash, "ipc_channel_error", default=None),
        # text or None
        "ipc_message_name": glom(processed_crash, "ipc_message_name", default=None),
        # text
        "moz_crash_reason": glom(processed_crash, "moz_crash_reason", default=None),
        # list of str; for example:
        # ["upload_file_minidump_browser", "upload_file_minidump_content"]
        "additional_minidumps": glom(
            processed_crash, "additional_minidumps", default=[]
        ),
        # pull out the original signature if there was one
        "original_signature": glom(processed_crash, "signature", default=""),
    }

    # Add platform-specific data for stacks

    if "json_dump" in processed_crash:
        # int or None
        crash_data["crashing_thread"] = glom(
            processed_crash, "json_dump.crash_info.crashing_thread", default=None
        )
        # string or None
        crash_data["reason"] = glom(
            processed_crash, "json_dump.crash_info.type", default=None
        )
        # list of CStackTrace or None
        crash_data["threads"] = glom(processed_crash, "json_dump.threads", default=None)
        # text or None
        crash_data["os"] = glom(
            processed_crash, "json_dump.system_info.os", default=None
        )

    if "java_exception" in processed_crash:
        # java_exception structure or {}
        crash_data["java_exception"] = glom(processed_crash, "java_exception")

    if "java_stack_trace" in processed_crash:
        # java_stack_trace string or None
        crash_data["java_stack_trace"] = glom(processed_crash, "java_stack_trace")

    return crash_data


#: List of allowed characters: ascii, printable, and non-whitespace except space
ALLOWED_CHARS = [chr(c) for c in range(32, 127)]


def drop_bad_characters(text):
    """Takes a text and drops all non-printable and non-ascii characters and
    also any whitespace characters that aren't space.

    :arg str text: the text to fix

    :returns: text with all bad characters dropped

    """
    # Strip all non-ascii and non-printable characters
    text = "".join([c for c in text if c in ALLOWED_CHARS])
    return text


def parse_source_file(source_file):
    """Parses a source file thing and returns the file name

    Example:

    >>> parse_file('hg:hg.mozilla.org/releases/mozilla-esr52:js/src/jit/MIR.h:755067c14b06')
    'js/src/jit/MIR.h'

    :arg str source_file: the source file ("file") from a stack frame

    :returns: the filename or ``None`` if it couldn't determine one

    """
    if not source_file:
        return None

    vcsinfo = source_file.split(":")
    if len(vcsinfo) == 4:
        # These are repositories or cloud file systems (e.g. hg, git, s3)
        vcstype, root, vcs_source_file, revision = vcsinfo
        return vcs_source_file

    if len(vcsinfo) == 2:
        # These are directories on someone's Windows computer and vcstype is a
        # file system (e.g. "c:", "d:", "f:")
        vcstype, vcs_source_file = vcsinfo
        return vcs_source_file

    if source_file.startswith("/"):
        # These are directories on OSX or Linux
        return source_file

    # We have no idea what this is, so return None
    return None


def _is_exception(exceptions, before_token, after_token, token):
    """Predicate for whether the open token is in an exception context

    :arg exceptions: list of strings or None
    :arg before_token: the text of the function up to the token delimiter
    :arg after_token: the text of the function after the token delimiter
    :arg token: the token (only if we're looking at a close delimiter

    :returns: bool

    """
    if not exceptions:
        return False
    for s in exceptions:
        if before_token.endswith(s):
            return True
        if s in token:
            return True
    return False


def collapse(function, open_string, close_string, replacement="", exceptions=None):
    """Collapses the text between two delimiters in a frame function value

    This collapses the text between two delimiters and either removes the text
    altogether or replaces it with a replacement string.

    There are certain contexts in which we might not want to collapse the text
    between two delimiters. These are denoted as "exceptions" and collapse will
    check for those exception strings occuring before the token to be replaced
    or inside the token to be replaced.

    Before::

        IPC::ParamTraits<nsTSubstring<char> >::Write(IPC::Message *,nsTSubstring<char> const &)
               ^        ^ open token
               exception string occurring before open token

    Inside::

        <rayon_core::job::HeapJob<BODY> as rayon_core::job::Job>::execute
        ^                              ^^^^ exception string inside token
        open token

    :arg function: the function value from a frame to collapse tokens in
    :arg open_string: the open delimiter; e.g. ``(``
    :arg close_string: the close delimiter; e.g. ``)``
    :arg replacement: what to replace the token with; e.g. ``<T>``
    :arg exceptions: list of strings denoting exceptions where we don't want
        to collapse the token

    :returns: new function string with tokens collapsed

    """
    collapsed = []
    open_count = 0
    open_token = []

    for i, char in enumerate(function):
        if not open_count:
            if char == open_string and not _is_exception(
                exceptions, function[:i], function[i + 1 :], ""
            ):
                open_count += 1
                open_token = [char]
            else:
                collapsed.append(char)

        else:
            if char == open_string:
                open_count += 1
                open_token.append(char)

            elif char == close_string:
                open_count -= 1
                open_token.append(char)

                if open_count == 0:
                    token = "".join(open_token)
                    if _is_exception(
                        exceptions, function[:i], function[i + 1 :], token
                    ):
                        collapsed.append("".join(open_token))
                    else:
                        collapsed.append(replacement)
                    open_token = []
            else:
                open_token.append(char)

    if open_count:
        token = "".join(open_token)
        if _is_exception(exceptions, function[:i], function[i + 1 :], token):
            collapsed.append("".join(open_token))
        else:
            collapsed.append(replacement)

    return "".join(collapsed)


def drop_prefix_and_return_type(function):
    """Takes the function value from a frame and drops prefix and return type

    For example::

        static void * Allocator<MozJemallocBase>::malloc(unsigned __int64)
        ^      ^^^^^^ return type
        prefix

    This gets changes to this::

        Allocator<MozJemallocBase>::malloc(unsigned __int64)

    This tokenizes on space, but takes into account types, generics, traits,
    function arguments, and other parts of the function signature delimited by
    things like `', <>, {}, [], and () for both C/C++ and Rust.

    After tokenizing, this returns the last token since that's comprised of the
    function name and its arguments.

    :arg function: the function value in a frame to drop bits from

    :returns: adjusted function value

    """
    DELIMITERS = {"(": ")", "{": "}", "[": "]", "<": ">", "`": "'"}
    OPEN = DELIMITERS.keys()
    CLOSE = DELIMITERS.values()

    # The list of tokens accumulated so far
    tokens = []

    # Keeps track of open delimiters so we can match and close them
    levels = []

    # The current token we're building
    current = []

    for _, char in enumerate(function):
        if char in OPEN:
            levels.append(char)
            current.append(char)
        elif char in CLOSE:
            if levels and DELIMITERS[levels[-1]] == char:
                levels.pop()
                current.append(char)
            else:
                # This is an unmatched close.
                current.append(char)
        elif levels:
            current.append(char)
        elif char == " ":
            tokens.append("".join(current))
            current = []
        else:
            current.append(char)

    if current:
        tokens.append("".join(current))

    while len(tokens) > 1 and tokens[-1].startswith(("(", "[clone")):
        # It's possible for the function signature to have a space between
        # the function name and the parenthesized arguments or [clone ...]
        # thing. If that's the case, we join the last two tokens. We keep doing
        # that until the last token is nice.
        #
        # Example:
        #
        #     somefunc (int arg1, int arg2)
        #             ^
        #     somefunc(int arg1, int arg2) [clone .cold.111]
        #                                 ^
        #     somefunc(int arg1, int arg2) [clone .cold.111] [clone .cold.222]
        #                                 ^                 ^
        tokens = tokens[:-2] + [" ".join(tokens[-2:])]

    return tokens[-1]


CRASH_ID_RE = re.compile(
    r"""
    ^
    [a-f0-9]{8}-
    [a-f0-9]{4}-
    [a-f0-9]{4}-
    [a-f0-9]{4}-
    [a-f0-9]{6}
    [0-9]{6}      # date in YYMMDD
    $
""",
    re.VERBOSE,
)


def is_crash_id_valid(crash_id):
    """Returns whether this is a valid crash id

    :arg str crash_id: the crash id in question

    :returns: True if it's valid, False if not

    """
    return bool(CRASH_ID_RE.match(crash_id))


def parse_crashid(item):
    """Returns a crashid from a number of formats.

    This handles the following three forms of crashids:

    * CRASHID
    * bp-CRASHID
    * http[s]://HOST[:PORT]/report/index/CRASHID

    :arg str item: the thing to parse a crash id from

    :returns: crashid as str or None

    """
    if is_crash_id_valid(item):
        return item

    if item.startswith("bp-") and is_crash_id_valid(item[3:]):
        return item[3:]

    if item.startswith("http"):
        parsed = urlparse(item)
        path = parsed.path
        if path.startswith("/report/index"):
            crash_id = path.split("/")[-1]
            if is_crash_id_valid(crash_id):
                return crash_id


def strip_leading_zeros(text):
    """Strips leading zeros from a hex string.

    Example:

    >>> strip_leading_zeros("0x0000000000032ec0")
    "0x32ec0"

    :param text: the text to strip leading zeros from

    :returns: stripped text

    """
    try:
        return hex(int(text, base=16))
    except (ValueError, TypeError):
        return text
