#==============================================================================
# C O P Y R I G H T
#------------------------------------------------------------------------------
# Copyright (c) 2019-2023 by Helmut Konrad Schewe. All rights reserved.
# This file is property of Helmut Konrad Schewe. Any unauthorized copy,
# use or distribution is an offensive act against international law and may
# be prosecuted under federal law. Its content is company confidential.
#==============================================================================

import binascii
import concurrent.futures
import contextlib
import functools
import glob
import math
import os
import random
import re
import sys
import time
import webbrowser

import baw.runtime

BAW_EXT = '.baw'
TMP = '.tmp'

REQUIREMENTS_TXT = 'requirements.txt'
REQUIREMENTS_DEV = 'requirements.dev'
REQUIREMENTS_EXTRA = 'requirements.ext'

SUCCESS = 0
FAILURE = 1

INPUT_ERROR = 2

NEWLINE = '\n'
UTF8 = 'utf8'


@contextlib.contextmanager
def handle_error(*exceptions: list, code: int = 1):
    """Catch given `exceptions` and print there message to `stderr`. Exit
    system with given `code`.

    Args:
        exceptions(iterable): of exception, which are handle by this context
        code(int): returned error-code
    Raises:
        SystemExit: if given `exceptions` is raised while executing context
    Yields:
        Context: to run operation

    >>> with handle_error(ValueError, code=10):
    ...     raise ValueError('HeKo')
    Traceback (most recent call last):
    ...
        sys.exit(code)
    SystemExit: 10
    """
    try:
        yield
    except exceptions as failure:
        error(failure)
        sys.exit(code)


def log(msg: str = '', end: str = NEWLINE):
    """Write message to logger

    Args:
        msg(str): message to log
        end(str): lineending
    Hint:
        Logging with default arguments will log a newline
    """
    msg = forward_slash(msg)
    msg = fix_encoding(msg)
    print(msg, end=end, file=sys.stdout, flush=True)


def debug(msg: str = '', end: str = NEWLINE):
    # TODO: ENABLE LATER
    logit = False
    if not logit:
        return
    msg = '[DEBUG]:' + msg
    log(msg, end=end)


def completed(completed, force: bool = False):  # pylint:disable=W0621
    if not completed.returncode and not force:
        return
    if completed.stdout and completed.stdout.strip():
        # stdout can be None
        log(completed.stdout)
    if completed.stderr and completed.stderr.strip():
        # stderr can be None
        log(completed.stderr)


def verbose(msg: str = '', end: str = NEWLINE, verbose: bool = False):  # pylint:disable=W0621
    if not verbose:
        return
    log(msg=msg, end=end)


def error(msg: str, end: str = NEWLINE):
    """Print error-message to stderr and add [ERROR]-tag"""
    # use forward slashs
    msg = forward_slash(msg)
    msg = fix_encoding(msg)
    print(f'[ERROR] {msg}', end=end, file=sys.stderr, flush=True)


def fix_encoding(msg: str) -> str:
    """Remove invalid character to display on console

    Args:
        msg(str): message with invalid character
    Returns:
        message `without` invalid character
    """
    # ensure to have str
    msg = str(msg)
    # convert for windows console
    encoding = 'cp1252' if 'win' in sys.platform else 'utf-8'
    # remove non valid char to avoid errors on win-console
    msg = msg.encode(encoding, errors='xmlcharrefreplace').decode(encoding)
    return msg


PLAINOUTPUT = 'PLAINOUTPUT'
SAVEGUARD = 'IAMTHESAVEGUARDXYXYXYXYXYXYXYXYXYXYXY'
SECOND_GUARD = 'OHMANIHAVETGOLEARNMOREPYTHONTHATS'


def forward_slash(content: str, save_newline=True):
    r"""\
    >>> forward_slash("\\")
    '/'
    >>> forward_slash(r"\\\\")
    '//'
    >>> forward_slash('\n')
    '\n'
    >>> forward_slash(r'C:\\usr\\python\\310\\')
    'C:/usr/python/310/'
    >>> forward_slash('\\\n')
    '/\n'
    """
    # TODO: HACK
    if PLAINOUTPUT in os.environ:
        return content
    content = str(content)
    if save_newline:
        # Save newline
        content = content.replace('\n', SECOND_GUARD)
        content = content.replace(r'\n', SAVEGUARD)
    # Forward slash
    content = content.replace(r'\\', '/').replace('\\', '/')
    if save_newline:
        # Restore newline
        content = content.replace(SECOND_GUARD, '\n')
        content = content.replace(SAVEGUARD, r'\n')
    return content


@functools.lru_cache(maxsize=16)
def tmp(root: str) -> str:
    """Return path to temporary folder. Create folder if required.

    Args:
        root(str): project root
    Returns:
        path to temporary folder

    >>> tmp(baw.ROOT)
    '...'
    """
    assert root
    # queuemo-1.17.2-py3.8.egg
    projectname = os.path.split(root)[1].split('-')[0]
    import baw.config  # pylint:disable=W0621
    path = os.path.join(baw.config.bawtmp(), 'tmp', projectname)
    os.makedirs(path, exist_ok=True)
    return path


def tmpname(width: int = 10):
    """\
    >>> len(tmpname(15))
    15
    >>> tmpname(15)
    '...'
    """
    value = int(random.random() * math.pow(10, width))  # nosec
    name = str(value).zfill(width)
    return name


def tmpfile() -> str:
    """\
    >>> tmpfile()
    '...'
    """
    name = tmpname()
    tmps = tmp(baw.ROOT)
    result = os.path.join(tmps, name)
    if os.path.exists(result):
        # try again
        return tmpfile()
    return result


@contextlib.contextmanager
def tmpdir(delete: bool = False) -> str:  # pylint:disable=W0613
    path = tmpfile()
    os.makedirs(path)
    yield path


def check_root(root: str):
    if not os.path.exists(root):
        raise ValueError(f'Project root: {root} does not exists')


def file_append(path: str, content: str):
    assert os.path.exists(path), str(path)
    with open(path, mode='a', newline=NEWLINE, encoding=UTF8) as fp:
        fp.write(content)


def file_create(path: str, content: str = ''):
    assert not os.path.exists(path), str(path)
    content = normalize_final(content)
    with open(path, mode='w', newline=NEWLINE, encoding=UTF8) as fp:
        fp.write(content)


def file_read(path: str):
    assert os.path.exists(path), str(path)
    with open(path, mode='r', newline=NEWLINE, encoding=UTF8) as fp:
        return normalize_final(fp.read())


def file_read_binary(path: str):
    assert os.path.exists(path), str(path)
    with open(path, mode='rb') as fp:
        return fp.read()


def file_remove(path: str):
    assert os.path.exists(path), str(path)
    assert os.path.isfile(path), str(path)
    os.remove(path)


def normalize_final(content: str):
    content = content.rstrip()
    content = f'{content}\n'
    return content


def file_replace(path: str, content: str):
    """Replace file content

    1. If not exists, create file
    2. If exists,     compare content, if changed than replace
                                       if not, do nothing
    Args:
        path(str): path to file
        content(str): content to write
    """
    content = normalize_final(content)
    if not os.path.exists(path):
        file_create(path, content)
        return
    current_content = file_read(path)
    if current_content == content:
        return

    with open(path, mode='w', newline=NEWLINE, encoding=UTF8) as fp:
        fp.write(content)


def print_runtime(before: int):
    """Determine runtime due the diff of current time and provided time
    `before`. Log this timediff.

    Args:
        before(int): time recorded some time before - use time.time()
    """
    time_diff = time.time() - before
    log('Runtime: %.2f secs\n' % time_diff)  # pylint:disable=C0209


@contextlib.contextmanager
def profile():
    """Print runtime to logger to monitore performance"""
    start = time.time()
    try:
        yield
    except Exception:
        print_runtime(start)
        raise
    print_runtime(start)


def skip(msg: str):
    """Logging skipped event.

    Args:
        msg(str): message to skip
    """
    log(f'skip: {msg}')


@contextlib.contextmanager
def empty(*args, **kwargs):  # pylint:disable=W0613
    yield


def openbrowser(url: str):
    """\
    >>> openbrowser(__file__) is None
    True
    """
    if testing():
        # running with pytest do not open webbrowser
        return
    webbrowser.open_new(url)


def fork(
    *runnables,
    worker: int = 6,
    process: bool = False,
    returncode: bool = False,
) -> int:
    """Run methods in parallel.

    Args:
        runnables(callable): callables to run
        worker(int): number of worker
        process(bool): if True use Process- instead of ThreadPool
        returncode(bool): always return `returncode` instead of computed
                          result
    Returns:
        returncode if error occurs or returncode=True
        result of computation if no error occurs or returncode is not used

    >>> fork(['Not A Method', 'Also not a method'], returncode=True) == baw.FAILURE
    True
    """
    failure = 0
    executor = concurrent.futures.ThreadPoolExecutor
    if process:
        executor = select_executor()
    result = [None] * len(runnables)
    with executor(max_workers=worker) as pool:
        futures = {pool.submit(item): item for item in runnables}
        for future in concurrent.futures.as_completed(futures):
            index = runnables.index(futures[future])
            try:
                result[index] = future.result()
            except Exception as failed:  # pylint:disable=broad-except
                error(f'future number: {index}; {future} failed.')
                error(failed)
                failure += 1
    if failure:
        return failure
    if returncode:
        # TODO: FAILURE IS ALWAYS ZERO
        # Pylint does not raises an Exception but produces an returnvalue.
        return max((failure, sum(result)))
    return result


def select_executor():
    # TODO: how to use multiprocessing with pytest, see pytest: 38.3.1
    testrun = os.environ.get('PYTEST_PLUGINS', False)
    executor = concurrent.futures.ProcessPoolExecutor
    if testrun:  # pragma:nocover
        executor = concurrent.futures.ThreadPoolExecutor
    return executor


def binhash(data: bytes) -> int:
    """\
    >>> binhash(b'hello')
    907060870
    """
    if isinstance(data, str):
        data: bytes = data.encode('utf8')
    result = binascii.crc32(data)
    return result


def fixup_windows(path):
    """On windows argsparse expand /var/outdir/test.xml to C:/usr/git/var/..

    This fixup fixes this.

    >>> fixup_windows('--junit_xml=C:/usr/git/var/outdir/test.xml')
    '--junit_xml=/var/outdir/test.xml'
    """
    # TODO: IMPORVE LATER
    path = path.replace('C:/usr/git/var', '/var')
    return path


def testing() -> bool:
    """\
    >>> testing()
    True
    """
    if os.environ.get('PYTEST_CURRENT_TEST', None):
        return True
    return False


def exitx(msg='', returncode=FAILURE):
    """End progam. Log optional exit message as failure or success.

    >>> exitx('Fehler?')
    Traceback (most recent call last):
    ...sys.exit(returncode)
    SystemExit: 1
    >>> exitx('Alles gut.', returncode=0)
    Traceback (most recent call last):
    ...
    SystemExit: 0
    """
    if msg:
        if returncode:
            baw.error(msg)
        else:
            baw.log(msg)
    sys.exit(returncode)


def git_hash(root) -> str:
    if not baw.runtime.hasprog("git"):
        exitx("install git, please")
    done = baw.runtime.run(
        "git describe",
        cwd=root,
    )
    value = done.stdout.strip()
    if value == static(root):
        return value
    # transform v2.40.1-5-gc1b4bee to
    # utilo-2.93.0.post6+g3b6726a
    value = value[1:]
    value = value.replace("-", ".post", 1)
    value = value.replace("-g", "+g")
    return value


def static(root):
    short = baw.cmd.info.baw_name(root)
    if not short:
        exitx(msg=f"missing short `{short}` def in .baw: {root}")
    path = os.path.join(root, short, "__init__.py")
    content = baw.utils.file_read(path)
    result = re.search(r"__version__ = \'(.*?)\'", content).group(1)
    return result


def files_sort(files: list) -> list:
    """Sort `files` path alphabetically. Sort file names by number if given.

    >>> files_sort(('/c/a', '/c/200.txt', '/c/2.txt', '/c/3', '/c/0.bmp'))
    ['/c/0.bmp', '/c/2.txt', '/c/3', '/c/200.txt', '/c/a']
    """
    files = [forward_slash(item) for item in files]

    def number_filename(item):
        # sort file names if they are numbers: 0,1,2,3,4,5,6,7,8,9,10
        item = item.lower()
        item = file_name(item)
        with contextlib.suppress(ValueError):
            # sort items by number
            item: int = int(item)
            # ensure to compare str and str and not str and int
            item: str = str(item).zfill(20)
        return item

    files = sorted(files, key=number_filename)
    return files


def file_name(path: str, ext: bool = False) -> str:
    """Determine file name without file extension out of file path.

    >>> file_name('/etc/profile.d/helm.sh')
    'helm'
    >>> file_name('info.txt')
    'info'
    >>> file_name('/etc/.tmp')
    '.tmp'
    >>> file_name('.etc')
    '.etc'
    >>> file_name('/no/file/ext')
    'ext'
    >>> file_name('etc/dev/raw.png', ext=True)
    'raw.png'
    """
    assert path, path
    path = forward_slash(path)
    try:
        _, name = path.rsplit('/', 1)
    except ValueError:
        name = path
    if ext:
        return name
    if name[0] == '.':
        return name
    return name.split('.')[0]


def file_list(  # pylint:disable=R1260
    path: str,
    include: list = None,
    exclude: list = None,
    recursive: bool = True,
    absolute: bool = False,
    sort: bool = True,
) -> list:
    """Scans `path` recursively and returns list of relative file path
    which matches `include` or `exclude` pattern.

    Args:
        path(str): root path to scan files
        include(list): list of patterns to include
        exclude(list): list of patterns to exclude
        recursive(bool): visit child folder
        absolute(bool): if True add `path` to extracted files
        sort(bool): sort file path by name
    Returns:
        List of selected files.
    """
    msg = f'only one pattern is allowed {include} ! {exclude}'
    assert not (include and exclude), msg
    include = include if include else []
    exclude = exclude if exclude else []
    include = [include] if isinstance(include, str) else include
    exclude = [exclude] if isinstance(exclude, str) else exclude
    # make unique and ?fast?
    include: set = set(include)
    exclude: set = set(exclude)
    result = []
    for item in glob.glob(f'{path}/**/*', recursive=recursive):
        if not os.path.isfile(item):
            continue
        item = os.path.relpath(item, path)
        filepath = forward_slash(item)
        try:
            ext = filepath.rsplit('.', maxsplit=1)[1]
        except IndexError:
            # file without extension
            ext = None
        if include:
            if ext not in include:
                continue
        if exclude:
            if ext in exclude:
                continue
        if absolute:
            filepath = os.path.join(path, item)
        result.append(filepath)
    if sort:
        result = files_sort(result)
    return result
