"""The Apple System Log (ASL) file parser."""

import os

from dfdatetime import posix_time as dfdatetime_posix_time

from plaso.containers import events
from plaso.lib import definitions
from plaso.lib import dtfabric_helper
from plaso.lib import errors
from plaso.lib import specification
from plaso.parsers import interface
from plaso.parsers import manager


class ASLEventData(events.EventData):
    """Apple System Log (ASL) event data.

    Attributes:
      extra_information (str): extra fields associated to the event.
      facility (str): facility.
      group_identifier (int): group identifier (GID).
      hostname (str): hostname.
      level (str): level of criticality of the event.
      message_body (str): message body.
      message_identifier (int): message identifier.
      process_identifier (int): process identifier (PID).
      read_group_identifier (int): the group identifier that can read this file,
          where -1 represents all.
      read_user_identifier (int): user identifier that can read this file,
          where -1 represents all.
      record_position (int): position of the event record.
      sender (str): sender or process that created the event.
      user_identifier (int): user identifier (UID).
      written_time (dfdatetime.DateTimeValues): entry written date and time.
    """

    DATA_TYPE = "macos:asl:entry"

    def __init__(self):
        """Initializes event data."""
        super().__init__(data_type=self.DATA_TYPE)
        self.extra_information = None
        self.facility = None
        self.group_identifier = None
        self.hostname = None
        self.level = None
        self.message_body = None
        self.message_identifier = None
        self.process_identifier = None
        self.read_group_identifier = None
        self.read_user_identifier = None
        self.record_position = None
        self.sender = None
        self.user_identifier = None
        self.written_time = None


class ASLFileEventData(events.EventData):
    """Apple System Log (ASL) file event data.

    Attributes:
      creation_time (dfdatetime.DateTimeValues): creation date and time.
      format_version (int): ASL file format version.
      is_dirty (bool): True if the last log entry offset does not match value
          in file header and the file is considered dirty.
    """

    DATA_TYPE = "macos:asl:file"

    def __init__(self):
        """Initializes event data."""
        super().__init__(data_type=self.DATA_TYPE)
        self.creation_time = None
        self.format_version = None
        self.is_dirty = None


class ASLParser(interface.FileObjectParser, dtfabric_helper.DtFabricHelper):
    """Parser for Apple System Log (ASL) files."""

    NAME = "asl_log"
    DATA_FORMAT = "Apple System Log (ASL) file"

    _DEFINITION_FILE = os.path.join(os.path.dirname(__file__), "asl.yaml")

    # Most significant bit of a 64-bit string offset.
    _STRING_OFFSET_MSB = 1 << 63

    def _ParseRecord(self, parser_mediator, file_object, record_offset):
        """Parses a record and produces events.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          file_object (file): file-like object.
          record_offset (int): offset of the record relative to the start of the file.

        Returns:
          int: next record offset.

        Raises:
          ParseError: if the record cannot be parsed.
        """
        record_map = self._GetDataTypeMap("asl_record")

        try:
            record, record_data_size = self._ReadStructureFromFileObject(
                file_object, record_offset, record_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to parse record at offset: 0x{record_offset:08x} "
                f"with error: {exception!s}"
            )

        file_offset = record_offset + record_data_size
        additional_data_size = record.data_size + 6 - record_data_size

        if additional_data_size % 8 != 0:
            raise errors.ParseError(
                f"Unsupported record additional data size: {additional_data_size:d}."
            )

        additional_data = self._ReadData(file_object, file_offset, additional_data_size)

        extra_fields = {}
        for additional_data_offset in range(0, additional_data_size - 8, 16):
            record_extra_field = self._ParseRecordExtraField(
                additional_data[additional_data_offset:], file_offset
            )
            file_offset += 16

            name = self._ParseRecordString(
                file_object, record_extra_field.name_string_offset
            )
            value = self._ParseRecordString(
                file_object, record_extra_field.value_string_offset
            )
            if name is not None:
                extra_fields[name] = value

        # TODO: implement determine previous record offset

        timestamp = (
            record.written_time * definitions.NANOSECONDS_PER_SECOND
        ) + record.written_time_nanoseconds

        event_data = ASLEventData()
        event_data.extra_information = ", ".join(
            [f"{name:s}: {value!s}" for name, value in sorted(extra_fields.items())]
        )
        event_data.facility = self._ParseRecordString(
            file_object, record.facility_string_offset
        )
        event_data.group_identifier = record.group_identifier
        event_data.hostname = self._ParseRecordString(
            file_object, record.hostname_string_offset
        )
        event_data.level = record.alert_level
        event_data.message_body = self._ParseRecordString(
            file_object, record.message_string_offset
        )
        event_data.message_identifier = record.message_identifier
        event_data.process_identifier = record.process_identifier
        event_data.read_group_identifier = record.read_group_identifier
        event_data.read_user_identifier = record.read_user_identifier
        event_data.record_position = record_offset
        event_data.sender = self._ParseRecordString(
            file_object, record.sender_string_offset
        )
        event_data.user_identifier = record.user_identifier
        event_data.written_time = dfdatetime_posix_time.PosixTimeInNanoseconds(
            timestamp=timestamp
        )
        parser_mediator.ProduceEventData(event_data)

        return record.next_record_offset

    def _ParseRecordExtraField(self, byte_stream, file_offset):
        """Parses a record extra field.

        Args:
          byte_stream (bytes): byte stream.
          file_offset (int): offset of the record extra field relative to
              the start of the file.

        Returns:
          asl_record_extra_field: record extra field.

        Raises:
          ParseError: if the record extra field cannot be parsed.
        """
        extra_field_map = self._GetDataTypeMap("asl_record_extra_field")

        try:
            record_extra_field = self._ReadStructureFromByteStream(
                byte_stream, file_offset, extra_field_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to parse record extra field at offset: 0x{file_offset:08x} "
                f"with error: {exception!s}"
            )

        return record_extra_field

    def _ParseRecordString(self, file_object, string_offset):
        """Parses a record string.

        Args:
          file_object (file): file-like object.
          string_offset (int): offset of the string relative to the start of
              the file.

        Returns:
          str: record string or None if string offset is 0.

        Raises:
          ParseError: if the record string cannot be parsed.
        """
        if string_offset == 0:
            return None

        if string_offset & self._STRING_OFFSET_MSB:
            if (string_offset >> 60) != 8:
                raise errors.ParseError("Invalid inline record string flag.")

            string_size = (string_offset >> 56) & 0x0F
            if string_size >= 8:
                raise errors.ParseError("Invalid inline record string size.")

            string_data = bytes(
                bytearray(
                    [
                        string_offset >> (8 * byte_index) & 0xFF
                        for byte_index in range(6, -1, -1)
                    ]
                )
            )

            try:
                return string_data[:string_size].decode("utf-8")
            except UnicodeDecodeError as exception:
                raise errors.ParseError(
                    f"Unable to decode inline record string with error: {exception!s}."
                )

        record_string_map = self._GetDataTypeMap("asl_record_string")

        try:
            record_string, _ = self._ReadStructureFromFileObject(
                file_object, string_offset, record_string_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to parse record string at offset: 0x{string_offset:08x} "
                f"with error: {exception!s}"
            )

        return record_string.string.rstrip("\x00")

    @classmethod
    def GetFormatSpecification(cls):
        """Retrieves the format specification.

        Returns:
          FormatSpecification: format specification.
        """
        format_specification = specification.FormatSpecification(cls.NAME)
        format_specification.AddNewSignature(
            b"ASL DB\x00\x00\x00\x00\x00\x00", offset=0
        )
        return format_specification

    def ParseFileObject(self, parser_mediator, file_object):
        """Parses an ASL file-like object.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          file_object (dfvfs.FileIO): file-like object.

        Raises:
          WrongParser: when the file cannot be parsed.
        """
        file_header_map = self._GetDataTypeMap("asl_file_header")

        try:
            file_header, _ = self._ReadStructureFromFileObject(
                file_object, 0, file_header_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.WrongParser(
                f"Unable to parse file header with error: {exception!s}"
            )

        is_dirty = False
        file_size = file_object.get_size()

        if file_header.first_log_entry_offset > 0:
            last_log_entry_offset = 0
            file_offset = file_header.first_log_entry_offset

            while file_offset < file_size:
                last_log_entry_offset = file_offset

                try:
                    file_offset = self._ParseRecord(
                        parser_mediator, file_object, file_offset
                    )
                except errors.ParseError as exception:
                    parser_mediator.ProduceWarning(
                        f"unable to parse record with error: {exception!s}"
                    )
                    return

                if file_offset == 0:
                    break

            if last_log_entry_offset != file_header.last_log_entry_offset:
                is_dirty = True
                parser_mediator.ProduceWarning(
                    "last log entry offset does not match value in file header.",
                    recovered=True,
                )

        event_data = ASLFileEventData()
        event_data.format_version = file_header.format_version
        event_data.is_dirty = is_dirty

        if file_header.creation_time:
            event_data.creation_time = dfdatetime_posix_time.PosixTime(
                timestamp=file_header.creation_time
            )

        parser_mediator.ProduceEventData(event_data)


manager.ParsersManager.RegisterParser(ASLParser)
