"""General utility functions.
"""
import hashlib
import binascii
import pathlib
import re
import gzip
import bz2
import os
import tempfile


_SMALL_NUMBERS = {
    1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six",
    7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten", 11: "Eleven",
    12: "Twelve", 13: "Thirteen", 14: "Fourteen", 15: "Fifteen",
    16: "Sixteen", 17: "Seventeen", 18: "Eighteen",
    19: "Nineteen", 20: "Twenty", 30: "Thirty", 40: "Forty",
    50: "Fifty", 60: "Sixty", 70: "Seventy",
    80: "Eighty", 90: "Ninty"
}
"""Small rule based numerics (`dict`)
"""
_BIG_NUMBERS = {
    100: "Hundred", 1000: "Thousand", 1000000: "Million",
    1000000000: "Billion", 1000000000000: "Trillion"
}
"""Large rule based numerics (`dict`)
"""


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_file_md5(infile):
    """Get an MD5 hash of the file.

    Parameters
    ----------
    infile : `str`
        The path to the input file.

    Returns
    -------
    md5hash : `str`
        The 32 character MD5 hash of the file.
    """
    with open(infile, "rb") as f:
        file_hash = hashlib.md5()
        while chunk := f.read(8192):
            file_hash.update(chunk)
    return file_hash.hexdigest()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def is_gzip(infile):
    """Test of the file is gzipped.

    Parameters
    ----------
    infile : `str`
        The path to the input file.

    Returns
    -------
    is_gzipped : `bool`
        ``True`` if the file is gzipped, ``False`` if not.
    """
    with open(infile, 'rb') as test:
        # We read the first two bytes and we will look at their values
        # to see if they are the gzip characters
        testchr = test.read(2)
        is_gzipped = False

        # Test for the gzipped characters
        if binascii.b2a_hex(testchr).decode() == "1f8b":
            # If it is gziped, then close it and reopen as a gzipped file
            is_gzipped = True

    return is_gzipped


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def is_bgzip(infile):
    """Test of the file is bgzipped (block zipped format).

    Parameters
    ----------
    infile : `str`
        The path to the input file.

    Returns
    -------
    is_bgzipped : `bool`
        ``True`` if the file is bgzipped, ``False`` if not.

    Notes
    -----
    bgzipped files will also test True for gzipped but not the other way
    around.
    """
    with open(infile, 'rb') as test:
        # We read the first two bytes and we will look at their values
        # to see if they are the gzip characters
        testchr = test.read(4)
        is_gzipped = False

        # Test for the gzipped characters
        if binascii.b2a_hex(testchr).decode() == "1f8b0804":
            # If it is gziped, then close it and reopen as a gzipped file
            is_gzipped = True

    return is_gzipped


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def is_bzip2(infile):
    """Test of the file is bzip2 compressed.

    Parameters
    ----------
    infile : `str`
        The path to the input file.

    Returns
    -------
    is_bzip2 : `bool`
        ``True`` if the file is is_bzip2, ``False`` if not.
    """
    with open(infile, 'rb') as test:
        # We read the first two bytes and we will look at their values
        # to see if they are the gzip characters
        testchr = test.read(4)
        is_bzip2 = False

        # Test for the gzipped characters
        if binascii.b2a_hex(testchr).decode() == "425a6839":
            # If it is gziped, then close it and reopen as a gzipped file
            is_bzip2 = True

    return is_bzip2


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_open_method(test_file, allow_none=False):
    """This gets the method to that can be used to open the ``test_file``.
    This can either be ``builtins.open``, ``gzip.open``, ``bz2.open``.

    Parameters
    ----------
    test_file : `str`
        The file path to test.
    allow_none : `bool`, optional, default: `False`
        Allow the ``test_file`` to be ``NoneType``. This is used in cases
        where you might want a NoneType to mean input from STDIN or output
        to STDOUT, in which case the open method is ``builtins.open``.

    Returns
    -------
    open_method : `builtins.open` or `gzip.open` or `bz2.open`
        The method to use to open the file.

    Notes
    -----
    This works as follows. If the file exists then we assume it is to be
    read (or overwritten) so the actual file is tested to see if it is
    gzipped. If it does not exist then the file extension is examined to see
    if it has a ``.gz``or ``.bgz`` file extension, if so we go for
    ``gzip.open``. If the extension then it is ``.bz2``, then ``bz2.open``.
    """
    try:
        if is_gzip(test_file) is True:
            return gzip.open
        elif is_bzip2(test_file) is True:
            return bz2.open
        return open
    except FileNotFoundError:
        if re.search(r'\.b?gz$', test_file):
            return gzip.open
        if re.search(r'\.bz2$', test_file):
            return bz2.open
        return open
    except TypeError:
        if allow_none is True and test_file is None:
            return open
        raise


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def strip_file_extension(infile):
    """Remove (potentially multiple) file extensions from a file name.

    Parameters
    ----------
    infile : `str`
        The path to the input file that the extensions need removing.

    Returns
    -------
    stripped_infile : `str`
        The path to the input file with all extensions removed.
    """
    infile = pathlib.Path(infile)

    # Get all the extensions from the file being indexed
    extensions = "".join(infile.suffixes)

    # Remove any extensions from the file being indexed to create a based
    # index file name
    return str(infile).replace(extensions, "")


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def test_same_files(first, second, error_msg="files are the same"):
    """Test that the file paths are not the same.

    Parameters
    ----------
    first : `str`
        The first file path.
    second : `str`
        The second file path.
    msg : `str`, optional, default `files are the same`
        An optional error message.

    Raises
    ------
    ValueError
        If the files are the same.
    """
    try:
        if get_full_path(first).samefile(get_full_path(second)):
            raise ValueError(error_msg)
    except FileNotFoundError:
        # Maybe the files have not been
        if get_full_path(first) == get_full_path(second):
            raise ValueError(error_msg)


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_full_path(path, allow_none=False):
    """Get the full path.

    Parameters
    ----------
    path : `str`
        The path to the input file.
    allow_none : `bool`, optional, default: `False`
        Allow the path to be ``NoneType``. This is used in cases where you
        might want a NoneType to mean input from STDIN or output to STDOUT

    Returns
    -------
    full_path : `pathlib.Path` or `NoneType`
        The full path, ``NoneType`` is returned if ``path`` is
        ``NoneType``.
    """
    try:
        path = pathlib.Path(path)
    except TypeError:
        if path is None:
            return path
        raise

    return path.expanduser().resolve()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_temp_file(**kwargs):
    """Get a temporary file, this wraps the system temp location and
    returns a file location that has been closed.

    Parameters
    ----------
    **kwargs
        The keyword arguments to ``tempfile.mkstemp``.

    Returns
    -------
    full_path : `str`
        The full path to the tempfile.
    """
    fobj, fname = tempfile.mkstemp(**kwargs)
    os.close(fobj)
    return fname


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_tmp_file(**kwargs):
    """An alias for get_temp_file, as I constantly get the name wrong.

    Parameters
    ----------
    **kwargs
        The keyword arguments to ``tempfile.mkstemp``.

    Returns
    -------
    full_path : `str`
        The full path to the tempfile.
    """
    return get_temp_file(**kwargs)


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_text_number(number, loop=0):
    """Return a string based number for an integer number, works up to the
    trillions.

    Parameters
    ----------
    number : `int`
        Th number to process into text.
    loop : `int`, optional, default: `0`
        The internal loop number, no effect, used for debugging only.

    Returns
    -------
    text_number : `str`
        A text representation of the number.
    """
    try:
        # First we see if it is a match to one of the small numbers
        return _SMALL_NUMBERS[number]
    except KeyError:
        # print("KEY_ERROR PASSED {0}={1}".format(loop, number))
        # This will hold the final parsed number for this function call
        final_number = ""

        # Convert the number into a string
        string_number = str(number)

        # If the number is greater than two digits then we will need an And
        # joiner at the end otherwise we do not
        joiner = ""
        if len(string_number) > 2:
            joiner = "And"

        # Grab the tens and units as they will need processing separately
        tens_and_units = string_number[
            len(string_number) - 2:len(string_number)
        ]

        try:
            # Make sure that we are not in the numbers as we mayb be
            tens = _SMALL_NUMBERS[int(tens_and_units)]
        except KeyError:
            # No we are not, so get the number for the units and the tens
            # separately, this should work
            try:
                units = _SMALL_NUMBERS[int(tens_and_units[1:2])]
                tens = "{0}{1}".format(
                    _SMALL_NUMBERS[int(tens_and_units[0:1]) * 10],
                    units
                )
            except (ValueError, KeyError):
                joiner = ""
                tens = ""

        # Now we will iterate through the number that is cast to a string and
        # slice substrings out based on naming transitions in the number series
        # Store the upper range limit of the string
        strlen = len(string_number) - 1

        # These are the transitions, if a number is 1234567. 67 has been
        # processed above and th eremaining transitions:
        # So the first iteration will select 3,4 so the hundred digits, 5
        # So the next iteration is the thousands 4, 7, so the thousands 234
        # So the next iteration is the millions 7, 10, so 1
        transitions = [3, 4, 7, 10, 13, 16]
        for i in range(len(transitions)):
            # Our transitions are numbered from the right and are one based
            # We need to extract from the left and be 0 based. As we go through
            # the transitions it is possible that the number sits in the middle
            # of a transition, in which case the start will be negative and the
            # end will be positive, in these cases, we fix to the start of the
            # string (usingn max)
            try:
                start = max(0, strlen - (transitions[i+1] - 1) + 1)
                end = strlen - (transitions[i] - 1) + 1
            except IndexError:
                start = 0
                end = 0

            # If the end is every < 0 then we have overshot the string and we
            # break
            if end <= 0:
                break

            # Now extract the digits
            digit = string_number[start:end]

            # This gets the power of the digits e.g. Hundred, Thousand, Million
            power = _BIG_NUMBERS[10**(transitions[i] - 1)]

            remainder = get_text_number(digit, loop=loop+1)
            if remainder == "":
                power = ""

            final_number = "{0}{1}{2}".format(remainder, power, final_number)
    return "{0}{1}{2}".format(final_number, joiner, tens)


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def obj_repr(obj, attr=None):
    """A generalised function for creating a __repr__ string.

    Parameters
    ----------
    obj : `Any`
        An object to extract attributes from.
    attr : `list` of `str`
        Attributes of the object to be included in the output. If None or
        len==0, then all non-function attributes are used

    Returns
    -------
    rep : `str`
        The string representation of the object
    """
    if attr is None or len(attr) == 0:
        # Get all the non private objects, note that this will return methods
        # as well.
        # TODO: remove methods from here
        attr = [i for i in dir(obj)
                if not i.startswith('_')]

    # Filter out any method calls
    attr_values = []
    for i in attr:
        val = getattr(obj, i)
        if val.__class__.__name__ != "method":
            attr_values.append("{0}={1}".format(i, val))

    # Turn outlist into a string and return
    return "<{0}({1})>".format(obj.__class__.__name__, ", ".join(attr_values))
