"""Parser for MacOS keychain database files."""

import codecs
import collections
import os

from dfdatetime import time_elements as dfdatetime_time_elements

from dtfabric.runtime import data_maps as dtfabric_data_maps

from plaso.containers import events
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 KeychainInternetRecordEventData(events.EventData):
    """MacOS keychain internet record event data.

    Attributes:
      account_name (str): name of the account.
      comments (str): comments added by the user.
      creation_time (dfdatetime.DateTimeValues): creation date and time of the keychain
          record.
      entry_name (str): name of the entry.
      modification_time (dfdatetime.DateTimeValues): modification date and time of the
          keychain record.
      protocol (str): internet protocol used, for example "https".
      ssgp_hash (str): password/certificate hash formatted as a hexadecimal string.
      text_description (str): description.
      type_protocol (str): sub-protocol used, for example "form".
      where (str): domain name or IP where the password is used.
    """

    DATA_TYPE = "macos:keychain:internet"

    def __init__(self):
        """Initializes event data."""
        super().__init__(data_type=self.DATA_TYPE)
        self.account_name = None
        self.comments = None
        self.creation_time = None
        self.entry_name = None
        self.modification_time = None
        self.protocol = None
        self.ssgp_hash = None
        self.text_description = None
        self.type_protocol = None
        self.where = None


# TODO: merge with KeychainInternetRecordEventData.
class KeychainApplicationRecordEventData(events.EventData):
    """MacOS keychain application password record event data.

    Attributes:
      account_name (str): name of the account.
      comments (str): comments added by the user.
      creation_time (dfdatetime.DateTimeValues): creation date and time of the keychain
          record.
      entry_name (str): name of the entry.
      modification_time (dfdatetime.DateTimeValues): modification date and time of the
          keychain record.
      ssgp_hash (str): password/certificate hash formatted as a hexadecimal string.
      text_description (str): description.
    """

    DATA_TYPE = "macos:keychain:application"

    def __init__(self):
        """Initializes event data."""
        super().__init__(data_type=self.DATA_TYPE)
        self.account_name = None
        self.comments = None
        self.creation_time = None
        self.entry_name = None
        self.modification_time = None
        self.ssgp_hash = None
        self.text_description = None


class KeychainDatabaseColumn:
    """MacOS keychain database column.

    Attributes:
      attribute_data_type (int): attribute (data) type.
      attribute_identifier (int): attribute identifier.
      attribute_name (str): attribute name.
    """

    def __init__(self):
        """Initializes a MacOS keychain database column."""
        super().__init__()
        self.attribute_data_type = None
        self.attribute_identifier = None
        self.attribute_name = None


class KeychainDatabaseTable:
    """MacOS keychain database table.

    Attributes:
      columns (list[KeychainDatabaseColumn]): columns.
      records (list[dict[str, str]]): records.
      relation_identifier (int): relation identifier.
      relation_name (str): relation name.
    """

    def __init__(self):
        """Initializes a MacOS keychain database table."""
        super().__init__()
        self.columns = []
        self.records = []
        self.relation_identifier = None
        self.relation_name = None


class KeychainParser(interface.FileObjectParser, dtfabric_helper.DtFabricHelper):
    """Parser for MacOS keychain database files."""

    NAME = "mac_keychain"
    DATA_FORMAT = "MacOS keychain database file"

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

    _MAJOR_VERSION = 1
    _MINOR_VERSION = 0

    # TODO: add more protocols.
    _PROTOCOL_TRANSLATION_DICT = {
        "htps": "https",
        "http": "http",
        "imap": "imap",
        "smtp": "smtp",
    }

    _RECORD_TYPE_CSSM_DL_DB_SCHEMA_INFO = 0x00000000
    _RECORD_TYPE_CSSM_DL_DB_SCHEMA_INDEXES = 0x00000001
    _RECORD_TYPE_CSSM_DL_DB_SCHEMA_ATTRIBUTES = 0x00000002
    _RECORD_TYPE_APPLICATION_PASSWORD = 0x80000000
    _RECORD_TYPE_INTERNET_PASSWORD = 0x80000001

    _ATTRIBUTE_DATA_READ_FUNCTIONS = {
        0: "_ReadAttributeValueString",
        1: "_ReadAttributeValueInteger",
        2: "_ReadAttributeValueInteger",
        5: "_ReadAttributeValueDateTime",
        6: "_ReadAttributeValueBinaryData",
    }

    def _ReadAttributeValueBinaryData(
        self,
        attribute_values_data,
        record_offset,
        attribute_values_data_offset,
        attribute_value_offset,
    ):
        """Reads a binary data attribute value.

        Args:
          attribute_values_data (bytes): attribute values data.
          record_offset (int): offset of the record relative to the start of the file.
          attribute_values_data_offset (int): offset of the attribute values data
              relative to the start of the record.
          attribute_value_offset (int): offset of the attribute relative to the start
              of the record.

        Returns:
          bytes: binary data value or None if attribute value offset is not set.

        Raises:
          ParseError: if the attribute value cannot be read.
        """
        if attribute_value_offset == 0:
            return None

        data_type_map = self._GetDataTypeMap("keychain_blob")

        file_offset = (
            record_offset + attribute_values_data_offset + attribute_value_offset
        )
        attribute_value_offset -= attribute_values_data_offset + 1
        attribute_value_data = attribute_values_data[attribute_value_offset:]

        try:
            string_attribute_value = self._ReadStructureFromByteStream(
                attribute_value_data, file_offset, data_type_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to map binary data attribute value data at offset: "
                f"0x{file_offset:08x} with error: {exception!s}"
            )

        return string_attribute_value.blob

    def _ReadAttributeValueDateTime(
        self,
        attribute_values_data,
        record_offset,
        attribute_values_data_offset,
        attribute_value_offset,
    ):
        """Reads a date time attribute value.

        Args:
          attribute_values_data (bytes): attribute values data.
          record_offset (int): offset of the record relative to the start of the file.
          attribute_values_data_offset (int): offset of the attribute values data
              relative to the start of the record.
          attribute_value_offset (int): offset of the attribute relative to the start
              of the record.

        Returns:
          str: date and time values.

        Raises:
          ParseError: if the attribute value cannot be read.
        """
        if attribute_value_offset == 0:
            return None

        data_type_map = self._GetDataTypeMap("keychain_date_time")

        file_offset = (
            record_offset + attribute_values_data_offset + attribute_value_offset
        )
        attribute_value_offset -= attribute_values_data_offset + 1
        attribute_value_data = attribute_values_data[attribute_value_offset:]

        try:
            date_time_attribute_value = self._ReadStructureFromByteStream(
                attribute_value_data, file_offset, data_type_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to map date time attribute value data at offset: "
                f"0x{file_offset:08x} with error: {exception!s}"
            )

        return date_time_attribute_value.date_time.rstrip("\x00")

    def _ReadAttributeValueInteger(
        self,
        attribute_values_data,
        record_offset,
        attribute_values_data_offset,
        attribute_value_offset,
    ):
        """Reads an integer attribute value.

        Args:
          attribute_values_data (bytes): attribute values data.
          record_offset (int): offset of the record relative to the start of the file.
          attribute_values_data_offset (int): offset of the attribute values data
              relative to the start of the record.
          attribute_value_offset (int): offset of the attribute relative to the start
              of the record.

        Returns:
          int: integer value or None if attribute value offset is not set.

        Raises:
          ParseError: if the attribute value cannot be read.
        """
        if attribute_value_offset == 0:
            return None

        data_type_map = self._GetDataTypeMap("uint32be")

        file_offset = (
            record_offset + attribute_values_data_offset + attribute_value_offset
        )
        attribute_value_offset -= attribute_values_data_offset + 1
        attribute_value_data = attribute_values_data[attribute_value_offset:]

        try:
            return self._ReadStructureFromByteStream(
                attribute_value_data, file_offset, data_type_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to map integer attribute value data at offset: "
                f"0x{file_offset:08x} with error: {exception!s}"
            )

    def _ReadAttributeValueString(
        self,
        attribute_values_data,
        record_offset,
        attribute_values_data_offset,
        attribute_value_offset,
    ):
        """Reads a string attribute value.

        Args:
          attribute_values_data (bytes): attribute values data.
          record_offset (int): offset of the record relative to the start of the file.
          attribute_values_data_offset (int): offset of the attribute values data
              relative to the start of the record.
          attribute_value_offset (int): offset of the attribute relative to the start
              of the record.

        Returns:
          str: string value or None if attribute value offset is not set.

        Raises:
          ParseError: if the attribute value cannot be read.
        """
        if attribute_value_offset == 0:
            return None

        data_type_map = self._GetDataTypeMap("keychain_string")

        file_offset = (
            record_offset + attribute_values_data_offset + attribute_value_offset
        )
        attribute_value_offset -= attribute_values_data_offset + 1
        attribute_value_data = attribute_values_data[attribute_value_offset:]

        try:
            string_attribute_value = self._ReadStructureFromByteStream(
                attribute_value_data, file_offset, data_type_map
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to map string attribute value data at offset: "
                f"0x{file_offset:08x} with error: {exception!s}"
            )

        return string_attribute_value.string

    def _ReadFileHeader(self, file_object):
        """Reads the file header.

        Args:
          file_object (file): file-like object.

        Returns:
          keychain_file_header: file header.

        Raises:
          ParseError: if the file header cannot be read.
        """
        data_type_map = self._GetDataTypeMap("keychain_file_header")

        file_header, _ = self._ReadStructureFromFileObject(
            file_object, 0, data_type_map
        )
        if (
            file_header.major_format_version != self._MAJOR_VERSION
            or file_header.minor_format_version != self._MINOR_VERSION
        ):
            raise errors.ParseError(
                f"Unsupported format version: {file_header.major_format_version:s}."
                f"{file_header.minor_format_version:s}"
            )

        return file_header

    def _ReadRecord(self, tables, file_object, record_offset, record_type):
        """Reads the record.

        Args:
          tables (dict[int, KeychainDatabaseTable]): tables per identifier.
          file_object (file): file-like object.
          record_offset (int): offset of the record relative to the start of
              the file.
          record_type (int): record type, which should correspond to a relation
              identifier of a table defined in the schema.

        Raises:
          ParseError: if the record cannot be read.
        """
        table = tables.get(record_type)
        if not table:
            raise errors.ParseError(f"Missing table for record type: {record_type:d}")

        record_header = self._ReadRecordHeader(file_object, record_offset)

        record = collections.OrderedDict()

        if table.columns:
            attribute_value_offsets = self._ReadRecordAttributeValueOffset(
                file_object, record_offset + 24, len(table.columns)
            )

        file_offset = file_object.tell()
        record_data_offset = file_offset - record_offset
        record_data_size = record_header.data_size - (file_offset - record_offset)
        record_data = file_object.read(record_data_size)

        if record_header.key_data_size > 0:
            record["_key_"] = record_data[: record_header.key_data_size]

        if table.columns:
            for index, column in enumerate(table.columns):
                attribute_data_read_function = self._ATTRIBUTE_DATA_READ_FUNCTIONS.get(
                    column.attribute_data_type, None
                )
                if attribute_data_read_function:
                    attribute_data_read_function = getattr(
                        self, attribute_data_read_function, None
                    )

                if not attribute_data_read_function:
                    attribute_value = None
                else:
                    attribute_value = attribute_data_read_function(
                        record_data,
                        record_offset,
                        record_data_offset,
                        attribute_value_offsets[index],
                    )

                record[column.attribute_name] = attribute_value

        table.records.append(record)

    def _ReadRecordAttributeValueOffset(
        self, file_object, file_offset, number_of_attribute_values
    ):
        """Reads the record attribute value offsets.

        Args:
          file_object (file): file-like object.
          file_offset (int): offset of the record attribute values offsets relative
              to the start of the file.
          number_of_attribute_values (int): number of attribute values.

        Returns:
          keychain_record_attribute_value_offsets: record attribute value offsets.

        Raises:
          ParseError: if the record attribute value offsets cannot be read.
        """
        offsets_data_size = number_of_attribute_values * 4

        offsets_data = file_object.read(offsets_data_size)

        context = dtfabric_data_maps.DataTypeMapContext(
            values={"number_of_attribute_values": number_of_attribute_values}
        )
        data_type_map = self._GetDataTypeMap("keychain_record_attribute_value_offsets")

        try:
            attribute_value_offsets = self._ReadStructureFromByteStream(
                offsets_data, file_offset, data_type_map, context=context
            )
        except (ValueError, errors.ParseError) as exception:
            raise errors.ParseError(
                f"Unable to map record attribute value offsets data at offset: "
                f"0x{file_offset:08x} with error: {exception!s}"
            )

        return attribute_value_offsets

    def _ReadRecordHeader(self, file_object, record_header_offset):
        """Reads the record header.

        Args:
          file_object (file): file-like object.
          record_header_offset (int): offset of the record header relative to
              the start of the file.

        Returns:
          keychain_record_header: record header.

        Raises:
          ParseError: if the record header cannot be read.
        """
        data_type_map = self._GetDataTypeMap("keychain_record_header")

        record_header, _ = self._ReadStructureFromFileObject(
            file_object, record_header_offset, data_type_map
        )
        return record_header

    def _ReadRecordSchemaAttributes(self, tables, file_object, record_offset):
        """Reads a schema attributes (CSSM_DL_DB_SCHEMA_ATTRIBUTES) record.

        Args:
          tables (dict[int, KeychainDatabaseTable]): tables per identifier.
          file_object (file): file-like object.
          record_offset (int): offset of the record relative to the start of
              the file.

        Raises:
          ParseError: if the record cannot be read.
        """
        record_header = self._ReadRecordHeader(file_object, record_offset)

        attribute_value_offsets = self._ReadRecordAttributeValueOffset(
            file_object, record_offset + 24, 6
        )
        file_offset = file_object.tell()
        attribute_values_data_offset = file_offset - record_offset
        attribute_values_data_size = record_header.data_size - (
            file_offset - record_offset
        )
        attribute_values_data = file_object.read(attribute_values_data_size)

        relation_identifier = self._ReadAttributeValueInteger(
            attribute_values_data,
            record_offset,
            attribute_values_data_offset,
            attribute_value_offsets[0],
        )
        attribute_identifier = self._ReadAttributeValueInteger(
            attribute_values_data,
            record_offset,
            attribute_values_data_offset,
            attribute_value_offsets[1],
        )
        attribute_name_data_type = self._ReadAttributeValueInteger(
            attribute_values_data,
            record_offset,
            attribute_values_data_offset,
            attribute_value_offsets[2],
        )
        attribute_name = self._ReadAttributeValueString(
            attribute_values_data,
            record_offset,
            attribute_values_data_offset,
            attribute_value_offsets[3],
        )
        # TODO: handle attribute_value_offsets[4]

        attribute_data_type = self._ReadAttributeValueInteger(
            attribute_values_data,
            record_offset,
            attribute_values_data_offset,
            attribute_value_offsets[5],
        )
        table = tables.get(relation_identifier)

        if not table:
            raise errors.ParseError(
                f"Missing table for relation identifier: {relation_identifier:d}"
            )

        if attribute_name is None and attribute_value_offsets[1] != 0:
            attribute_value_offset = attribute_value_offsets[1]
            attribute_value_offset -= attribute_values_data_offset + 1
            attribute_name = attribute_values_data[
                attribute_value_offset : attribute_value_offset + 4
            ]
            attribute_name = attribute_name.decode("ascii")

        column = KeychainDatabaseColumn()
        column.attribute_data_type = attribute_data_type
        column.attribute_identifier = attribute_identifier
        column.attribute_name = attribute_name

        table.columns.append(column)

        table = tables.get(self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_ATTRIBUTES)
        if not table:
            raise errors.ParseError("Missing CSSM_DL_DB_SCHEMA_ATTRIBUTES table.")

        record = collections.OrderedDict(
            {
                "RelationID": relation_identifier,
                "AttributeID": attribute_identifier,
                "AttributeNameFormat": attribute_name_data_type,
                "AttributeName": attribute_name,
                "AttributeFormat": attribute_data_type,
            }
        )
        table.records.append(record)

    def _ReadRecordSchemaIndexes(self, tables, file_object, record_offset):
        """Reads a schema indexes (CSSM_DL_DB_SCHEMA_INDEXES) record.

        Args:
          tables (dict[int, KeychainDatabaseTable]): tables per identifier.
          file_object (file): file-like object.
          record_offset (int): offset of the record relative to the start of
              the file.

        Raises:
          ParseError: if the record cannot be read.
        """
        _ = self._ReadRecordHeader(file_object, record_offset)

        attribute_value_offsets = self._ReadRecordAttributeValueOffset(
            file_object, record_offset + 24, 5
        )
        if attribute_value_offsets != (0x2D, 0x31, 0x35, 0x39, 0x3D):
            raise errors.ParseError("Unsupported record attribute value offsets")

        file_offset = file_object.tell()
        data_type_map = self._GetDataTypeMap("keychain_record_schema_indexes")

        record_values, _ = self._ReadStructureFromFileObject(
            file_object, file_offset, data_type_map
        )
        if record_values.relation_identifier not in tables:
            raise errors.ParseError(
                "CSSM_DL_DB_SCHEMA_INDEXES defines relation identifier not defined "
                "in CSSM_DL_DB_SCHEMA_INFO."
            )

        table = tables.get(self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_INDEXES)
        if not table:
            raise errors.ParseError("Missing CSSM_DL_DB_SCHEMA_INDEXES table.")

        record = collections.OrderedDict(
            {
                "RelationID": record_values.relation_identifier,
                "IndexID": record_values.index_identifier,
                "AttributeID": record_values.attribute_identifier,
                "IndexType": record_values.index_type,
                "IndexedDataLocation": record_values.index_data_location,
            }
        )
        table.records.append(record)

    def _ReadRecordSchemaInformation(self, tables, file_object, record_offset):
        """Reads a schema information (CSSM_DL_DB_SCHEMA_INFO) record.

        Args:
          tables (dict[int, KeychainDatabaseTable]): tables per identifier.
          file_object (file): file-like object.
          record_offset (int): offset of the record relative to the start of
              the file.

        Raises:
          ParseError: if the record cannot be read.
        """
        _ = self._ReadRecordHeader(file_object, record_offset)

        attribute_value_offsets = self._ReadRecordAttributeValueOffset(
            file_object, record_offset + 24, 2
        )
        if attribute_value_offsets != (0x21, 0x25):
            raise errors.ParseError("Unsupported record attribute value offsets")

        file_offset = file_object.tell()
        data_type_map = self._GetDataTypeMap("keychain_record_schema_information")

        record_values, _ = self._ReadStructureFromFileObject(
            file_object, file_offset, data_type_map
        )
        relation_name = record_values.relation_name.decode("ascii")

        table = KeychainDatabaseTable()
        table.relation_identifier = record_values.relation_identifier
        table.relation_name = relation_name

        tables[table.relation_identifier] = table

        table = tables.get(self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_INFO)
        if not table:
            raise errors.ParseError("Missing CSSM_DL_DB_SCHEMA_INFO table.")

        record = collections.OrderedDict(
            {
                "RelationID": record_values.relation_identifier,
                "RelationName": relation_name,
            }
        )
        table.records.append(record)

    def _ReadTable(self, tables, file_object, table_offset):
        """Reads the table.

        Args:
          tables (dict[int, KeychainDatabaseTable]): tables per identifier.
          file_object (file): file-like object.
          table_offset (int): offset of the table relative to the start of
              the file.

        Raises:
          ParseError: if the table cannot be read.
        """
        table_header = self._ReadTableHeader(file_object, table_offset)

        for record_offset in table_header.record_offsets:
            if record_offset == 0:
                continue

            record_offset += table_offset

            if table_header.record_type == self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_INFO:
                self._ReadRecordSchemaInformation(tables, file_object, record_offset)
            elif table_header.record_type == (
                self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_INDEXES
            ):
                self._ReadRecordSchemaIndexes(tables, file_object, record_offset)
            elif table_header.record_type == (
                self._RECORD_TYPE_CSSM_DL_DB_SCHEMA_ATTRIBUTES
            ):
                self._ReadRecordSchemaAttributes(tables, file_object, record_offset)
            else:
                self._ReadRecord(
                    tables, file_object, record_offset, table_header.record_type
                )

    def _ReadTableHeader(self, file_object, table_header_offset):
        """Reads the table header.

        Args:
          file_object (file): file-like object.
          table_header_offset (int): offset of the tables header relative to
              the start of the file.

        Returns:
          keychain_table_header: table header.

        Raises:
          ParseError: if the table header cannot be read.
        """
        data_type_map = self._GetDataTypeMap("keychain_table_header")

        table_header, _ = self._ReadStructureFromFileObject(
            file_object, table_header_offset, data_type_map
        )
        return table_header

    def _ReadTablesArray(self, file_object, tables_array_offset):
        """Reads the tables array.

        Args:
          file_object (file): file-like object.
          tables_array_offset (int): offset of the tables array relative to
              the start of the file.

        Returns:
          dict[int, KeychainDatabaseTable]: tables per identifier.

        Raises:
          ParseError: if the tables array cannot be read.
        """
        # TODO: implement https://github.com/libyal/dtfabric/issues/12 and update
        # keychain_tables_array definition.

        data_type_map = self._GetDataTypeMap("keychain_tables_array")

        tables_array, _ = self._ReadStructureFromFileObject(
            file_object, tables_array_offset, data_type_map
        )
        tables = collections.OrderedDict()
        for table_offset in tables_array.table_offsets:
            self._ReadTable(tables, file_object, tables_array_offset + table_offset)

        return tables

    def _ParseDateTimeValue(self, parser_mediator, record, name):
        """Parses a date time value.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          record (dict[str, object]): database record.
          name (str): name of the date time value (CSSM_DB_ATTRIBUTE_FORMAT_TIME_DATE)
              the format: "YYYYMMDDhhmmssZ".

        Returns:
          tuple: containing:

              dfdatetime.TimeElements: date and time extracted from the value or None
                  if the value does not represent a valid string.
              bool: value to indicate the date and time value was corrupted.
        """
        date_time_value = record[name]
        if not date_time_value:
            return None, False

        if date_time_value[14] != "Z":
            parser_mediator.ProduceWarning(
                f"unsupported date and time value: {date_time_value!s}"
            )
            return None, True

        try:
            year = int(date_time_value[0:4], 10)
            month = int(date_time_value[4:6], 10)
            day_of_month = int(date_time_value[6:8], 10)
            hours = int(date_time_value[8:10], 10)
            minutes = int(date_time_value[10:12], 10)
            seconds = int(date_time_value[12:14], 10)
        except (TypeError, ValueError):
            parser_mediator.ProduceWarning(
                f"unsupported date and time value: {date_time_value!s}"
            )
            return None, True

        time_elements_tuple = (year, month, day_of_month, hours, minutes, seconds)

        try:
            date_time = dfdatetime_time_elements.TimeElements(
                time_elements_tuple=time_elements_tuple
            )
        except ValueError:
            parser_mediator.ProduceWarning(
                f"unsupported date and time value: {date_time_value!s}"
            )
            return None, True

        return date_time, False

    def _ParseBinaryDataAsString(self, parser_mediator, record, name):
        """Parses a binary data value as string.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          record (dict[str, object]): database record.
          name (str): name of the binary data value (CSSM_DB_ATTRIBUTE_FORMAT_BLOB).

        Returns:
          tuple: containing:

              str: binary data value formatted as a string or None if no string could
                  be extracted or binary data value is None (NULL).
              bool: value to indicate the binary data value was corrupted.
        """
        binary_data_value = record[name]
        if not binary_data_value:
            return None, False

        corrupted = False

        try:
            value = binary_data_value.decode("utf-8")
        except UnicodeDecodeError:
            parser_mediator.ProduceWarning(
                f"Unable to decode binary data value: {name:s} as UTF-8. Unsupported "
                f"code points are escaped."
            )
            value = binary_data_value.decode("utf-8", errors="backslashreplace")
            corrupted = True

        return value, corrupted

    def _ParseIntegerTagString(self, parser_mediator, record, name):
        """Parses an integer value as a tag string.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          record (dict[str, object]): database record.
          name (str): name of the integer value (CSSM_DB_ATTRIBUTE_FORMAT_SINT32 or
              CSSM_DB_ATTRIBUTE_FORMAT_UINT32) that represents a tag string.

        Returns:
          tuple: containing:

              str: integer value formatted as a tag string or None if integer value is
                  None (NULL).
              bool: value to indicate the tag string value was corrupted.
        """
        integer_value = record[name]
        if not integer_value:
            return None, False

        corrupted = False
        tag_string = codecs.decode(f"{integer_value:08x}", "hex")

        try:
            tag_string = tag_string.decode("utf-8")
        except UnicodeDecodeError:
            parser_mediator.ProduceWarning(
                f"Unable to decode tag string value: {name:s} as UTF-8. Unsupported "
                f"code points are escaped."
            )
            tag_string = tag_string.decode("utf-8", errors="backslashreplace")
            corrupted = True

        return tag_string, corrupted

    def _ParseApplicationPasswordRecord(self, parser_mediator, record):
        """Extracts the information from an application password record.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          record (dict[str, object]): database record.

        Raises:
          ParseError: if Internet password record cannot be parsed.
        """
        key = record.get("_key_")
        if not key or not key.startswith(b"ssgp"):
            raise errors.ParseError(
                "Unsupported application password record key value does not start "
                'with: "ssgp".'
            )

        corrupted = False

        ssgp_hash = codecs.encode(key[4:], "hex")
        try:
            ssgp_hash = ssgp_hash.decode("utf-8")
        except UnicodeDecodeError:
            parser_mediator.ProduceWarning(
                "Unable to decode ssgp hash as UTF-8. Unsupported code points are "
                "escaped."
            )
            ssgp_hash = ssgp_hash.decode("utf-8", errors="backslashreplace")
            corrupted = True

        event_data = KeychainApplicationRecordEventData()
        event_data.account_name, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator,
            record,
            "acct",
        )
        event_data.comments, value_corrupted = self._ParseIntegerTagString(
            parser_mediator, record, "crtr"
        )
        corrupted = corrupted or value_corrupted

        event_data.creation_time, value_corrupted = self._ParseDateTimeValue(
            parser_mediator, record, "cdat"
        )
        corrupted = corrupted or value_corrupted

        event_data.entry_name, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator,
            record,
            "PrintName",
        )
        corrupted = corrupted or value_corrupted

        event_data.modification_time, value_corrupted = self._ParseDateTimeValue(
            parser_mediator, record, "mdat"
        )
        corrupted = corrupted or value_corrupted

        event_data.ssgp_hash = ssgp_hash
        event_data.text_description, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator,
            record,
            "desc",
        )
        corrupted = corrupted or value_corrupted

        parser_mediator.ProduceEventData(event_data, corrupted=corrupted)

    def _ParseInternetPasswordRecord(self, parser_mediator, record):
        """Extracts the information from an Internet password record.

        Args:
          parser_mediator (ParserMediator): mediates interactions between parsers and
              other components, such as storage and dfVFS.
          record (dict[str, object]): database record.

        Raises:
          ParseError: if Internet password record cannot be parsed.
        """
        key = record.get("_key_")
        if not key or not key.startswith(b"ssgp"):
            raise errors.ParseError(
                "Unsupported Internet password record key value does not start "
                'with: "ssgp".'
            )

        corrupted = False

        protocol_value = record["ptcl"]
        protocol_string = codecs.decode(f"{protocol_value:08x}", "hex")

        try:
            protocol_string = protocol_string.decode("utf-8")
        except UnicodeDecodeError:
            parser_mediator.ProduceWarning(
                "Unable to decode ptcl as UTF-8. Unsupported code points are escaped."
            )
            protocol_string = protocol_string.decode("utf-8", errors="backslashreplace")
            corrupted = True

        ssgp_hash = codecs.encode(key[4:], "hex")
        try:
            ssgp_hash = ssgp_hash.decode("utf-8")
        except UnicodeDecodeError:
            parser_mediator.ProduceWarning(
                "Unable to decode ssgp hash as UTF-8. Unsupported code points are "
                "escaped."
            )
            ssgp_hash = ssgp_hash.decode("utf-8", errors="backslashreplace")
            corrupted = True

        event_data = KeychainInternetRecordEventData()
        event_data.account_name, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator, record, "acct"
        )
        corrupted = corrupted or value_corrupted

        event_data.comments, value_corrupted = self._ParseIntegerTagString(
            parser_mediator, record, "crtr"
        )
        corrupted = corrupted or value_corrupted

        event_data.creation_time, value_corrupted = self._ParseDateTimeValue(
            parser_mediator, record, "cdat"
        )
        corrupted = corrupted or value_corrupted

        event_data.entry_name, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator, record, "PrintName"
        )
        corrupted = corrupted or value_corrupted

        event_data.modification_time, value_corrupted = self._ParseDateTimeValue(
            parser_mediator, record, "mdat"
        )
        corrupted = corrupted or value_corrupted

        event_data.protocol = self._PROTOCOL_TRANSLATION_DICT.get(
            protocol_string, protocol_string
        )
        event_data.ssgp_hash = ssgp_hash
        event_data.text_description, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator, record, "desc"
        )
        corrupted = corrupted or value_corrupted

        event_data.type_protocol, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator, record, "atyp"
        )
        corrupted = corrupted or value_corrupted

        event_data.where, value_corrupted = self._ParseBinaryDataAsString(
            parser_mediator, record, "srvr"
        )
        corrupted = corrupted or value_corrupted

        parser_mediator.ProduceEventData(event_data)

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

        Returns:
          FormatSpecification: format specification.
        """
        format_specification = specification.FormatSpecification(cls.NAME)
        format_specification.AddNewSignature(b"kych", offset=0)
        return format_specification

    def ParseFileObject(self, parser_mediator, file_object):
        """Parses a MacOS keychain file-like object.

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

        Raises:
          WrongParser: when the file cannot be parsed.
        """
        try:
            file_header = self._ReadFileHeader(file_object)
        except (ValueError, errors.ParseError):
            raise errors.WrongParser("Unable to parse file header.")

        tables = self._ReadTablesArray(file_object, file_header.tables_array_offset)

        table = tables.get(self._RECORD_TYPE_APPLICATION_PASSWORD)
        if table:
            for record in table.records:
                self._ParseApplicationPasswordRecord(parser_mediator, record)

        table = tables.get(self._RECORD_TYPE_INTERNET_PASSWORD)
        if table:
            for record in table.records:
                self._ParseInternetPasswordRecord(parser_mediator, record)


manager.ParsersManager.RegisterParser(KeychainParser)
