"""Text parser plugin for Atlassian Bitbucket log files.

This is for the atlassian-bitbucket.log file, one of multiple log files
produced by a Bitbucket DC/Server installation.

The default log format (logback pattern: %date %-5level [%thread] %request
%logger{36} %m%n%eThrowable) produces lines of the form:

  YYYY-MM-DD HH:MM:SS,mmm LEVEL [thread] logger_class message

With the optional %request context fields between [thread] and logger_class:

  YYYY-MM-DD HH:MM:SS,mmm LEVEL [thread] user request_identifier
  session_identifier ip_address "action" logger_class message

All %request fields are optional and may be absent. In practice, lines often
omit some or all of these fields depending on the context of the log event.

Also see:
  https://support.atlassian.com/bitbucket-data-center/kb/how-to-read-the-bitbucket-data-center-log-formats/
  https://support.atlassian.com/bitbucket-data-center/kb/how-to-change-the-bitbucket-application-log-format/
"""

import pyparsing

from dfdatetime import time_elements as dfdatetime_time_elements

from plaso.containers import events
from plaso.lib import errors
from plaso.parsers import text_parser
from plaso.parsers.text_plugins import interface


class AtlassianBitbucketEventData(events.EventData):
    """Bitbucket application log event data.

    Attributes:
      ip_address (str): the client IP address associated with the request, if
          present in the log line.
      level (str): the logging level of the event, such as INFO, WARN or ERROR.
      logger_class (str): the abbreviated or full class name responsible for
          logging the event, such as c.a.b.m.r.DefaultRepositoryManager.
      message_body (str): message body.
      request_action (str): the request action string, such as
          "TransactionService/Transact", if present.
      request_id (str): the unique request identifier, such as
          2CM38K4Fx339x113x2, if present.
      session_identifier (str): the session identifier, if present.
      thread (str): the JVM thread name from which the log event originated.
      username (str): username.
      written_time (dfdatetime.DateTimeValues): entry written date and time.
    """

    DATA_TYPE = "atlassian:bitbucket:line"

    def __init__(self):
        """Initializes event data."""
        super().__init__(data_type=self.DATA_TYPE)
        self.ip_address = None
        self.level = None
        self.logger_class = None
        self.message_body = None
        self.request_action = None
        self.request_identifier = None
        self.session_identifier = None
        self.thread = None
        self.username = None
        self.written_time = None


class AtlassianBitbucketTextPlugin(interface.TextPlugin):
    """Text parser plugin for Atlassian Bitbucket log files."""

    NAME = "atlassian_bitbucket"
    DATA_FORMAT = "Atlassian Bitbucket log file"

    ENCODING = "utf-8"

    _TWO_DIGITS = pyparsing.Word(pyparsing.nums, exact=2).set_parse_action(
        lambda tokens: int(tokens[0], 10)
    )

    _THREE_DIGITS = pyparsing.Word(pyparsing.nums, exact=3).set_parse_action(
        lambda tokens: int(tokens[0], 10)
    )

    _FOUR_DIGITS = pyparsing.Word(pyparsing.nums, exact=4).set_parse_action(
        lambda tokens: int(tokens[0], 10)
    )

    # Bitbucket log levels.
    _BITBUCKET_LEVELS = ["DEBUG", "ERROR", "FATAL", "INFO", "TRACE", "WARN"]

    # Date and time format: 2020-09-08 07:53:45,084
    _DATE_TIME = (
        _FOUR_DIGITS
        + pyparsing.Suppress("-")
        + _TWO_DIGITS
        + pyparsing.Suppress("-")
        + _TWO_DIGITS
        + _TWO_DIGITS
        + pyparsing.Suppress(":")
        + _TWO_DIGITS
        + pyparsing.Suppress(":")
        + _TWO_DIGITS
        + pyparsing.Suppress(",")
        + _THREE_DIGITS
    ).set_results_name("date_time")

    # Log level (DEBUG, ERROR, FATAL, INFO, TRACE, WARN).
    _LOG_LEVEL = pyparsing.oneOf(_BITBUCKET_LEVELS).set_results_name("level")

    # Thread name enclosed in brackets. Thread names do not contain ']'.
    _BITBUCKET_THREAD = (
        pyparsing.Suppress("[")
        + pyparsing.SkipTo("]").set_results_name("thread")
        + pyparsing.Suppress("]")
    )

    # Logger class, which can be abbreviated or full Java class name, such as:
    #     c.a.b.m.r.DefaultRepositoryManager
    #     com.atlassian.bitbucket.internal.boot.log.BuildInfoLogger
    #     o.h.i.ExceptionMapperStandardImpl
    #     org.hibernate.SQL
    #
    # The class name must start with a letter and contain at least one dot.
    _BITBUCKET_LOGGER = pyparsing.Regex(
        r"[a-zA-Z][a-zA-Z0-9_$]*(?:\.[a-zA-Z][a-zA-Z0-9_$]*)+"
    ).set_results_name("logger_class")

    # Log message body: rest of line.
    _BITBUCKET_LOG_MESSAGE = pyparsing.SkipTo(pyparsing.LineEnd()).set_results_name(
        "message_body"
    )

    # The %request context block between [thread] and logger_class is optional.
    # It may contain: user, request_identifier, session_identifier, ip_address,
    # "action".
    # We capture everything between ']' and the logger class as original text and
    # parse it afterwards.
    _REQUEST_CONTEXT_TEXT = pyparsing.SkipTo(_BITBUCKET_LOGGER).set_results_name(
        "request_context_text"
    )

    # Guard that ensures the logger class is NOT preceded by '[', which
    # would indicate a Confluence-format log line where the logger is wrapped
    # in brackets: [thread] [logger.class] message.
    _NOT_BRACKET = pyparsing.NotAny(pyparsing.Literal("["))

    # Complete log line structure:
    # <timestamp> <level> [<thread>] [optional request context] <logger> <message_body>
    # _NOT_BRACKET before _REQUEST_CONTEXT_RAW ensures the first non-whitespace
    # character after [thread] is not '[', rejecting Confluence-format lines.
    _BITBUCKET_LOG_LINE = (
        _DATE_TIME
        + _LOG_LEVEL
        + _BITBUCKET_THREAD
        + _REQUEST_CONTEXT_TEXT
        + _BITBUCKET_LOGGER
        + _BITBUCKET_LOG_MESSAGE
    )

    _LINE_STRUCTURES = [("log_entry", _BITBUCKET_LOG_LINE)]

    # Sub-patterns for parsing the raw request context string.
    # Request identifier: alphanumeric token with 'x' separators.
    _REQUEST_IDENTIFIER = pyparsing.Regex(r"[0-9A-Za-z]{6,}x[0-9]+x[0-9]+x[0-9]+")

    # Session identifier: @/*/alphanumeric token, optionally comma-separated with
    # mesh.
    _SESSION_IDENTIFIER = pyparsing.Regex(r"[@*][A-Za-z0-9_x]+(?:,[A-Za-z0-9_x]+)?")

    # IPv4 or IPv6 address.
    _RE_IP_ADDRESS = pyparsing.Regex(
        r"(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-fA-F]*:){2,7}[0-9a-fA-F]*"
    )

    # Quoted action string.
    _RE_REQUEST_ACTION = pyparsing.QuotedString('"')

    # Username: word characters, dots, hyphens, slashes.
    _RE_USERNAME = pyparsing.Regex(r"[A-Za-z][A-Za-z0-9._\-/]*")

    _REQUEST_CONTEXT_GRAMMAR = (
        pyparsing.Optional(_RE_USERNAME.set_results_name("username"))
        + pyparsing.Optional(_REQUEST_IDENTIFIER.set_results_name("request_identifier"))
        + pyparsing.Optional(_SESSION_IDENTIFIER.set_results_name("session_identifier"))
        + pyparsing.Optional(_RE_IP_ADDRESS.set_results_name("ip_address"))
        + pyparsing.Optional(_RE_REQUEST_ACTION.set_results_name("request_action"))
    )

    _LINE_STRUCTURES = [("log_entry", _BITBUCKET_LOG_LINE)]

    VERIFICATION_GRAMMAR = _BITBUCKET_LOG_LINE

    def _ParseRecord(self, parser_mediator, key, structure):
        """Parses a pyparsing structure.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers
              and other components, such as storage and dfVFS.
          key (str): name of the parsed structure.
          structure (pyparsing.ParseResults): tokens from a parsed log line.

        Raises:
          ParseError: if the structure cannot be parsed.
        """
        if key != "log_entry":
            raise errors.ParseError(f"Unsupported structure: {key:s}")

        time_elements_structure = self._GetValueFromStructure(structure, "date_time")

        message_body = self._GetValueFromStructure(
            structure, "message_body", default_value=""
        ).strip()

        request_context_text = self._GetValueFromStructure(
            structure, "request_context_text", default_value=""
        ).strip()

        event_data = AtlassianBitbucketEventData()
        event_data.level = self._GetValueFromStructure(structure, "level")
        event_data.logger_class = self._GetValueFromStructure(structure, "logger_class")
        event_data.message_body = message_body or None
        event_data.thread = self._GetValueFromStructure(structure, "thread")

        if request_context_text:
            request_context = self._REQUEST_CONTEXT_GRAMMAR.parse_string(
                request_context_text, parse_all=False
            )
            event_data.username = request_context.get("username") or None
            event_data.request_identifier = (
                request_context.get("request_identifier") or None
            )
            event_data.session_identifier = (
                request_context.get("session_identifier") or None
            )
            event_data.ip_address = request_context.get("ip_address") or None
            event_data.request_action = request_context.get("request_action") or None

        event_data.written_time = self._ParseTimeElements(time_elements_structure)

        parser_mediator.ProduceEventData(event_data)

    def _ParseTimeElements(self, time_elements_structure):
        """Parses date and time elements of a log line.

        Args:
          time_elements_structure (pyparsing.ParseResults): date and time elements
              of a log line.

        Returns:
          dfdatetime.TimeElements: date and time value.

        Raises:
          ParseError: if a valid date and time value cannot be derived from
              the time elements.
        """
        try:
            date_time = dfdatetime_time_elements.TimeElementsInMilliseconds(
                time_elements_tuple=time_elements_structure
            )
            date_time.is_local_time = True

            return date_time

        except (IndexError, TypeError, ValueError) as exception:
            raise errors.ParseError(
                f"Unable to parse time elements with error: {exception!s}"
            )

    def CheckRequiredFormat(self, parser_mediator, text_reader):
        """Check if the log record has the minimal structure required by the plugin.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers
              and other components, such as storage and dfVFS.
          text_reader (EncodedTextReader): text reader.

        Returns:
          bool: True if this is the correct plugin, False otherwise.
        """
        # Require at least 2 non-empty matching lines to guard against false
        # positives.

        verified_lines = 0
        for line in text_reader.lines.splitlines():
            if not line:
                continue

            try:
                structure = self._VerifyString(line)
            except errors.ParseError:
                return False

            verified_lines += 1
            if verified_lines >= 2:
                break

        if verified_lines < 2:
            return False

        time_elements_structure = self._GetValueFromStructure(structure, "date_time")

        try:
            self._ParseTimeElements(time_elements_structure)
        except errors.ParseError:
            return False

        return True


text_parser.TextLogParser.RegisterPlugin(AtlassianBitbucketTextPlugin)
