#!/usr/bin/env python
"""
The tools file manipulation module contains functions for manipulating and
moving files around.
"""

import datetime
import errno

# Import modules
import glob
import os
import shutil
import sys
import tempfile
import time
from typing import List, Union

import rsgislib


def get_file_basename(
    input_file: str, check_valid: bool = False, n_comps: int = 0, rm_n_exts: int = 0
) -> str:
    """
    Uses os.path module to return file basename (i.e., path and extension removed)

    :param input_file: string for the input file name and path
    :param check_valid: if True then resulting basename will be checked for punctuation
                       characters (other than underscores) and spaces, punctuation
                       will be either removed and spaces changed to an underscore.
                       (Default = False)
    :param n_comps: if > 0 then the resulting basename will be split using underscores
                    and the return based name will be defined using the n_comps
                    components split by under scores.
    :param rm_n_exts: used where an input file has more than one extension
                      (e.g., tar.gz) and only n extensions should be removed.
                      Default: 0 which will removed all extensions calculated
                      based on the number of full-stops (.) within the file name.
                      If a value of 1 was provided for filename.tar.gz then the
                      returns output would be filename.tar.
    :return: basename for file

    """
    import string

    file_base_name = os.path.basename(input_file)
    n_exts = file_base_name.count(".")
    if (rm_n_exts == 0) or (rm_n_exts > n_exts):
        rm_n_exts = n_exts

    basename = file_base_name
    if n_exts > 0:
        for i in range(rm_n_exts):
            basename = os.path.splitext(basename)[0]

    if check_valid:
        basename = basename.replace(" ", "_")
        for punct in string.punctuation:
            if (punct != "_") and (punct != "-"):
                basename = basename.replace(punct, "")
    if n_comps > 0:
        basename_split = basename.split("_")
        if len(basename_split) < n_comps:
            raise rsgislib.RSGISPyException(
                "The number of components specified is more than the number "
                "of components in the basename."
            )
        out_basename = ""
        for i in range(n_comps):
            if i == 0:
                out_basename = basename_split[i]
            else:
                out_basename = out_basename + "_" + basename_split[i]
        basename = out_basename
    return basename


def is_path_valid(file_path_name: str) -> bool:
    """
    This function tests whether a file path is valid in terms of the length of
    each component and the characters used. Should be cross platform.

    This function was adapted from the answer here:
    https://stackoverflow.com/questions/9532499/check-whether-a-path-is-valid-in-python-without-creating-a-file-at-the-paths-ta

    :param file_path_name: the file path to be tested
    :return: True if the passed pathname is a valid pathname for the current OS;

    """
    # Windows-specific error code indicating an invalid pathname.
    ERROR_INVALID_NAME = 123
    # See Also
    # ----------
    # https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
    #    Official listing of all such codes.

    # If this pathname is either not a string or is but is empty, this pathname
    # is invalid.
    try:
        if not isinstance(file_path_name, str) or not file_path_name:
            return False

        # Strip this pathname's Windows-specific drive specifier (e.g., `C:\`)
        # if any. Since Windows prohibits path components from containing `:`
        # characters, failing to strip this `:`-suffixed prefix would
        # erroneously invalidate all valid absolute Windows pathnames.
        _, file_path_name = os.path.splitdrive(file_path_name)

        # Directory guaranteed to exist. If the current OS is Windows, this is
        # the drive to which Windows was installed (e.g., the "%HOMEDRIVE%"
        # environment variable); else, the typical root directory.
        root_dirname = (
            os.environ.get("HOMEDRIVE", "C:")
            if sys.platform == "win32"
            else os.path.sep
        )
        assert os.path.isdir(root_dirname)  # ...Murphy and her ironclad Law

        # Append a path separator to this directory if needed.
        root_dirname = root_dirname.rstrip(os.path.sep) + os.path.sep

        # Test whether each path component split from this pathname is valid or
        # not, ignoring non-existent and non-readable path components.
        for pathname_part in file_path_name.split(os.path.sep):
            try:
                os.lstat(root_dirname + pathname_part)
            # If an OS-specific exception is raised, its error code
            # indicates whether this pathname is valid or not. Unless this
            # is the case, this exception implies an ignorable kernel or
            # filesystem complaint (e.g., path not found or inaccessible).
            #
            # Only the following exceptions indicate invalid pathnames:
            #
            # * Instances of the Windows-specific "WindowsError" class
            #   defining the "winerror" attribute whose value is
            #   "ERROR_INVALID_NAME". Under Windows, "winerror" is more
            #   fine-grained and hence useful than the generic "errno"
            #   attribute. When a too-long pathname is passed, for example,
            #   "errno" is "ENOENT" (i.e., no such file or directory) rather
            #   than "ENAMETOOLONG" (i.e., file name too long).
            # * Instances of the cross-platform "OSError" class defining the
            #   generic "errno" attribute whose value is either:
            #   * Under most POSIX-compatible OSes, "ENAMETOOLONG".
            #   * Under some edge-case OSes (e.g., SunOS, *BSD), "ERANGE".
            except ValueError as e:
                return False
            except OSError as exc:
                if hasattr(exc, "winerror"):
                    if exc.winerror == ERROR_INVALID_NAME:
                        return False
                elif exc.errno in {errno.ENAMETOOLONG, errno.ERANGE}:
                    return False
    # If a "TypeError" exception was raised, it almost certainly has the
    # error message "embedded NUL character" indicating an invalid pathname.
    except TypeError as exc:
        return False
    # If no exception was raised, all path components and hence this
    # pathname itself are valid.
    else:
        return True


def is_path_sibling_creatable(file_path_name: str) -> bool:
    """
    A function which checks whether the file path provided would
    be creatable (i.e., directory exists and you have permission to
    write to it.

    This function was adapted from the answer here:
    https://stackoverflow.com/questions/9532499/check-whether-a-path-is-valid-in-python-without-creating-a-file-at-the-paths-ta

    :param file_path_name: the input file path to be checked.
    :return: True if the current user has sufficient permissions to create **siblings**
             (i.e., arbitrary files in the parent directory) of the passed pathname

    """
    # Parent directory of the passed path. If empty, we substitute the current
    # working directory (CWD) instead.
    dirname = os.path.dirname(file_path_name) or os.getcwd()

    try:
        # For safety, explicitly close and hence delete this temporary file
        # immediately after creating it in the passed path's parent directory.
        with tempfile.TemporaryFile(dir=dirname):
            pass
        return True
    # While the exact type of exception raised by the above function depends on
    # the current version of the Python interpreter, all such types subclass the
    # following exception superclass.
    except EnvironmentError:
        return False


def does_path_exists_or_creatable(file_path_name: str) -> bool:
    """
    A function which checks whether a file path either exists or is
    creatable if it does not exist.

    This function was adapted from the answer here:
    https://stackoverflow.com/questions/9532499/check-whether-a-path-is-valid-in-python-without-creating-a-file-at-the-paths-ta

    :param file_path_name: the input file path to be checked.
    :return: True if the passed pathname is a valid pathname on the current OS _and_
             either currently exists or is hypothetically creatable in a cross-platform
             manner optimized for POSIX-unfriendly filesystems

    """
    try:
        # To prevent "os" module calls from raising undesirable exceptions on
        # invalid path names, is_path_valid() is explicitly called first.
        return is_path_valid(file_path_name) and (
            os.path.exists(file_path_name) or is_path_sibling_creatable(file_path_name)
        )
    # Report failure on non-fatal filesystem complaints (e.g., connection
    # timeouts, permissions issues) implying this path to be inaccessible. All
    # other exceptions are unrelated fatal issues and should not be caught here.
    except OSError:
        return False


def get_dir_name(input_file: str) -> str:
    """
    A function which returns just the name of the directory of the input path
    (file or directory) without the rest of the path.

    :param input_file: string for the input path (file or directory) name and path
    :return: directory name

    """
    input_file = os.path.abspath(input_file)
    if os.path.isfile(input_file):
        dir_path = os.path.dirname(input_file)
    elif os.path.isdir(input_file):
        dir_path = input_file
    else:
        raise rsgislib.RSGISPyException("Input path must be either a file or directory")
    dir_name = os.path.basename(dir_path)
    return dir_name


def split_path_all(input_path: str) -> List[str]:
    """
    A function which splits all the components within a file path
    into a list of components rather than the os.path.split function
    which just splits the last item.

    :param input_path: the input file path.
    :return: a list of the file path components.

    """
    all_parts = []
    while 1:
        parts = os.path.split(input_path)
        if parts[0] == input_path:  # sentinel for absolute paths
            all_parts.insert(0, parts[0])
            break
        elif parts[1] == input_path:  # sentinel for relative paths
            all_parts.insert(0, parts[1])
            break
        else:
            input_path = parts[0]
            all_parts.insert(0, parts[1])
    return all_parts


def delete_file_silent(input_file: str) -> bool:
    """
    A function which can be used in-place of os.remove to delete
    a file but if checks if the file exists and only calls os.remove
    if it does exist but also catches any Exceptions from os.remove
    and just returns a boolean as to whether the input_file has been
    removed.

    :param input_file: input file path for the file which is to be removed.
    :return: boolean (True: File was removed or did not exist. False:
             os.remove through an Exception so assume file was not removed)

    """
    try:
        if os.path.exists(input_file) and os.path.isfile(input_file):
            os.remove(input_file)
        file_removed = True
    except Exception:
        file_removed = False
    return file_removed


def delete_file_with_basename(input_file: str, print_rms=True):
    """
    Function to delete all the files which have a path
    and base name defined in the input_file attribute.

    :param input_file: string for the input file name and path
    :param print_rms: print the files being deleted (Default: True)

    """
    baseName = os.path.splitext(input_file)[0]
    fileList = glob.glob(baseName + str(".*"))
    for file in fileList:
        if print_rms:
            print("Deleting file: " + str(file))
        delete_file_silent(file)


def rm_files_size_gt(
    file_path: str,
    file_srch: str,
    min_size: int,
    rm_file: bool = False,
    rm_use_basename: bool = False,
):
    """
    A function which removes all the files from the search path which are
    greater than the specified size.

    Note, the file_path and file_srch will be merged with os.path.join.
    e.g., file_path="/hello/world", file_srch="*.txt" would result in
    "/hello/world/*.txt". Wild characters can get put in both parts if
    needed.

    :param file_path: The directory within which the files will be search for.
    :param file_srch: The search string (must have a wild card '*' for glob).
    :param min_size: the minimum valid size, above this size the files will
                     be deleted. In bytes.
    :param rm_file: If True then files will be deleted if False then a list of
                    'rm file' commands will be produced rather than the files
                    actually being deleted. (default: False)
    :param rm_use_basename: If True then all files with the same base name (i.e.,
                            same name but different file extension) within the same
                            directory will also be deleted. Useful if you have file
                            formats which have multiple files. (default: False)

    """
    file_lst = find_files_size_limits(file_path, file_srch, min_size=min_size)
    if len(file_lst) > 0:
        for in_file in file_lst:
            if rm_file:
                if rm_use_basename:
                    delete_file_with_basename(in_file)
                else:
                    delete_file_silent(in_file)
            else:
                print("rm {}".format(in_file))


def find_file(dir_path: str, file_search: str) -> str:
    """
    Search for a single file with a path using glob. Therefore, the file
    path returned is a true path. Within the file_search provide the file
    name with '*' as wildcard(s).

    :param dir_path: string for the input directory path
    :param file_search: string with a * wildcard for the file being searched for.
    :return: string with the path to the file

    .. code:: python

        import rsgislib.tools.filetools
        file_path = rsgislib.tools.filetools.find_file("in/dir", "*N15W093*.tif")

    """
    files = glob.glob(os.path.join(dir_path, file_search))
    if len(files) != 1:
        raise rsgislib.RSGISPyException(
            "Could not find a single file ("
            + file_search
            + "); found "
            + str(len(files))
            + " files."
        )
    return files[0]


def find_file_none(dir_path: str, file_search: str) -> Union[None, str]:
    """
    Search for a single file with a path using glob. Therefore, the file
    path returned is a true path. Within the file_search provide the file
    name with '*' as wildcard(s). Returns None is not found.

    :param dir_path: string for the input directory path
    :param file_search: string with a * wildcard for the file being searched for.
    :return: string with the path to the file

    .. code:: python

        import rsgislib.tools.filetools
        file_path = rsgislib.tools.filetools.find_file_none("in/dir", "*N15W093*.tif")
        if file_path is not None:
            print(file_path)

    """
    files = glob.glob(os.path.join(dir_path, file_search))
    if len(files) != 1:
        return None
    return files[0]


def find_files_ext(dir_path: str, ending: str) -> dict:
    """
    Find all the files within a directory structure with a specific file ending.
    The files are return as dictionary using the file name as the dictionary key.
    This means you cannot have files with the same name within the structure.

    :param dir_path: the base directory path within which to search.
    :param ending: the file ending (e.g., .txt, or txt or .kea, kea).
    :return: dict with file name as key

    .. code:: python

        import rsgislib.tools.filetools
        file_paths = rsgislib.tools.filetools.find_files_ext("in/dir", ".tif")

    """
    out_file_dict = dict()
    for root, dirs, files in os.walk(dir_path):
        for file in files:
            if file.endswith(ending):
                file_found = os.path.join(root, file)
                if os.path.isfile(file_found):
                    out_file_dict[file] = file_found
    return out_file_dict


def find_files_mpaths_ext(dir_paths: list, ending: str) -> dict:
    """
    Find all the files within a list of input directories and the structure beneath
    with a specific file ending. The files are return as dictionary using the file
    name as the dictionary key. This means you cannot have files with the same name
    within the structure.

    :param dir_paths: a list of base directory paths within which to search.
    :param ending: the file ending (e.g., .txt, or txt or .kea, kea).
    :return: dict with file name as key

    .. code:: python

        import rsgislib.tools.filetools
        dir_paths = ["in/dir", "test/dir", "img/files"]
        file_paths = rsgislib.tools.filetools.find_files_mpaths_ext(dir_paths, ".tif")


    """
    out_file_dict = dict()
    for dir_path in dir_paths:
        for root, dirs, files in os.walk(dir_path):
            for file in files:
                if file.endswith(ending):
                    file_found = os.path.join(root, file)
                    if os.path.isfile(file_found):
                        out_file_dict[file] = file_found
    return out_file_dict


def find_first_file(dir_path: str, file_search: str, rtn_except: bool = True) -> str:
    """
    Search for a single file with a path using glob. Therefore, the file
    path returned is a true path. Within the file_search provide the file
    name with '*' as wildcard(s).

    :param dir_path: The directory within which to search, note that the search will
                     be within sub-directories within the base directory until a file
                     meeting the search criteria are met.
    :param file_search: The file search string in the file name and must contain a
                        wild character (i.e., *).
    :param rtn_except: if True then an exception will be raised if no file or multiple
                       files are found (default). If False then None will be returned
                       rather than an exception raised.
    :return: The file found (or None if rtn_except=False)

    .. code:: python

        import rsgislib.tools.filetools
        file_paths = rsgislib.tools.filetools.find_first_file("in/dir", "*N15W093*.tif")

    """
    files = None
    for root, dirs, files in os.walk(dir_path):
        files = glob.glob(os.path.join(root, file_search))
        if len(files) > 0:
            break
    out_file = None
    if (files is not None) and (len(files) == 1):
        out_file = files[0]
    elif rtn_except:
        raise rsgislib.RSGISPyException(
            "Could not find a single file ({0}) in {1}; "
            "found {2} files.".format(file_search, dir_path, len(files))
        )
    return out_file


def get_files_mod_time(
    file_lst: list,
    dt_before: datetime.datetime = None,
    dt_after: datetime.datetime = None,
) -> list:
    """
    A function which subsets a list of files based on datetime of
    last modification. The function also does a check as to whether
    a file exists, files which don't exist will be ignored.

    :param file_lst: The list of file path - represented as strings.
    :param dt_before: a datetime object with a date/time where files modified
                      before this will be returned
    :param dt_after: a datetime object with a date/time where files modified
                     after this will be returned

    Example:

    .. code:: python

        import glob
        import datetime
        import rsgislib.tools.filetools

        input_files = glob.glob("in/dir/*.tif")
        dt_before = datetime.datetime(year=2020, month=12, day=25, hour=12, minute=30)
        file_path = rsgislib.tools.filetools.get_files_mod_time(input_files, dt_before)

    """
    if (dt_before is None) and (dt_after is None):
        raise rsgislib.RSGISPyException(
            "You must define at least one of dt_before or dt_after"
        )
    out_file_lst = list()
    for cfile in file_lst:
        if os.path.exists(cfile):
            mod_time_stamp = os.path.getmtime(cfile)
            mod_time = datetime.datetime.fromtimestamp(mod_time_stamp)
            if (dt_before is not None) and (mod_time < dt_before):
                out_file_lst.append(cfile)
            if (dt_after is not None) and (mod_time > dt_after):
                out_file_lst.append(cfile)
    return out_file_lst


def find_files_size_limits(
    dir_path: str, file_search: str, min_size: int = 0, max_size: int = None
) -> list:
    """
    Search for files with a path using glob. Therefore, the file
    paths returned is a true path. Within the file_search provide the file
    names with '*' as wildcard(s).

    :param dir_path: string for the input directory path
    :param file_search: string with a * wildcard for the file being searched for.
    :param min_size: the minimum file size in bytes (default is 0)
    :param max_size: the maximum file size in bytes, if None (default) then ignored.
    :return: string with the path to the file

    Example:

    .. code:: python

        import rsgislib.tools.filetools
        file_paths = rsgislib.tools.filetools.find_files_size_limits("in/dir",
                                                                     "*N15W093*.tif",
                                                                     0, 100000)

    """
    files = glob.glob(os.path.join(dir_path, file_search))
    out_files = list()
    for c_file in files:
        file_size = get_file_size(c_file)
        if (max_size is None) and (file_size > min_size):
            out_files.append(c_file)
        elif (file_size > min_size) and (file_size < max_size):
            out_files.append(c_file)
    return out_files


def is_cmd_tool_avail(cmd_name: str, test_call_cmd: List[str] = None):
    """
    A function which finds if an executable command is available
    on the system path. If you use the option to call the command
    then care is needed as this is running a command on the system
    and this function does no checking on what command and options
    has been past(!).

    :param cmd_name: the name of the command to test (e.g., tar, gzip etc.)
    :param test_call_cmd: Option to call the command to test if an error
                          is thrown. Default is None and then command is not
                          called but if a List (e.g., ["tar", "-h"]) is provided
                          then it will be executed to test whether the command
                          runs without throwing an error. Note, this is
    :return: boolean (True: Command available. False Command not available)
    """
    command_exists = shutil.which(cmd_name) is not None
    if (test_call_cmd is not None) and command_exists:
        import subprocess

        if not isinstance(test_call_cmd, list):
            raise rsgislib.RSGISPyException(
                "Input test command was not provided as a list."
            )

        try:
            rtn_info = subprocess.run(
                test_call_cmd,
                capture_output=True,
                text=True,
                check=True,
            )
            if rtn_info.returncode == 0:
                return True
            else:
                return False
        except subprocess.CalledProcessError:
            return False
        except Exception:
            return False
    return command_exists


def file_is_hidden(dir_path: str) -> bool:
    """
    A function to test whether a file or folder is 'hidden' or not on the
    file system. Should be cross platform between Linux/UNIX and windows.

    :param dir_path: input file path to be tested
    :return: boolean (True = hidden)

    Example:

    .. code:: python

        import rsgislib.tools.filetools
        if rsgislib.tools.filetools.file_is_hidden("in/dir/img.kea"):
            print("File is hidden")

    """
    dir_path = os.path.abspath(dir_path)
    if os.name == "nt":
        import win32api
        import win32con

        attribute = win32api.GetFileAttributes(dir_path)
        return attribute & (
            win32con.FILE_ATTRIBUTE_HIDDEN | win32con.FILE_ATTRIBUTE_SYSTEM
        )
    else:
        file_name = os.path.basename(dir_path)
        return file_name.startswith(".")


def get_dir_list(dir_path: str, inc_hidden: bool = False) -> list:
    """
    Function which get the list of directories within the specified path.

    :param dir_path: file path to search within
    :param inc_hidden: boolean specifying whether hidden files should be
                       included (default=False)
    :return: list of directory paths

    Example:

    .. code:: python

        import rsgislib.tools.filetools
        files = rsgislib.tools.filetools.get_dir_list("in/dir")

    """
    out_dir_lst = list()
    dir_listing = os.listdir(dir_path)
    for item in dir_listing:
        c_path = os.path.join(dir_path, item)
        if os.path.isdir(c_path):
            if not inc_hidden:
                if not file_is_hidden(c_path):
                    out_dir_lst.append(c_path)
            else:
                out_dir_lst.append(c_path)
    return out_dir_lst


def convert_file_size_units(in_size: int, in_unit: str, out_unit: str) -> float:
    """
    A function which converts between file size units

    :param in_size: input file size
    :param in_unit: the input unit for the file size. Options: bytes, kb, mb, gb, tb
    :param out_unit: the output unit for the file size. Options: bytes, kb, mb, gb, tb
    :return: float for the output file size

    """
    in_unit = in_unit.lower()
    if in_unit not in ["bytes", "kb", "mb", "gb", "tb"]:
        raise rsgislib.RSGISPyException(
            "Input unit must be one of: bytes, kb, mb, gb, tb"
        )

    out_unit = out_unit.lower()
    if out_unit not in ["bytes", "kb", "mb", "gb", "tb"]:
        raise rsgislib.RSGISPyException(
            "Output unit must be one of: bytes, kb, mb, gb, tb"
        )

    if in_unit == "bytes":
        file_size_bytes = in_size
    elif in_unit == "kb":
        file_size_bytes = in_size * 1024.0
    elif in_unit == "mb":
        file_size_bytes = in_size * (1024.0**2)
    elif in_unit == "gb":
        file_size_bytes = in_size * (1024.0**3)
    elif in_unit == "tb":
        file_size_bytes = in_size * (1024.0**4)
    else:
        raise rsgislib.RSGISPyException("Input unit it not recognised.")

    if out_unit == "bytes":
        out_file_size = file_size_bytes
    elif out_unit == "kb":
        out_file_size = file_size_bytes / 1024.0
    elif out_unit == "mb":
        out_file_size = file_size_bytes / (1024.0**2)
    elif out_unit == "gb":
        out_file_size = file_size_bytes / (1024.0**3)
    elif out_unit == "tb":
        out_file_size = file_size_bytes / (1024.0**4)
    else:
        raise rsgislib.RSGISPyException("Output unit it not recognised.")

    return out_file_size


def get_file_size(file_path: str, unit: str = "bytes") -> float:
    """
    A function which returns the file size of a file in the specified unit.

    Units:
    * bytes
    * kb - kilobytes  (bytes / 1024)
    * mb - megabytes  (bytes / 1024^2)
    * gb - gigabytes  (bytes / 1024^3)
    * tb - terabytes  (bytes / 1024^4)

    :param file_path: the path to the file for which the size is to be calculated.
    :param unit: the unit for the file size. Options: bytes, kb, mb, gb, tb
    :return: float for the file size.

    """
    import pathlib

    unit = unit.lower()
    if unit not in ["bytes", "kb", "mb", "gb", "tb"]:
        raise rsgislib.RSGISPyException("Unit must be one of: bytes, kb, mb, gb, tb")

    p = pathlib.Path(file_path)
    if p.exists() and p.is_file():
        file_size_bytes = p.stat().st_size
        out_file_size = convert_file_size_units(file_size_bytes, "bytes", unit)
    else:
        raise rsgislib.RSGISPyException("Input file path does not exist")
    return out_file_size


def get_file_lock(
    input_file: str,
    sleep_period: int = 1,
    wait_iters: int = 120,
    use_except: bool = False,
) -> bool:
    """
    A function which gets a lock on a file.

    The lock file will be a unix hidden file (i.e., starts with a .) and it will
    have .lok added to the end. E.g., for input file hello_world.txt the lock file
    will be .hello_world.txt.lok. The contents of the lock file will be the time and
    date of creation.

    Using the default parameters (sleep 1 second and wait 120 iterations) if the
    lock isn't available it will be retried every second for 120 seconds (i.e., 2 mins).

    :param input_file: The input file for which the lock will be created.
    :param sleep_period: time in seconds to sleep for, if the lock isn't
                         available. (Default=1 second)
    :param wait_iters: the number of iterations to wait for before giving
                       up. (Default=120)
    :param use_except: Boolean. If True then an exception will be thrown if the lock
                       is not available. If False (default) False will be returned
                       if the lock is not successful.
    :return: boolean. True: lock was successfully gained. False: lock was not gained.

    """
    file_path, file_name = os.path.split(input_file)
    lock_file_name = ".{}.lok".format(file_name)
    lock_file_path = os.path.join(file_path, lock_file_name)

    got_lock = False
    for i in range(wait_iters + 1):
        if not os.path.exists(lock_file_path):
            got_lock = True
            break
        time.sleep(sleep_period)

    if got_lock:
        c_datetime = datetime.datetime.now()
        f = open(lock_file_path, "w")
        f.write("{}\n".format(c_datetime.isoformat()))
        f.flush()
        f.close()
    elif use_except:
        raise rsgislib.RSGISPyException(
            "Lock could not be gained for file: {}".format(input_file)
        )

    return got_lock


def release_file_lock(input_file: str):
    """
    A function which releases a lock file for the input file.

    :param input_file: The input file for which the lock will be created.

    """
    file_path, file_name = os.path.split(input_file)
    lock_file_name = ".{}.lok".format(file_name)
    lock_file_path = os.path.join(file_path, lock_file_name)
    if os.path.exists(lock_file_path):
        if not delete_file_silent(lock_file_path):
            raise rsgislib.RSGISPyException(
                "Could not delete the lock file... something has gone wrong!"
            )


def clean_file_locks(dir_path: str, timeout: int = 3600):
    """
    A function which cleans up any remaining lock file (i.e., if an application
    has crashed). The timeout time will be compared with the time written within
    the file.

    :param dir_path: the file path to search for lock files (i.e., ".*.lok")
    :param timeout: the time (in seconds) for the timeout. Default: 3600 (1 hours)

    """
    import rsgislib.tools.utils

    c_dateime = datetime.datetime.now()
    lock_files = glob.glob(os.path.join(dir_path, ".*.lok"))
    for lock_file_path in lock_files:
        create_date_str = rsgislib.tools.utils.read_text_file_no_new_lines(
            lock_file_path
        )
        create_date = datetime.datetime.fromisoformat(create_date_str)
        time_since_create = (c_dateime - create_date).total_seconds()
        if time_since_create > timeout:
            if not delete_file_silent(lock_file_path):
                raise rsgislib.RSGISPyException(
                    "Could not delete the lock file... something has gone wrong!"
                )


def sort_imgs_to_dirs_utm(input_imgs_dir: str, file_search_str: str, out_base_dir: str):
    """
    A function which will sort a series of input image files which
    a projected using the UTM system into individual directories per
    UTM zone. Please note that the input files are moved on your system!!

    :param input_imgs_dir: directory where the input files are to be found.
    :param file_search_str: the wildcard search string to find files within
                            the input directory (e.g., "in_dir/*.kea").
    :param out_base_dir: the output directory where the UTM folders will be created
                         and the files copied.

    """
    import rsgislib.imageutils

    in_files = glob.glob(os.path.join(input_imgs_dir, file_search_str))
    for img_file in in_files:
        utm_zone = rsgislib.imageutils.getUTMZone(img_file)
        if utm_zone is not None:
            out_dir = os.path.join(out_base_dir, "utm" + utm_zone)
            if not os.path.exists(out_dir):
                os.makedirs(out_dir)
            img_file_list = rsgislib.imageutils.getImageFiles(img_file)
            for tmp_file in img_file_list:
                print("Moving: " + tmp_file)
                outFile = os.path.join(out_dir, os.path.basename(tmp_file))
                shutil.move(tmp_file, outFile)


def natural_sort_file_names(in_file_lst: List[str]) -> List[str]:
    """
    A function which performs a natural sort of a list of files. For example,
    if you start file names with dates (YYYYMMDD) then this function will return
    the list of file names in date order (earliest first).

    :param in_file_lst: the input list of file paths. The get_file_basename function
                        is used to extract the file name which is used for the sort.
    :return: the sorted list of names.

    """
    import re

    def _natural_sort_key(s):
        s = get_file_basename(s)
        _nsre = re.compile("([0-9]+)")
        return [
            int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)
        ]

    in_file_lst.sort(key=_natural_sort_key)
    return in_file_lst


def sort_file_by_datetime(in_file_lst: List[str]) -> List[str]:
    """
    A function which sorts a list of file based on the list the file was
    last modified. The list will be outputted in ascending order (i.e., oldest
    to earliest). The python function os.path.getmtime is used to access the
    modified file for the file.

    :param in_file_lst: the input list of file paths, which need to be accessible.
    :return: the sorted list of names.

    """
    in_file_lst.sort(key=os.path.getmtime)
    return in_file_lst


def create_directory_archive(in_dir: str, out_arch: str, arch_format: str) -> str:
    """
    A function which creates an archive from an input directory. This function uses
    subprocess to call the appropriate command line function.

    Please note that this function has similar functionality to shutil.make_archive
    and I would recommend you use that but I found it sometimes produces an error so
    I provided this function which uses the terminal functions as a drop in replacement.

    :param in_dir: The input directory path for which the archive with be created.
    :param out_arch: The output archive file path and name. Note this should not
                     include an extension as this will be added automatically.
    :param arch_format: The format for the archive. The options are: zip, tar,
                        gztar, bztar, xztar
    :return: a string with the full file path and name, including the file extension.

    """
    import subprocess

    if not is_path_valid(in_dir):
        raise rsgislib.RSGISPyException(f"The input directory is not valid: {in_dir}")

    c_pwd = os.getcwd()
    out_arch = os.path.abspath(out_arch)
    in_dir = os.path.abspath(in_dir)
    base_dir = os.path.dirname(in_dir)
    dir_name = os.path.split(in_dir)[1]
    os.chdir(base_dir)

    if arch_format == "zip":
        out_arch_file = "{}.zip".format(out_arch.strip())
        if not does_path_exists_or_creatable(out_arch_file):
            raise rsgislib.RSGISPyException(
                f"Output file path is not creatable: {out_arch_file}"
            )
        cmd = ["zip", "-r", out_arch_file, dir_name]
        subprocess.run(cmd, check=True)
    elif arch_format == "tar":
        out_arch_file = "{}.tar".format(out_arch.strip())
        if not does_path_exists_or_creatable(out_arch_file):
            raise rsgislib.RSGISPyException(
                f"Output file path is not creatable: {out_arch_file}"
            )
        cmd = ["tar", "-cvf", out_arch_file, dir_name]
        subprocess.run(cmd, check=True)
    elif arch_format == "gztar":
        out_arch_file = "{}.tar.gz".format(out_arch.strip())
        if not does_path_exists_or_creatable(out_arch_file):
            raise rsgislib.RSGISPyException(
                f"Output file path is not creatable: {out_arch_file}"
            )
        cmd = ["tar", "-cvzf", out_arch_file, dir_name]
        subprocess.run(cmd, check=True)
    elif arch_format == "bztar":
        out_arch_file = "{}.tar.bz2".format(out_arch.strip())
        if not does_path_exists_or_creatable(out_arch_file):
            raise rsgislib.RSGISPyException(
                f"Output file path is not creatable: {out_arch_file}"
            )
        cmd = ["tar", "-cvjSf", out_arch_file, dir_name]
        subprocess.run(cmd, check=True)
    elif arch_format == "xztar":
        out_arch_file = "{}.tar.xz".format(out_arch.strip())
        if not does_path_exists_or_creatable(out_arch_file):
            raise rsgislib.RSGISPyException(
                f"Output file path is not creatable: {out_arch_file}"
            )
        cmd = ["tar", "-cvJf", out_arch_file, dir_name]
        subprocess.run(cmd, check=True)
    else:
        raise rsgislib.RSGISPyException("Do not recognise the archive format specifed.")

    os.chdir(c_pwd)
    return out_arch_file


def create_sha1_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA1 hash string of the input file.

    :param input_file: the input file for which the SHA1 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA1 hash string of the file.

    """
    import hashlib

    sha1_hash = hashlib.sha1()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha1_hash.update(byte_block)
    return sha1_hash.hexdigest()


def create_sha224_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA224 hash string of the input file.

    :param input_file: the input file for which the SHA224 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA224 hash string of the file.

    """
    import hashlib

    sha224_hash = hashlib.sha224()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha224_hash.update(byte_block)
    return sha224_hash.hexdigest()


def create_sha256_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA256 hash string of the input file.

    :param input_file: the input file for which the SHA256 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA256 hash string of the file.

    """
    import hashlib

    sha256_hash = hashlib.sha256()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha256_hash.update(byte_block)
    return sha256_hash.hexdigest()


def create_sha384_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA384 hash string of the input file.

    :param input_file: the input file for which the SHA384 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA384 hash string of the file.

    """
    import hashlib

    sha384_hash = hashlib.sha384()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha384_hash.update(byte_block)
    return sha384_hash.hexdigest()


def create_sha512_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA512 hash string of the input file.

    :param input_file: the input file for which the SHA512 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA512 hash string of the file.

    """
    import hashlib

    sha512_hash = hashlib.sha512()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha512_hash.update(byte_block)
    return sha512_hash.hexdigest()


def create_md5_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the MD5 hash string of the input file.

    :param input_file: the input file for which the MD5 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: MD5 hash string of the file.

    """
    import hashlib

    md5_hash = hashlib.md5()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            md5_hash.update(byte_block)
    return md5_hash.hexdigest()


def create_blake2b_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the Blake2B hash string of the input file.

    :param input_file: the input file for which the Blake2B hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: Blake2B hash string of the file.

    """
    import hashlib

    blake2b_hash = hashlib.blake2b()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            blake2b_hash.update(byte_block)
    return blake2b_hash.hexdigest()


def create_blake2s_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the Blake2S hash string of the input file.

    :param input_file: the input file for which the Blake2S hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: Blake2S hash string of the file.

    """
    import hashlib

    blake2s_hash = hashlib.blake2s()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            blake2s_hash.update(byte_block)
    return blake2s_hash.hexdigest()


def create_sha3_224_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA3_224 hash string of the input file.

    :param input_file: the input file for which the SHA3_224 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA3_224 hash string of the file.

    """
    import hashlib

    sha224_hash = hashlib.sha3_224()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha224_hash.update(byte_block)
    return sha224_hash.hexdigest()


def create_sha3_256_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA3_256 hash string of the input file.

    :param input_file: the input file for which the SHA3_256 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA3_256 hash string of the file.

    """
    import hashlib

    sha256_hash = hashlib.sha3_256()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha256_hash.update(byte_block)
    return sha256_hash.hexdigest()


def create_sha3_384_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA3_384 hash string of the input file.

    :param input_file: the input file for which the SHA3_384 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA3_384 hash string of the file.

    """
    import hashlib

    sha384_hash = hashlib.sha3_384()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha384_hash.update(byte_block)
    return sha384_hash.hexdigest()


def create_sha3_512_hash(input_file: str, block_size: int = 4096) -> str:
    """
    A function which calculates finds the SHA3_512 hash string of the input file.

    :param input_file: the input file for which the SHA3_512 hash string with be found.
    :param block_size: the size of the blocks the file is read in in bytes
                       (default 4096; i.e., 4kb)
    :return: SHA3_512 hash string of the file.

    """
    import hashlib

    sha512_hash = hashlib.sha3_512()
    with open(input_file, "rb") as f:
        # Read and update hash string value in blocks of 4K
        for byte_block in iter(lambda: f.read(block_size), b""):
            sha512_hash.update(byte_block)
    return sha512_hash.hexdigest()


def untar_file(
    in_file: str, out_dir: str, gen_arch_dir: bool = True, verbose: bool = False
) -> str:
    """
    A function which extracts data from a tar file into the specified
    output directory. Optionally, an output directory of the same name
    as the archive file can be created for the output files.

    :param in_file: The input archive file.
    :param out_dir: The output directory which must exist (if gen_arch_dir=True then
                    a new directory will be created within the out_dir
    :param gen_arch_dir: Create a new directory with the same name as the input file
                         where the output files will be extracted to. (Default: True)
    :param verbose: If True (default: False) then more user feedback will be printed
                    to the console.
    :return: output directory where data was extracted to.

    """
    if not os.path.exists(out_dir):
        raise rsgislib.RSGISPyException("Output directory does not exist.")
    if not os.path.exists(in_file):
        raise rsgislib.RSGISPyException("Input file does not exist.")
    in_file = os.path.abspath(in_file)
    out_dir = os.path.abspath(out_dir)
    process_dir = out_dir
    if gen_arch_dir:
        basename = get_file_basename(in_file)
        process_dir = os.path.join(out_dir, basename)
    if not os.path.exists(process_dir):
        os.makedirs(process_dir)
    c_dir = os.getcwd()
    os.chdir(process_dir)
    if verbose:
        print("Extracting: {}".format(in_file))
        print("Output to: {}".format(process_dir))
        cmd = ["tar", "-xvf", in_file]
    else:
        cmd = ["tar", "-xf", in_file]
    try:
        import subprocess

        subprocess.run(cmd, check=True)
    except OSError as e:
        os.chdir(c_dir)
        raise rsgislib.RSGISPyException("Could not extract data: {}".format(cmd))
    os.chdir(c_dir)
    return process_dir


def untar_gz_file(
    in_file: str, out_dir: str, gen_arch_dir: bool = True, verbose: bool = False
) -> str:
    """
    A function which extracts data from a tar.gz file into the specified
    output directory. Optionally, an output directory of the same name
    as the archive file can be created for the output files.

    :param in_file: The input archive file.
    :param out_dir: The output directory which must exist (if gen_arch_dir=True then
                    a new directory will be created within the out_dir
    :param gen_arch_dir: Create a new directory with the same name as the input file
                         where the output files will be extracted to. (Default: True)
    :param verbose: If True (default: False) then more user feedback will be printed
                    to the console.
    :return: output directory where data was extracted to.

    """
    if not os.path.exists(out_dir):
        raise rsgislib.RSGISPyException("Output directory does not exist.")
    if not os.path.exists(in_file):
        raise rsgislib.RSGISPyException("Input file does not exist.")
    in_file = os.path.abspath(in_file)
    out_dir = os.path.abspath(out_dir)
    process_dir = out_dir
    if gen_arch_dir:
        basename = get_file_basename(in_file)
        process_dir = os.path.join(out_dir, basename)
    if not os.path.exists(process_dir):
        os.makedirs(process_dir)
    c_dir = os.getcwd()
    os.chdir(process_dir)
    if verbose:
        print("Extracting: {}".format(in_file))
        print("Output to: {}".format(process_dir))
        cmd = ["tar", "-xvzf", in_file]
        print(cmd)
    else:
        cmd = ["tar", "-xzf", in_file]
    try:
        import subprocess

        subprocess.run(cmd, check=True)
    except OSError as e:
        os.chdir(c_dir)
        raise rsgislib.RSGISPyException("Could not extract data: {}".format(cmd))
    os.chdir(c_dir)
    return process_dir


def unzip_file(
    in_file: str, out_dir: str, gen_arch_dir: bool = True, verbose: bool = False
) -> str:
    """
    A function which extracts data from a zip file into the specified
    output directory. Optionally, an output directory of the same name
    as the archive file can be created for the output files.

    :param in_file: The input archive file.
    :param out_dir: The output directory which must exist (if gen_arch_dir=True then
                    a new directory will be created within the out_dir
    :param gen_arch_dir: Create a new directory with the same name as the input file
                         where the output files will be extracted to. (Default: True)
    :param verbose: If True (default: False) then more user feedback will be printed
                    to the console.
    :return: output directory where data was extracted to.

    """
    if not os.path.exists(out_dir):
        raise rsgislib.RSGISPyException("Output directory does not exist.")
    if not os.path.exists(in_file):
        raise rsgislib.RSGISPyException("Input file does not exist.")
    in_file = os.path.abspath(in_file)
    out_dir = os.path.abspath(out_dir)
    process_dir = out_dir
    if gen_arch_dir:
        basename = get_file_basename(in_file)
        process_dir = os.path.join(out_dir, basename)
    if not os.path.exists(process_dir):
        os.makedirs(process_dir)
    c_dir = os.getcwd()
    os.chdir(process_dir)
    cmd = ["unzip", in_file]
    if verbose:
        print("Extracting: {}".format(in_file))
        print("Output to: {}".format(process_dir))
        print(cmd)
    try:
        import subprocess

        subprocess.run(cmd, check=True)
    except OSError as e:
        os.chdir(c_dir)
        raise rsgislib.RSGISPyException("Could not extract data: {}".format(cmd))
    os.chdir(c_dir)
    return process_dir


def untar_bz_file(
    in_file: str, out_dir: str, gen_arch_dir: bool = True, verbose: bool = False
) -> str:
    """
    A function which extracts data from a tar.bz file into the specified
    output directory. Optionally, an output directory of the same name
    as the archive file can be created for the output files.

    :param in_file: The input archive file.
    :param out_dir: The output directory which must exist (if gen_arch_dir=True then
                    a new directory will be created within the out_dir
    :param gen_arch_dir: Create a new directory with the same name as the input file
                         where the output files will be extracted to. (Default: True)
    :param verbose: If True (default: False) then more user feedback will be printed
                    to the console.
    :return: output directory where data was extracted to.

    """
    if not os.path.exists(out_dir):
        raise rsgislib.RSGISPyException("Output directory does not exist.")
    if not os.path.exists(in_file):
        raise rsgislib.RSGISPyException("Input file does not exist.")
    in_file = os.path.abspath(in_file)
    out_dir = os.path.abspath(out_dir)
    process_dir = out_dir
    if gen_arch_dir:
        basename = get_file_basename(in_file)
        process_dir = os.path.join(out_dir, basename)
    if not os.path.exists(process_dir):
        os.makedirs(process_dir)
    c_dir = os.getcwd()
    os.chdir(process_dir)
    if verbose:
        print("Extracting: {}".format(in_file))
        print("Output to: {}".format(process_dir))
        cmd = ["tar", "-xvjf", in_file]
        print(cmd)
    else:
        cmd = ["tar", "-xjf", in_file]
    try:
        import subprocess

        subprocess.run(cmd, check=True)
    except OSError as e:
        os.chdir(c_dir)
        raise rsgislib.RSGISPyException("Could not extract data: {}".format(cmd))
    os.chdir(c_dir)
    return process_dir


def create_targz_arch(out_arch_file: str, file_list: list, base_path: str = None):
    """
    A function which can be used to create a tar.gz file containing the
    list of input files. If you wish to remove some of the directory
    structure from the file paths in provided then a single base_path
    can be provided and will be removed from the file paths in the archive.

    :param out_arch_file: the output tar.gz file path
    :param file_list: the list of files to be added to the archive.
    :param base_path: the base path which will be removed from all the input files.
                      Note, this means all the input files must have the same base
                      path. Optional: Default is None (i.e., ignored).

    """
    import tarfile

    out_arch_file = os.path.abspath(out_arch_file)

    if base_path is not None:
        cwd = os.getcwd()
        os.chdir(base_path)

    with tarfile.open(out_arch_file, "w:gz") as tar_out_file:
        for c_file in file_list:
            if base_path is not None:
                c_file = os.path.relpath(c_file, base_path)
            tar_out_file.add(c_file)

    if base_path is not None:
        os.chdir(cwd)
