"""File entry filters."""

import abc
import collections

import pysigscan

from dfdatetime import time_elements

from plaso.filters import logger


class FileEntryFilter:
  """File entry filter interface."""

  # pylint: disable=redundant-returns-doc
  @abc.abstractmethod
  def Matches(self, file_entry):
    """Compares the file entry against the filter.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches the filter, False if not or
          None if the filter does not apply.
    """

  @abc.abstractmethod
  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """


class DateTimeFileEntryFilter(FileEntryFilter):
  """Date and time-based file entry filter."""

  _DATE_TIME_RANGE_TUPLE = collections.namedtuple(
      'date_time_range_tuple', 'time_value start_date_time end_date_time')

  # Maps the time value of the date time range to a file entry attribute name.
  _TIME_VALUE_MAPPINGS = {
      'atime': 'access_time',
      'bkup': 'backup_time',
      'ctime': 'change_time',
      'crtime': 'creation_time',
      'dtime': 'deletion_time',
      'mtime': 'modification_time'}

  _SUPPORTED_TIME_VALUES = frozenset(_TIME_VALUE_MAPPINGS.keys())

  def __init__(self):
    """Initializes a date and time-based file entry filter."""
    super().__init__()
    self._date_time_ranges = []

  def AddDateTimeRange(
      self, time_value, start_time_string=None, end_time_string=None):
    """Adds a date time filter range.

    The time strings are formatted as:
    YYYY-MM-DD hh:mm:ss.######[+-]##:##
    Where # are numeric digits ranging from 0 to 9 and the seconds
    fraction can be either 3 or 6 digits. The time of day, seconds fraction
    and timezone offset are optional. The default timezone is UTC.

    Args:
      time_value (str): time value, such as, atime, ctime, crtime, dtime, bkup
          and mtime.
      start_time_string (str): start date and time value string.
      end_time_string (str): end date and time value string.

    Raises:
      ValueError: If the filter is badly formed.
    """
    if not isinstance(time_value, str):
      raise ValueError('Filter type must be a string.')

    if start_time_string is None and end_time_string is None:
      raise ValueError(
          'Filter must have either a start or an end date time value.')

    time_value_lower = time_value.lower()
    if time_value_lower not in self._SUPPORTED_TIME_VALUES:
      raise ValueError(f'Unsupported time value: {time_value:s}.')

    start_date_time = None
    if start_time_string:
      start_date_time = time_elements.TimeElementsInMicroseconds()
      start_date_time.CopyFromDateTimeString(start_time_string)

    end_date_time = None
    if end_time_string:
      end_date_time = time_elements.TimeElementsInMicroseconds()
      end_date_time.CopyFromDateTimeString(end_time_string)

    # Make sure that the end timestamp occurs after the beginning.
    # If not then we need to reverse the time range.
    if (None not in (start_date_time, end_date_time) and
        start_date_time > end_date_time):
      raise ValueError(
          'Invalid date time value start must be earlier than end.')

    self._date_time_ranges.append(self._DATE_TIME_RANGE_TUPLE(
        time_value_lower, start_date_time, end_date_time))

  def Matches(self, file_entry):
    """Compares the file entry against the filter.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches the filter, False if not or
          None if the filter does not apply.
    """
    if not self._date_time_ranges:
      return None

    for date_time_range in self._date_time_ranges:
      time_attribute = self._TIME_VALUE_MAPPINGS.get(
          date_time_range.time_value, None)
      if not time_attribute:
        continue

      timestamp = getattr(file_entry, time_attribute, None)
      if timestamp is None:
        continue

      if (date_time_range.start_date_time is not None and
          timestamp < date_time_range.start_date_time):
        return False

      if (date_time_range.end_date_time is not None and
          timestamp > date_time_range.end_date_time):
        return False

    return True

  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """
    if self._date_time_ranges:
      for date_time_range in self._date_time_ranges:
        if date_time_range.start_date_time is None:
          end_time_string = date_time_range.end_date_time.CopyToDateTimeString()
          output_writer.Write(
              f'\t{date_time_range.time_value:s} after {end_time_string:s}\n')

        elif date_time_range.end_date_time is None:
          start_time_string = (
              date_time_range.start_date_time.CopyToDateTimeString())
          output_writer.Write((
              f'\t{date_time_range.time_value:s} before '
              f'{start_time_string:s}\n'))

        else:
          start_time_string = (
              date_time_range.start_date_time.CopyToDateTimeString())
          end_time_string = date_time_range.end_date_time.CopyToDateTimeString()
          output_writer.Write(
              f'\t{date_time_range.time_value:s} between '
              f'{start_time_string:s} and {end_time_string:s}\n')


class ExtensionsFileEntryFilter(FileEntryFilter):
  """Extensions-based file entry filter."""

  def __init__(self, extensions):
    """Initializes an extensions-based file entry filter.

    An extension is defined as "pdf" as in "document.pdf".

    Args:
      extensions (list[str]): a list of extension strings.
    """
    super().__init__()
    self._extensions = extensions

  def Matches(self, file_entry):
    """Compares the file entry against the filter.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches the filter, False if not or
          None if the filter does not apply.
    """
    location = getattr(file_entry.path_spec, 'location', None)
    if not location:
      return None

    if '.' not in location:
      return False

    _, _, extension = location.rpartition('.')
    return extension.lower() in self._extensions

  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """
    if self._extensions:
      extensions = ', '.join(self._extensions)
      output_writer.Write(f'\textensions: {extensions:s}\n')


class NamesFileEntryFilter(FileEntryFilter):
  """Names-based file entry filter."""

  def __init__(self, names):
    """Initializes a names-based file entry filter.

    Args:
      names (list[str]): names.
    """
    super().__init__()
    self._names = names

  def Matches(self, file_entry):
    """Compares the file entry against the filter.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches the filter.
    """
    if not self._names or not file_entry.IsFile():
      return False

    return file_entry.name.lower() in self._names

  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """
    if self._names:
      names = ', '.join(self._names)
      output_writer.Write(f'\tnames: {names:s}\n')


class SignaturesFileEntryFilter(FileEntryFilter):
  """Signature-based file entry filter."""

  def __init__(self, specification_store, signature_identifiers):
    """Initializes a signature-based file entry filter.

    Args:
      specification_store (FormatSpecificationStore): a specification store.
      signature_identifiers (list[str]): signature identifiers.
    """
    super().__init__()
    self._file_scanner = None
    self._signature_identifiers = []

    self._file_scanner = self._GetScanner(
        specification_store, signature_identifiers)

  def _GetScanner(self, specification_store, signature_identifiers):
    """Initializes the scanner form the specification store.

    Args:
      specification_store (FormatSpecificationStore): a specification store.
      signature_identifiers (list[str]): signature identifiers.

    Returns:
      pysigscan.scanner: signature scanner or None.
    """
    if not specification_store:
      return None

    scanner_object = pysigscan.scanner()

    for format_specification in specification_store.specifications:
      if format_specification.identifier not in signature_identifiers:
        continue

      for signature in format_specification.signatures:
        pattern_offset = signature.offset
        if pattern_offset is None:
          signature_flags = pysigscan.signature_flags.NO_OFFSET
        elif pattern_offset < 0:
          pattern_offset *= -1
          signature_flags = pysigscan.signature_flags.RELATIVE_FROM_END
        else:
          signature_flags = pysigscan.signature_flags.RELATIVE_FROM_START

        scanner_object.add_signature(
            signature.identifier, pattern_offset, signature.pattern,
            signature_flags)

      self._signature_identifiers.append(format_specification.identifier)

    return scanner_object

  def Matches(self, file_entry):
    """Compares the file entry against the filter.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches the filter, False if not or
          None if the filter does not apply.
    """
    if not self._file_scanner or not file_entry.IsFile():
      return None

    file_object = file_entry.GetFileObject()
    if not file_object:
      return False

    try:
      scan_state = pysigscan.scan_state()
      self._file_scanner.scan_file_object(scan_state, file_object)

    except OSError as exception:
      # TODO: replace location by display name.
      location = getattr(file_entry.path_spec, 'location', '')
      logger.error(
          f'[skipping] unable to scan file: {location:s} for signatures '
          f'with error: {exception!s}')
      return False

    return scan_state.number_of_scan_results > 0

  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """
    if self._file_scanner:
      identifiers = ", ".join(self._signature_identifiers)
      output_writer.Write(f'\tsignature identifiers: {identifiers:s}\n')


class FileEntryFilterCollection:
  """Collection of file entry filters."""

  def __init__(self):
    """Initializes a file entry filter collection."""
    super().__init__()
    self._artifacts_trie = None
    self._filters = []

  def AddFilter(self, file_entry_filter):
    """Adds a file entry filter to the collection.

    Args:
      file_entry_filter (FileEntryFilter): file entry filter.
    """
    self._filters.append(file_entry_filter)

  def GetMatchingArtifacts(self, path, path_separator):
    """Retrieves the artifacts that match the given path.

    Args:
      path (str): The path of the extracted file.
      path_separator (str): The path separator.

    Returns:
      list[str]: A list of artifact names that match the path.
    """
    if not self._artifacts_trie:
      return []

    return self._artifacts_trie.GetMatchingArtifacts(path, path_separator)

  def HasFilters(self):
    """Determines if filters are defined.

    Returns:
      bool: True if filters are defined.
    """
    return bool(self._filters)

  def Matches(self, file_entry):
    """Compares the file entry against the filter collection.

    Args:
      file_entry (dfvfs.FileEntry): file entry to compare.

    Returns:
      bool: True if the file entry matches one of the filters. If no filters
          are provided or applicable the result will be True.
    """
    if not self._filters:
      return True

    results = []
    for file_entry_filter in self._filters:
      result = file_entry_filter.Matches(file_entry)
      results.append(result)

    return True in results or False not in results

  def Print(self, output_writer):
    """Prints a human readable version of the filter.

    Args:
      output_writer (CLIOutputWriter): output writer.
    """
    if self._filters:
      output_writer.Write('Filters:\n')
      for file_entry_filter in self._filters:
        file_entry_filter.Print(output_writer)

  def SetArtifactsTrie(self, artifacts_trie):
    """Sets the artifacts trie.

    Args:
      artifacts_trie (ArtifactsTrie): artifacts trie.
    """
    self._artifacts_trie = artifacts_trie
