"""Shared functionality for OpenSearch output modules."""

import logging
import os

from acstore.containers import interface as containers_interface

from dfdatetime import interface as dfdatetime_interface
from dfdatetime import posix_time as dfdatetime_posix_time

from dfvfs.serializer.json_serializer import JsonPathSpecSerializer

try:
    import opensearchpy
except ImportError:
    opensearchpy = None

from plaso.lib import errors
from plaso.output import formatting_helper
from plaso.output import interface
from plaso.output import logger

# Configure the OpenSearch logger.
if opensearchpy:
    opensearch_logger = logging.getLogger("opensearchpy.trace")
    opensearch_logger.setLevel(logging.WARNING)


class SharedOpenSearchFieldFormattingHelper(formatting_helper.FieldFormattingHelper):
    """Shared OpenSearch output module field formatting helper."""

    # Maps the name of a fields to a a callback function that formats
    # the field value.
    _FIELD_FORMAT_CALLBACKS = {
        "datetime": "_FormatDateTime",
        "display_name": "_FormatDisplayName",
        "inode": "_FormatInode",
        "message": "_FormatMessage",
        "source_long": "_FormatSource",
        "source_short": "_FormatSourceShort",
        "tag": "_FormatTag",
        "timestamp": "_FormatTimestamp",
        "timestamp_desc": "_FormatTimestampDescription",
        "yara_match": "_FormatYaraMatch",
    }

    # The field format callback methods require specific arguments hence
    # the check for unused arguments is disabled here.
    # pylint: disable=unused-argument

    def _FormatDateTime(self, output_mediator, event, event_data, event_data_stream):
        """Formats a date and time field in ISO 8601 format.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.

        Returns:
          str: date and time field.
        """
        date_time = dfdatetime_posix_time.PosixTimeInMicroseconds(
            timestamp=event.timestamp
        )
        return date_time.CopyToDateTimeStringISO8601()

    def _FormatTag(self, output_mediator, event_tag):
        """Formats an event tag field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event_tag (EventTag): event tag or None if not set.

        Returns:
          list[str]: event tag labels.
        """
        return getattr(event_tag, "labels", None) or []

    def _FormatInode(self, output_mediator, event, event_data, event_data_stream):
        """Formats an inode field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.

        Returns:
          str: inode field.
        """
        inode = getattr(event_data, "inode", None)
        if isinstance(inode, int):
            inode = f"{inode:d}"

        return inode

    def _FormatTimestamp(self, output_mediator, event, event_data, event_data_stream):
        """Formats a timestamp field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.

        Returns:
          int: timestamp field.
        """
        return event.timestamp

    def _FormatTimestampDescription(
        self, output_mediator, event, event_data, event_data_stream
    ):
        """Formats a timestamp description field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.

        Returns:
          str: timestamp description field.
        """
        return event.timestamp_desc

    def _FormatYaraMatch(self, output_mediator, event, event_data, event_data_stream):
        """Formats a Yara match field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.

        Returns:
          list[str]: Yara match field.
        """
        return getattr(event_data_stream, "yara_match", None) or []

    # pylint: enable=unused-argument

    def GetFormattedField(
        self,
        output_mediator,
        field_name,
        event,
        event_data,
        event_data_stream,
        event_tag,
    ):
        """Formats the specified field.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          field_name (str): name of the field.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.
          event_tag (EventTag): event tag.

        Returns:
          object: value of the field or None if not set.
        """
        callback_name = self._FIELD_FORMAT_CALLBACKS.get(field_name)
        if callback_name == "_FormatTag":
            return self._FormatTag(output_mediator, event_tag)

        callback_function = None
        if callback_name:
            callback_function = getattr(self, callback_name, None)

        if callback_function:
            output_value = callback_function(
                output_mediator, event, event_data, event_data_stream
            )
        elif hasattr(event_data_stream, field_name):
            output_value = getattr(event_data_stream, field_name, None)
        else:
            output_value = getattr(event_data, field_name, None)

        return output_value


class SharedOpenSearchOutputModule(interface.OutputModule):
    """Shared functionality for an OpenSearch output module."""

    # pylint: disable=abstract-method

    NAME = "opensearch_shared"

    SUPPORTS_ADDITIONAL_FIELDS = True
    SUPPORTS_CUSTOM_FIELDS = True

    _DEFAULT_FLUSH_INTERVAL = 1000

    # Number of seconds to wait before a request to OpenSearch is timed out.
    _DEFAULT_REQUEST_TIMEOUT = 300

    _DEFAULT_FIELD_NAMES = [
        "datetime",
        "display_name",
        "message",
        "source_long",
        "source_short",
        "tag",
        "timestamp",
        "timestamp_desc",
    ]

    def __init__(self):
        """Initializes an output module."""
        super().__init__()
        self._client = None
        self._custom_fields = {}
        self._event_documents = []
        self._field_names = self._DEFAULT_FIELD_NAMES
        self._field_formatting_helper = SharedOpenSearchFieldFormattingHelper()
        self._flush_interval = self._DEFAULT_FLUSH_INTERVAL
        self._host = None
        self._index_name = None
        self._mappings = None
        self._number_of_buffered_events = 0
        self._password = None
        self._port = None
        self._username = None
        self._use_ssl = None
        self._ca_certs = None
        self._url_prefix = None

    def _Connect(self):
        """Connects to an OpenSearch server.

        Raises:
          RuntimeError: if the OpenSearch version is not supported or the server
              cannot be reached.
        """
        opensearch_host = {"host": self._host, "port": self._port}

        if self._url_prefix:
            opensearch_host["url_prefix"] = self._url_prefix

        opensearch_http_auth = None
        if self._username is not None:
            opensearch_http_auth = (self._username, self._password)

        self._client = opensearchpy.OpenSearch(
            [opensearch_host],
            http_auth=opensearch_http_auth,
            use_ssl=self._use_ssl,
            ca_certs=self._ca_certs,
        )
        logger.debug(
            f"Connected to OpenSearch server: {self._host:s} port: {self._port:d} "
            f"URL prefix: {self._url_prefix!s}."
        )

    def _CreateIndexIfNotExists(self, index_name, mappings):
        """Creates an OpenSearch index if it does not exist.

        Args:
          index_name (str): mame of the index.
          mappings (dict[str, object]): mappings of the index.

        Raises:
          RuntimeError: if the OpenSearch index cannot be created.
        """
        try:
            # Try opensearch-py >= 2.5.0 calling convention.
            index_exists = self._client.indices.exists(index=index_name)
        except TypeError:
            # pylint: disable=missing-kwoa,too-many-function-args
            index_exists = self._client.indices.exists(index_name)

        try:
            if not index_exists:
                self._client.indices.create(
                    body={"mappings": mappings}, index=index_name
                )
        except opensearchpy.exceptions.ConnectionError as exception:
            raise RuntimeError(
                f"Unable to create OpenSearch index with error: {exception!s}"
            )

    def _FlushEvents(self):
        """Inserts the buffered event documents into OpenSearch."""
        try:
            # pylint: disable=unexpected-keyword-arg
            bulk_arguments = {
                "body": self._event_documents,
                "index": self._index_name,
                "request_timeout": self._DEFAULT_REQUEST_TIMEOUT,
            }
            self._client.bulk(**bulk_arguments)

        except (ValueError, opensearchpy.exceptions.OpenSearchException) as exception:
            # Ignore problematic events
            logger.warning(f"Unable to bulk insert with error: {exception!s}")

        logger.debug(
            "Inserted {self._number_of_buffered_events:d} events into OpenSearch"
        )
        self._event_documents = []
        self._number_of_buffered_events = 0

    def _SanitizeField(self, data_type, attribute_name, field):
        """Sanitizes a field for output.

        Args:
          data_type (str): event data type.
          attribute_name (str): name of the event attribute.
          field (object): value of the field to sanitize.

        Returns:
          object: sanitized value of the field.
        """
        # Some parsers have written bytes values to storage.
        if isinstance(field, bytes):
            logger.warning(
                f"Found bytes value for attribute '{attribute_name:s}' for data type: "
                f"{data_type!s}. Unsupported UTF-8 code points are escaped."
            )
            field = field.decode("utf-8", "backslashreplace")

        return field

    def Close(self):
        """Closes connection to OpenSearch.

        Inserts any remaining buffered event documents.
        """
        self._FlushEvents()

        self._client = None

    def GetFieldValues(
        self, output_mediator, event, event_data, event_data_stream, event_tag
    ):
        """Retrieves the output field values.

        Args:
          output_mediator (OutputMediator): mediates interactions between output
              modules and other components, such as storage and dfVFS.
          event (EventObject): event.
          event_data (EventData): event data.
          event_data_stream (EventDataStream): event data stream.
          event_tag (EventTag): event tag.

        Returns:
          dict[str, str]: output field values per name.
        """
        event_values = {}

        if event_data:
            for attribute_name, attribute_value in event_data.GetAttributes():
                # Ignore attribute container identifier and date and time values.
                if isinstance(
                    attribute_value,
                    (
                        containers_interface.AttributeContainerIdentifier,
                        dfdatetime_interface.DateTimeValues,
                    ),
                ):
                    continue

                if (
                    isinstance(attribute_value, list)
                    and attribute_value
                    and isinstance(
                        attribute_value[0], dfdatetime_interface.DateTimeValues
                    )
                ):
                    continue

                # Ignore protected internal only attributes.
                if attribute_name[0] == "_" and attribute_name != "_parser_chain":
                    continue

                # Output _parser_chain as parser for backwards compatibility.
                if attribute_name == "_parser_chain":
                    attribute_name = "parser"

                event_values[attribute_name] = attribute_value

        if event_data_stream:
            for attribute_name, attribute_value in event_data_stream.GetAttributes():
                event_values[attribute_name] = attribute_value

        for attribute_name in self._field_names:
            if attribute_name not in event_values:
                event_values[attribute_name] = None

        field_values = {}
        for attribute_name, attribute_value in event_values.items():
            if attribute_name == "path_spec":
                try:
                    field_value = JsonPathSpecSerializer.WriteSerialized(
                        attribute_value
                    )
                except TypeError:
                    continue

            else:
                field_value = self._field_formatting_helper.GetFormattedField(
                    output_mediator,
                    attribute_name,
                    event,
                    event_data,
                    event_data_stream,
                    event_tag,
                )

            if field_value is None and attribute_name in self._custom_fields:
                field_value = self._custom_fields.get(attribute_name)

            if field_value is None:
                field_value = "-"

            field_values[attribute_name] = self._SanitizeField(
                event_data.data_type, attribute_name, field_value
            )

        return field_values

    def SetAdditionalFields(self, field_names):
        """Sets the names of additional fields to output.

        Args:
          field_names (list[str]): names of additional fields to output.
        """
        self._field_names.extend(field_names)

    def SetCustomFields(self, field_names_and_values):
        """Sets the names and values of custom fields to output.

        Args:
          field_names_and_values (list[tuple[str, str]]): names and values of
              custom fields to output.
        """
        self._custom_fields = dict(field_names_and_values)
        self._field_names.extend(self._custom_fields.keys())

    def SetFlushInterval(self, flush_interval):
        """Sets the flush interval.

        Args:
          flush_interval (int): number of events to buffer before doing a bulk
              insert.
        """
        self._flush_interval = flush_interval
        logger.debug(f"OpenSearch flush interval: {flush_interval:d}")

    def SetIndexName(self, index_name):
        """Sets the index name.

        Args:
          index_name (str): name of the index.
        """
        self._index_name = index_name
        logger.debug(f"OpenSearch index name: {index_name:s}")

    def SetMappings(self, mappings):
        """Sets the mappings.

        Args:
          mappings (dict[str, object]): mappings of the index.
        """
        self._mappings = mappings

    def SetPassword(self, password):
        """Sets the password.

        Args:
          password (str): password to authenticate with.
        """
        self._password = password
        logger.debug("OpenSearch password: ********")

    def SetServerInformation(self, server, port):
        """Sets the server information.

        Args:
          server (str): IP address or hostname of the server.
          port (int): Port number of the server.
        """
        self._host = server
        self._port = port
        logger.debug(f"OpenSearch server: {server!s} port: {port:d}")

    def SetUsername(self, username):
        """Sets the username.

        Args:
          username (str): username to authenticate with.
        """
        self._username = username
        logger.debug(f"OpenSearch username: {username!s}")

    def SetUseSSL(self, use_ssl):
        """Sets the use of ssl.

        Args:
          use_ssl (bool): enforces use of ssl.
        """
        self._use_ssl = use_ssl
        logger.debug(f"OpenSearch use SSL/TLS: {use_ssl!s}")

    def SetCACertificatesPath(self, ca_certificates_path):
        """Sets the path to the CA certificates.

        Args:
          ca_certificates_path (str): path to file containing a list of root
            certificates to trust.

        Raises:
          BadConfigOption: if the CA certificates file does not exist.
        """
        if not ca_certificates_path:
            return

        if not os.path.exists(ca_certificates_path):
            raise errors.BadConfigOption(
                f"No such certificate file: {ca_certificates_path:s}"
            )

        self._ca_certs = ca_certificates_path
        logger.debug(f"OpenSearch certificate file: {ca_certificates_path:s}")

    def SetURLPrefix(self, url_prefix):
        """Sets the URL prefix.

        Args:
          url_prefix (str): URL prefix.
        """
        self._url_prefix = url_prefix
        logger.debug("OpenSearch URL prefix: {0!s}")
