# -*- coding: utf-8 -*-

"""
Personal Python Toolkit
Modularized all-in-one toolkit for Python
----------------------------------------------------------------------------
(C) Tobias "NotTheEvilOne" Wolf - All rights reserved
https://github.com/NotTheEvilOne/ppt_file

This Source Code Form is subject to the terms of the Mozilla Public License,
v. 2.0. If a copy of the MPL was not distributed with this file, You can
obtain one at http://mozilla.org/MPL/2.0/.
"""

# pylint: disable=import-error,invalid-name,no-member,undefined-variable

from os import path
from weakref import proxy, ProxyTypes
import os
import stat
import time

try:
    import fcntl

    _USE_FILE_LOCKING = False
except ImportError:
    _USE_FILE_LOCKING = True

_PathLike = os.PathLike if (hasattr(os, "PathLike")) else object


class File(_PathLike):
    """
    Get file objects to work with files easily.

    :author:     Tobias "NotTheEvilOne" Wolf et al.
    :copyright:  Tobias "NotTheEvilOne" Wolf - All rights reserved
    :package:    ppt
    :subpackage: file
    :since:      v1.0.0
    :license:    http://mozilla.org/MPL/2.0/
                 Mozilla Public License, v. 2.0
    """

    # pylint: disable=bad-option-value,slots-on-old-class
    __slots__ = (
        "binary",
        "chmod",
        "file_path_name",
        "file_size",
        "_handle",
        "_handle_lock",
        "_log_handler",
        "readonly",
        "timeout_retries",
        "umask",
    )
    """
python.org: __slots__ reserves space for the declared variables and prevents
the automatic creation of __dict__ and __weakref__ for each instance.
    """

    def __init__(
        self,
        default_umask=None,
        default_chmod=None,
        timeout_retries=5,
        log_handler=None,
    ):
        """
        Constructor __init__(File)

        :param default_umask: umask to set before creating a new file
        :param default_chmod: chmod to set when creating a new file
        :param timeout_retries: Retries before timing out
        :param log_handler: Log handler to use

        :since: v1.0.0
        """

        self.binary = False
        """
Binary file flag
        """
        self.chmod = None
        """
chmod to set when creating a new file
        """
        self.file_path_name = ""
        """
File path and name for the file handle
        """
        self.file_size = -1
        """
File size of the file handle
        """
        self._handle = None
        """
Handle to the opened file
        """
        self._handle_lock = "r"
        """
Current locking mode
        """
        self._log_handler = None
        """
The log handler is called whenever debug messages should be logged or errors
happened.
        """
        self.readonly = False
        """
True if file is opened read-only
        """
        self.timeout_retries = 5 if (timeout_retries is None) else timeout_retries
        """
Retries before timing out
        """
        self.umask = default_umask
        """
umask to set before creating a new file
        """

        if isinstance(default_chmod, int):
            self.chmod = default_chmod
        else:
            default_chmod = int(default_chmod, 8)
            self.chmod = 0

            if (1000 & default_chmod) == 1000:
                self.chmod |= stat.S_ISVTX
            if (2000 & default_chmod) == 2000:
                self.chmod |= stat.S_ISGID
            if (4000 & default_chmod) == 4000:
                self.chmod |= stat.S_ISUID
            if (0o100 & default_chmod) == 0o100:
                self.chmod |= stat.S_IXUSR
            if (0o200 & default_chmod) == 0o200:
                self.chmod |= stat.S_IWUSR
            if (0o400 & default_chmod) == 0o400:
                self.chmod |= stat.S_IRUSR
            if (0o010 & default_chmod) == 0o010:
                self.chmod |= stat.S_IXGRP
            if (0o020 & default_chmod) == 0o020:
                self.chmod |= stat.S_IWGRP
            if (0o040 & default_chmod) == 0o040:
                self.chmod |= stat.S_IRGRP
            if (0o001 & default_chmod) == 0o001:
                self.chmod |= stat.S_IXOTH
            if (0o002 & default_chmod) == 0o002:
                self.chmod |= stat.S_IWOTH
            if (0o004 & default_chmod) == 0o004:
                self.chmod |= stat.S_IROTH

        if log_handler is not None:
            self.log_handler = log_handler

    def __del__(self):
        """
        Destructor __del__(File)

        :since: v1.0.0
        """

        self.close()

    def __enter__(self):
        """
        python.org: Enter the runtime context related to this object.

        :since: v1.0.0
        """

        if self._handle is None:
            raise IOError("Failed to enter context for an uninitialized file instance")

        return self

    def __exit__(self, exc_type, exc_value, traceback):
        """
        python.org: Exit the runtime context related to this object.

        :return: (bool) True to suppress exceptions
        :since:  v1.0.0
        """

        self.close()

    def __fspath__(self):
        """
        python.org: Return the file system path representation of the object.

        :return: (str) File system path representation
        :since:  v1.0.0
        """

        if self._handle is None:
            raise IOError("File handle invalid")

        return self.file_path_name

    @property
    def handle(self):
        """
        Returns the file handle.

        :return: (mixed) File handle; None if not opened
        :since:  v1.0.0
        """

        return None if (self._handle is None) else self._handle

    @property
    def is_eof(self):
        """
        Returns true if the handle is at EOF.

        :return: (bool) True if EOF
        :since:  v1.0.0
        """

        return (
            True
            if (self._handle is None or self._handle.tell() == self.file_size)
            else False
        )

    @property
    def is_valid(self):
        """
        Returns true if the file handle is available.

        :return: (bool) True on success
        :since:  v1.0.0
        """

        return self._handle is not None

    @property
    def log_handler(self):
        """
        Returns the log handler.

        :return: (object) Log handler in use
        :since:  v1.0.0
        """

        return self._log_handler

    @log_handler.setter
    def log_handler(self, log_handler):
        """
        Sets the log handler.

        :param log_handler: Log handler to use

        :since: v1.0.0
        """

        self._log_handler = (
            log_handler if (isinstance(log_handler, ProxyTypes)) else proxy(log_handler)
        )

    @property
    def size(self):
        """
        Returns the size in bytes.

        :return: (int) Size in bytes
        :since:  v1.0.0
        """

        return -1 if (self._handle is None) else self.file_size

    def close(self, delete_empty=False):
        """
        python.org: Flush and close this stream.

        :param delete_empty: If the file handle is valid, the file is empty and
                             this parameter is true then the file will be deleted.

        :return: (bool) True on success
        :since:  v1.0.0
        """

        # global: _USE_FILE_LOCKING

        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.close()")

        _return = False

        if self._handle is not None:
            file_position = self.tell()

            if not self.readonly and delete_empty and file_position < 1:
                self.read(1)
                file_position = self.tell()

            self._handle.close()
            _return = True

            if self._handle_lock == "w" and _USE_FILE_LOCKING:
                lock_path_name_os = path.normpath(
                    "{0}.lock".format(self.file_path_name)
                )

                if path.exists(lock_path_name_os):
                    try:
                        os.unlink(lock_path_name_os)
                    except IOError:
                        pass

            if not self.readonly and delete_empty and file_position < 1:
                file_path_name_os = path.normpath(self.file_path_name)
                _return = True

                try:
                    os.unlink(file_path_name_os)
                except IOError:
                    _return = False

            self._handle = None

            self.file_path_name = ""
            self.file_size = -1
            self.readonly = False

        return _return

    def flush(self):
        """
        python.org: Flush the write buffers of the stream if applicable.

        :return: (bool) True on success
        :since:  v1.0.0
        """

        _return = False

        if self._handle is not None:
            _return = True

            if not self.readonly:
                self._handle.flush()
                os.fsync(self._handle.fileno())

        return _return

    def lock(self, lock_mode):
        """
        Changes file locking if needed.

        :param lock_mode: The requested file locking mode ("r" or "w").

        :return: (bool) True on success
        :since:  v1.0.0
        """

        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.lock({0})", lock_mode)

        _return = False

        if self._handle is None:
            if self._log_handler is not None:
                self._log_handler.warning(
                    "ppt_file.File.lock()- reporting: File handle invalid"
                )
        elif lock_mode == "w" and self.readonly:
            if self._log_handler is not None:
                self._log_handler.error(
                    "ppt_file.File.lock()- reporting: File handle is in readonly mode"
                )
        elif lock_mode == self._handle_lock:
            _return = True
        else:
            timeout_retries = self.timeout_retries

            while timeout_retries > 0:
                if self._locking(lock_mode):
                    self._handle_lock = "w" if (lock_mode == "w") else "r"
                    _return = True
                    timeout_retries = -1

                    break
                else:
                    timeout_retries -= 1
                    time.sleep(1)

            if timeout_retries > -1 and self._log_handler is not None:
                self._log_handler.error(
                    "ppt_file.File.lock()- reporting: File lock change failed"
                )

        return _return

    def _locking(self, lock_mode, file_path_name=""):
        """
        Runs flock or an alternative locking mechanism.

        :param lock_mode: The requested file locking mode ("r" or "w").
        :param file_path_name: Alternative path to the locking file (used for
                              _USE_FILE_LOCKING)

        :return: (bool) True on success
        :since:  v1.0.0
        """

        # global: _USE_FILE_LOCKING
        # pylint: disable=broad-except

        _return = False

        if len(file_path_name) < 1:
            file_path_name = self.file_path_name
        lock_path_name_os = path.normpath("{0}.lock".format(file_path_name))

        if len(file_path_name) > 0 and self._handle is not None:
            if lock_mode == "w" and self.readonly:
                _return = False
            elif _USE_FILE_LOCKING:
                is_locked = path.exists(lock_path_name_os)

                if is_locked:
                    is_locked = False

                    if time.time() - self.timeout_retries < path.getmtime(
                        lock_path_name_os
                    ):
                        try:
                            os.unlink(lock_path_name_os)
                        except IOError:
                            pass
                    else:
                        is_locked = True

                if lock_mode == "w":
                    if is_locked and self._handle_lock == "w":
                        _return = True
                    elif not is_locked:
                        try:
                            open(lock_path_name_os, "w").close()
                            _return = True
                        except IOError:
                            pass
                elif is_locked and self._handle_lock == "w":
                    try:
                        os.unlink(lock_path_name_os)
                        _return = True
                    except IOError:
                        pass
                elif not is_locked:
                    _return = True
            else:
                operation = fcntl.LOCK_EX if (lock_mode == "w") else fcntl.LOCK_SH

                try:
                    fcntl.flock(self._handle, operation)
                    _return = True
                except Exception:
                    pass

        return _return

    def open(self, file_path_name, readonly=False, file_mode="r+b"):
        """
        Opens a file session.

        :param file_path_name: Path to the requested file
        :param readonly: Open file in readonly mode
        :param file_mode: File mode to use

        :return: (bool) True on success
        :since:  v1.0.0
        """

        if self._log_handler is not None:
            self._log_handler.debug(
                "ppt_file.File.open({0}, {1})", file_path_name, file_mode
            )

        if self._handle is None:
            exists = False
            file_path_name_os = path.normpath(file_path_name)
            _return = True

            self.readonly = True if (readonly) else False

            if path.exists(file_path_name_os):
                exists = True
            elif not self.readonly:
                if self.umask is not None:
                    os.umask(int(self.umask, 8))
            else:
                _return = False

            is_binary = True if ("b" in file_mode) else False

            if _return:
                try:
                    self._handle = self._open(file_path_name, file_mode, is_binary)
                except IOError:
                    _return = False
            elif self._log_handler is not None:
                self._log_handler.warning(
                    "ppt_file.File.open()- reporting: Failed opening {0} - file does not exist",
                    file_path_name,
                )

            if self._handle is None:
                if not exists and not self.readonly:
                    try:
                        os.unlink(file_path_name_os)
                    except IOError:
                        pass
            else:
                self.binary = is_binary

                if self.chmod is not None and not exists:
                    os.chmod(file_path_name_os, self.chmod)
                self.file_path_name = file_path_name

                if self.lock("r"):
                    self.file_size = os.stat(file_path_name_os).st_size
                else:
                    _return = False
                    self.close(not exists)
                    self._handle = None
        else:
            _return = False

        return _return

    def _open(self, file_path_name_os, file_mode, is_binary):
        """
        Opens a file handle and sets the encoding to UTF-8.

        :param file_path_name_os: Path to the requested file
        :param file_mode: File mode to use
        :param is_binary: False if the file is an UTF-8 (or ASCII) encoded one

        :return: (object) File
        :since:  v1.0.0
        """

        _return = None

        if not is_binary:
            try:
                _return = open(file_path_name_os, file_mode, encoding="utf-8")
            except TypeError:
                pass

        if _return is None:
            _return = open(file_path_name_os, file_mode)

        return _return

    def read(self, n=0, timeout=-1):
        """
        python.org: Read up to n bytes from the object and return them.

        :param n: How many bytes to read from the current position (0 means until
                  EOF)
        :param timeout: Timeout to use (defaults to construction time value)

        :return: (bytes) Data; None if EOF
        :since:  v1.0.0
        """

        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.read({0:d}, {1:d})", n, timeout)

        _return = None

        if self.lock("r"):
            bytes_unread = n
            _return = bytes() if (self.binary) else ""
            timeout_time = None if (timeout < 0) else (time.time() + timeout)

            while (
                (bytes_unread > 0 or n == 0)
                and not self.is_eof
                and (timeout_time is None or time.time() < timeout_time)
            ):
                part_size = 16384 if (bytes_unread > 16384 or n == 0) else bytes_unread
                _return += self._handle.read(part_size)

                if n > 0:
                    bytes_unread -= part_size

            if (
                bytes_unread > 0 or (n == 0 and self.is_eof)
            ) and self._log_handler is not None:
                self._log_handler.error(
                    "ppt_file.File.read()- reporting: Timeout occured before EOF"
                )

        return _return

    def seek(self, offset):
        """
        python.org: Change the stream position to the given byte offset.

        :param offset: Seek to the given offset

        :return: (int) Return the new absolute position.
        :since:  v1.0.0
        """

        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.seek({0:d})", offset)

        return -1 if (self._handle is None) else self._handle.seek(offset)

    def tell(self):
        """
        python.org: Return the current stream position as an opaque number.

        :return: (int) Stream position
        :since:  v1.0.0
        """

        return -1 if (self._handle is None) else self._handle.tell()

    def truncate(self, new_size=None):
        """
        python.org: Resize the stream to the given size in bytes.

        :param new_size: Cut file at the given byte position

        :return: (int) New file size
        :since:  v1.0.0
        """

        if new_size is None:
            new_size = max(0, self.tell())
        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.truncate({0:d})", new_size)

        if self.lock("w"):
            _return = self._handle.truncate(new_size)
            self.file_size = new_size
        else:
            raise IOError("Failed to truncate the file")

        return _return

    def write(self, b, timeout=-1):
        """
        python.org: Write the given bytes or bytearray object, b, to the underlying
        raw stream and return the number of bytes written.

        :param b: (Over)write file with the given data at the current position
        :param timeout: Timeout to use (defaults to construction time value)

        :return: (int) Number of bytes written
        :since:  v1.0.0
        """

        if self._log_handler is not None:
            self._log_handler.debug("ppt_file.File.write({0:d})", timeout)

        _return = 0

        if self.lock("w"):
            if self.binary and not isinstance(b, bytes):
                b = str.encode(b, "raw_unicode_escape")

            bytes_unwritten = len(b)
            bytes_written = self._handle.tell()

            if (bytes_written + bytes_unwritten) <= self.file_size:
                new_size = 0
            else:
                new_size = bytes_written + bytes_unwritten

            timeout_time = time.time()
            timeout_time += self.timeout_retries if (timeout < 0) else timeout

            while bytes_unwritten > 0 and time.time() < timeout_time:
                part_size = 16384 if (bytes_unwritten > 16384) else bytes_unwritten

                self._handle.write(b[_return : (_return + part_size)])
                bytes_unwritten -= part_size
                _return += part_size

            if bytes_unwritten > 0:
                self.file_size = os.stat(path.normpath(self.file_path_name)).st_size
                if self._log_handler is not None:
                    self._log_handler.error(
                        "ppt_file.File.write()- reporting: Timeout occured before EOF"
                    )
            elif new_size > 0:
                self.file_size = new_size

        return _return
