"""The command line interface (CLI) tools classes."""

import abc
import codecs
import datetime
import locale
import sys
import time
import textwrap

import pytz

try:
    import resource
except ImportError:
    resource = None

import plaso

from plaso.cli import logger
from plaso.cli import views
from plaso.lib import definitions
from plaso.lib import errors


class CLITool:
    """Command line interface tool.

    Attributes:
      preferred_encoding (str): preferred encoding of single-byte or multi-byte
          character strings, sometimes referred to as extended ASCII.
      show_troubleshooting (bool): True if troubleshooting information should
          be shown.
    """

    NAME = ""

    # The maximum number of characters of a line written to the output writer.
    _LINE_LENGTH = 80

    # The fall back preferred encoding.
    _PREFERRED_ENCODING = "utf-8"

    def __init__(self, input_reader=None, output_writer=None):
        """Initializes a command line interface tool.

        Args:
          input_reader (Optional[CLIInputReader]): input reader, where None
              indicates that the stdin input reader should be used.
          output_writer (Optional[CLIOutputWriter]): output writer, where None
              indicates that the stdout output writer should be used.
        """
        super().__init__()

        preferred_encoding = locale.getpreferredencoding()
        if not preferred_encoding:
            preferred_encoding = self._PREFERRED_ENCODING
        elif isinstance(preferred_encoding, bytes):
            preferred_encoding = preferred_encoding.decode("utf-8")

        if not input_reader:
            input_reader = StdinInputReader(encoding=preferred_encoding)
        if not output_writer:
            output_writer = StdoutOutputWriter(encoding=preferred_encoding)

        self._data_location = None
        self._debug_mode = False
        self._encode_errors = "strict"
        self._has_user_warning = False
        self._input_reader = input_reader
        self._log_file = None
        self._output_writer = output_writer
        self._quiet_mode = False
        self._unattended_mode = False
        self._views_format_type = views.ViewsFactory.FORMAT_TYPE_CLI
        self._vfs_back_end = "auto"

        self.preferred_encoding = preferred_encoding
        self.show_troubleshooting = False

    @property
    def data_location(self):
        """str: path of the data files."""
        return self._data_location

    def _CanEnforceProcessMemoryLimit(self):
        """Determines if a process memory limit can be enforced.

        Returns:
          bool: True if a process memory limit can be enforced, False otherwise.
        """
        return bool(resource)

    def _EncodeString(self, string):
        """Encodes a string in the preferred encoding.

        Returns:
          bytes: encoded string.
        """
        try:
            # Note that encode() will first convert string into a Unicode string
            # if necessary.
            encoded_string = string.encode(
                self.preferred_encoding, errors=self._encode_errors
            )
        except UnicodeEncodeError:
            if self._encode_errors == "strict":
                logger.error(
                    "Unable to properly write output due to encoding error. "
                    "Switching to error tolerant encoding which can result in "
                    "unsupported code points being escaped."
                )
                self._encode_errors = "backslashreplace"

            encoded_string = string.encode(
                self.preferred_encoding, errors=self._encode_errors
            )

        return encoded_string

    def _EnforceProcessMemoryLimit(self, memory_limit):
        """Enforces a process memory limit.

        Args:
          memory_limit (int): maximum number of bytes the process is allowed
              to allocate, where 0 represents no limit and None a default of
              4 GiB.
        """
        # Resource is not supported on Windows.
        if resource:
            if memory_limit is None:
                memory_limit = 4 * 1024 * 1024 * 1024
            elif memory_limit == 0:
                memory_limit = resource.RLIM_INFINITY

            try:
                resource.setrlimit(resource.RLIMIT_DATA, (memory_limit, memory_limit))
            except ValueError:
                current_limit = resource.getrlimit(resource.RLIMIT_DATA)[0]
                logger.warning(
                    (
                        f"Unable to set memory limit to {memory_limit!s} current limit "
                        f"is {current_limit!s}."
                    )
                )

    def _GetPathSpecificationString(self, path_spec):
        """Retrieves a printable string representation of the path specification.

        Args:
          path_spec (dfvfs.PathSpec): path specification.

        Returns:
          str: printable string representation of the path specification.
        """
        if not path_spec:
            return "N/A"

        return "\n".join(
            [
                line.translate(definitions.NON_PRINTABLE_CHARACTER_TRANSLATION_TABLE)
                for line in path_spec.comparable.split("\n")
            ]
        )

    def _ParseInformationalOptions(self, options):
        """Parses the informational options.

        Args:
          options (argparse.Namespace): command line arguments.
        """
        self._debug_mode = getattr(options, "debug", False)
        self._quiet_mode = getattr(options, "quiet", False)
        self._unattended_mode = getattr(options, "unattended", False)

        if self._debug_mode and self._quiet_mode:
            logger.warning(
                "Cannot use debug and quiet mode at the same time, defaulting to "
                "debug output."
            )

    def _ParseLogFileOptions(self, options):
        """Parses the log file options.

        Args:
          options (argparse.Namespace): command line arguments.
        """
        self._log_file = self.ParseStringOption(options, "log_file")
        if not self._log_file:
            local_date_time = datetime.datetime.now()
            self._log_file = (
                f"{self.NAME:s}-{local_date_time.year:04d}{local_date_time.month:02d}"
                f"{local_date_time.day:02d}T{local_date_time.hour:02d}"
                f"{local_date_time.minute:02d}{local_date_time.second:02d}.log.gz"
            )

    def _PrintUserWarning(self, warning_text):
        """Prints a warning to the user.

        Args:
          warning_text (str): text used to warn the user.
        """
        warning_text = textwrap.wrap(f"WARNING: {warning_text:s}", 80)
        print("\n".join(warning_text), file=sys.stderr)
        print("", file=sys.stderr)

        self._has_user_warning = True

    def _PromptUserForInput(self, input_text):
        """Prompts user for an input.

        Args:
          input_text (str): text used for prompting the user for input.

        Returns:
          str: input read from the user.
        """
        self._output_writer.Write(f"{input_text:s}: ")
        return self._input_reader.Read()

    def _WaitUserWarning(self):
        """Waits 15 seconds after printing warnings to the user."""
        if self._has_user_warning:
            print("Waiting for 15 second to give you time to cancel.")
            print("")

            time.sleep(15)

    def AddBasicOptions(self, argument_group):
        """Adds the basic options to the argument group.

        Args:
          argument_group (argparse._ArgumentGroup): argparse argument group.
        """
        version_string = self.GetVersionInformation()

        # We want a custom help message and not the default argparse one.
        argument_group.add_argument(
            "-h", "--help", action="help", help="Show this help message and exit."
        )
        argument_group.add_argument(
            "--troubles",
            dest="show_troubleshooting",
            action="store_true",
            default=False,
            help="Show troubleshooting information.",
        )
        argument_group.add_argument(
            "-V",
            "--version",
            dest="version",
            action="version",
            version=version_string,
            help="Show the version information.",
        )

    def AddInformationalOptions(self, argument_group):
        """Adds the informational options to the argument group.

        Args:
          argument_group (argparse._ArgumentGroup): argparse argument group.
        """
        argument_group.add_argument(
            "-d",
            "--debug",
            dest="debug",
            action="store_true",
            default=False,
            help="Enable debug output.",
        )
        argument_group.add_argument(
            "-q",
            "--quiet",
            dest="quiet",
            action="store_true",
            default=False,
            help="Disable informational output.",
        )
        argument_group.add_argument(
            "-u",
            "--unattended",
            dest="unattended",
            action="store_true",
            default=False,
            help=(
                "Enable unattended mode and do not ask the user for additional "
                "input when needed, but terminate with an error instead."
            ),
        )

    def AddLogFileOptions(self, argument_group):
        """Adds the log file option to the argument group.

        Args:
          argument_group (argparse._ArgumentGroup): argparse argument group.
        """
        argument_group.add_argument(
            "--logfile",
            "--log_file",
            "--log-file",
            action="store",
            metavar="FILENAME",
            dest="log_file",
            type=str,
            default="",
            help=(
                f"Path of the file in which to store log messages, by default this "
                f'file will be named: "{self.NAME:s}-YYYYMMDDThhmmss.log.gz". Note '
                f'that the file will be gzip compressed if the extension is ".gz".'
            ),
        )

    def CheckOutDated(self):
        """Checks if the version of plaso is outdated and warns the user."""
        version_date_time = datetime.datetime(
            int(plaso.__version__[0:4], 10),
            int(plaso.__version__[4:6], 10),
            int(plaso.__version__[6:8], 10),
        )
        date_time_delta = datetime.datetime.utcnow() - version_date_time

        if date_time_delta.days > 180:
            logger.warning(
                "This version of Plaso is more than 6 months old. It is strongly "
                "recommended to update it."
            )
            self._PrintUserWarning(
                "the version of Plaso you are using is more than 6 months old. It is "
                "strongly recommended to update it."
            )

    def GetCommandLineArguments(self):
        """Retrieves the command line arguments.

        Returns:
          str: command line arguments.
        """
        command_line_arguments = sys.argv
        if not command_line_arguments:
            return ""

        if isinstance(command_line_arguments[0], bytes):
            encoding = sys.stdin.encoding

            # Note that sys.stdin.encoding can be None.
            if not encoding:
                encoding = self.preferred_encoding

            try:
                command_line_arguments = [
                    argument.decode(encoding) for argument in command_line_arguments
                ]
            except UnicodeDecodeError:
                logger.error(
                    "Unable to properly read command line input due to encoding "
                    "error. Unsupported code points are escaped."
                )
                command_line_arguments = [
                    argument.decode(encoding, errors="backslashreplace")
                    for argument in command_line_arguments
                ]

        return " ".join(command_line_arguments)

    def GetVersionInformation(self):
        """Retrieves the version information.

        Returns:
          str: version information.
        """
        return f"plaso - {self.NAME:s} version {plaso.__version__:s}"

    def ListTimeZones(self):
        """Lists the time zones."""
        max_length = 0
        for time_zone_name in pytz.all_timezones:
            max_length = max(max_length, len(time_zone_name))

        utc_date_time = datetime.datetime.utcnow()

        table_view = views.ViewsFactory.GetTableView(
            self._views_format_type,
            column_names=["Time zone", "UTC Offset"],
            title="Zones",
        )
        for time_zone_name in pytz.all_timezones:
            try:
                local_time_zone = pytz.timezone(time_zone_name)
            except AssertionError as exception:
                logger.error(
                    (
                        f"Unable to determine information about time zone: "
                        f"{time_zone_name:s} with error: {exception!s}"
                    )
                )
                continue

            local_date_string = str(local_time_zone.localize(utc_date_time))
            if "+" in local_date_string:
                _, _, utc_offset = local_date_string.rpartition("+")
                utc_offset_string = f"+{utc_offset:s}"
            else:
                _, _, utc_offset = local_date_string.rpartition("-")
                utc_offset_string = f"-{utc_offset:s}"

            table_view.AddRow([time_zone_name, utc_offset_string])

        table_view.Write(self._output_writer)

    def ParseNumericOption(self, options, name, base=10, default_value=None):
        """Parses a numeric option.

        If the option is not set the default value is returned.

        Args:
          options (argparse.Namespace): command line arguments.
          name (str): name of the numeric option.
          base (Optional[int]): base of the numeric value.
          default_value (Optional[object]): default value.

        Returns:
          int: numeric value.

        Raises:
          BadConfigOption: if the options are invalid.
        """
        numeric_value = getattr(options, name, None)
        if not numeric_value:
            return default_value

        try:
            return int(numeric_value, base)

        except (TypeError, ValueError):
            name = name.replace("_", " ")
            raise errors.BadConfigOption(
                f"Unsupported numeric value {name:s}: {numeric_value!s}."
            )

    def ParseStringOption(self, options, argument_name, default_value=None):
        """Parses a string command line argument.

        Args:
          options (argparse.Namespace): command line arguments.
          argument_name (str): name of the command line argument.
          default_value (Optional[object]): default value of the command line
              argument.

        Returns:
          object: command line argument value. If the command line argument is
              not set the default value will be returned.

        Raises:
          BadConfigOption: if the command line argument value cannot be converted
              to a Unicode string.
        """
        argument_value = getattr(options, argument_name, None)
        if not argument_value:
            return default_value

        if isinstance(argument_value, bytes):
            encoding = sys.stdin.encoding

            # Note that sys.stdin.encoding can be None.
            if not encoding:
                encoding = self.preferred_encoding

            try:
                argument_value = codecs.decode(argument_value, encoding)
            except UnicodeDecodeError as exception:
                raise errors.BadConfigOption(
                    (
                        f"Unable to convert option: {argument_name:s} to Unicode with "
                        f"error: {exception!s}."
                    )
                )

        elif not isinstance(argument_value, str):
            raise errors.BadConfigOption(
                f"Unsupported option: {argument_name:s} string type required."
            )

        return argument_value

    def PrintSeparatorLine(self):
        """Prints a separator line."""
        self._output_writer.Write("-" * self._LINE_LENGTH)
        self._output_writer.Write("\n")


class CLIInputReader:
    """Command line interface input reader interface."""

    def __init__(self, encoding="utf-8"):
        """Initializes an input reader.

        Args:
          encoding (Optional[str]): input encoding.
        """
        super().__init__()
        self._encoding = encoding

    # pylint: disable=redundant-returns-doc
    @abc.abstractmethod
    def Read(self):
        """Reads a string from the input.

        Returns:
          str: input.
        """


class CLIOutputWriter:
    """Command line interface output writer interface."""

    def __init__(self, encoding="utf-8"):
        """Initializes an output writer.

        Args:
          encoding (Optional[str]): output encoding.
        """
        super().__init__()
        self._encoding = encoding

    @abc.abstractmethod
    def Write(self, string):
        """Writes a string to the output.

        Args:
          string (str): output.
        """


class FileObjectInputReader(CLIInputReader):
    """File object command line interface input reader.

    This input reader relies on the file-like object having a readline method.
    """

    def __init__(self, file_object, encoding="utf-8"):
        """Initializes a file object command line interface input reader.

        Args:
          file_object (file): file-like object to read from.
          encoding (Optional[str]): input encoding.
        """
        super().__init__(encoding=encoding)
        self._errors = "strict"
        self._file_object = file_object

    def Read(self):
        """Reads a string from the input.

        Returns:
          str: input.
        """
        encoded_string = self._file_object.readline()

        if isinstance(encoded_string, str):
            return encoded_string

        try:
            string = codecs.decode(encoded_string, self._encoding, self._errors)
        except UnicodeDecodeError:
            if self._errors == "strict":
                logger.error(
                    "Unable to properly read input due to encoding error. "
                    "Switching to error tolerant encoding which can result in "
                    "unsupported code points being escaped."
                )
                self._errors = "backslashreplace"

            string = codecs.decode(encoded_string, self._encoding, self._errors)

        return string


class StdinInputReader(FileObjectInputReader):
    """Stdin command line interface input reader."""

    def __init__(self, encoding="utf-8"):
        """Initializes an stdin input reader.

        Args:
          encoding (Optional[str]): input encoding.
        """
        super().__init__(sys.stdin, encoding=encoding)

    def Read(self):
        """Reads a string from the input.

        Returns:
          str: input.
        """
        # Flush stdout to guarantee that all output has been provided before waiting
        # for input.
        sys.stdout.flush()

        return super().Read()


class FileObjectOutputWriter(CLIOutputWriter):
    """File object command line interface output writer.

    This output writer relies on the file-like object having a write method.
    """

    def __init__(self, file_object, encoding="utf-8"):
        """Initializes a file object command line interface output writer.

        Args:
          file_object (file): file-like object to read from.
          encoding (Optional[str]): output encoding.
        """
        super().__init__(encoding=encoding)
        self._errors = "strict"
        self._file_object = file_object

    def Write(self, string):
        """Writes a string to the output.

        Args:
          string (str): output.
        """
        try:
            # Note that encode() will first convert string into a Unicode string
            # if necessary.
            encoded_string = codecs.encode(string, self._encoding, self._errors)
        except UnicodeEncodeError:
            if self._errors == "strict":
                logger.error(
                    "Unable to properly write output due to encoding error. "
                    "Switching to error tolerant encoding which can result in "
                    "unsupported code points being escaped."
                )
                self._errors = "backslashreplace"

            encoded_string = codecs.encode(string, self._encoding, self._errors)

        self._file_object.write(encoded_string)


class StdoutOutputWriter(FileObjectOutputWriter):
    """Stdout command line interface output writer."""

    def __init__(self, encoding="utf-8"):
        """Initializes a stdout output writer.

        Args:
          encoding (Optional[str]): output encoding.
        """
        super().__init__(sys.stdout, encoding=encoding)

    def Write(self, string):
        """Writes a string to the output.

        Args:
          string (str): output.
        """
        sys.stdout.write(string)
