# -*- coding: utf-8 -*-
'''
Tools for working with paths and files.
'''

# Standard library imports
import os
import shutil
import stat
import zipfile
from fnmatch import fnmatch


def normalize(*parts):
    '''Join, expand, and normalize a filepath.'''

    path = os.path.expanduser(os.path.expandvars(os.path.join(*parts)))
    return os.path.abspath(path).replace('\\', '/')


def parent(path):
    '''Get a file paths parent directory'''
    return normalize(os.path.dirname(path))


def ensure_path_exists(path, *args):
    '''Like os.makedirs but keeps quiet if path already exists'''

    if os.path.exists(path):
        return

    os.makedirs(path, *args)


def is_writable(path):
    '''Check if a directory is writable.'''

    if os.path.exists(path):
        return os.access(path, os.X_OK | os.W_OK)

    try:
        os.makedirs(path)
        touch(normalize(path, 'tmpfile'))
    except OSError as e:
        return False
    else:
        rmtree(path)
        return True


def rmtree(path):
    '''Safely remove directory and all of it's contents.'''

    def onerror(func, path, _):
        os.chmod(path, stat.S_IWRITE)
        func(path)

    shutil.rmtree(path, onerror=onerror)


def walk_up(start_dir, depth=20):
    '''Like os.walk but walks up a tree.'''

    root = start_dir

    for i in range(depth):
        contents = os.listdir(root)
        subdirs, files = [], []
        for f in contents:
            if os.path.isdir(os.path.join(root, f)):
                subdirs.append(f)
            else:
                files.append(f)

        yield root, subdirs, files

        parent = os.path.dirname(root)
        if parent and not parent == root:
            root = parent
        else:
            break


def touch(filepath):
    '''Touch the given filepath'''

    with open(filepath, 'a'):
        os.utime(filepath, None)


def format_size(bytesize):
    '''Human readable size.'''

    value = bytesize
    for unit in ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'zi']:
        if value < 1024.0:
            return "{:.0f} {}".format(value, unit)
        value /= 1024.0
    return "{:.0f} {}".format(value, unit)


def get_file_count(folder):
    '''Get the number of files in a folder.'''

    count = 0
    for _, _, files in exclusive_walk(folder):
        count += len(files)
    return count


def get_folder_size(folder):
    '''Get the size of a folder in bytes.'''

    size = 0
    for root, _, files in exclusive_walk(folder):
        for file in files:
            file_path = os.path.join(root, file)
            if not os.path.islink(file_path):
                size += os.path.getsize(file_path)
    return size


def exclude_names(names):
    '''Returns True when a file matches one of the provided names.'''

    def check_file_against_names(file):
        return os.path.basename(file) in names

    return check_file_against_names


def exclude_patterns(patterns):
    '''Returns True when a file matches against one of the provided patterns'''

    def check_file_against_patterns(file):
        return any([fnmatch(file, p) for p in patterns])

    return check_file_against_patterns


def include_prebuilt_pyc(file):
    '''Returns True when a pyc file has no accompanying py file.

    This is useful in cases where tool distributors prebuild pyc files as
    a crude form of anti-piracy. These need to be included in cpenv modules
    when zipped to be uploaded to a repository.
    '''

    return (
        file.endswith('.pyc')
        and not os.path.isfile(file.replace('.pyc', '.py'))
        and '__pycache__' not in file
    )


def is_excluded(value, predicates):
    '''Check if a value matches any of the exclude predicates.'''

    return any([predicate(value) for predicate in predicates])


def is_included(value, predicates):
    '''Returns True if value matches any of the include predicates.'''

    return any([predicate(value) for predicate in predicates])


def exclusive_walk(folder, excludes=None, includes=None):
    '''Like os.walk but excludes/includes files by using predicate functions.

    Excludes the following by default:
        names: __pycache__, .git, Thumbs.db, .venv, venv
        patterns: *.pyc, *.egg-info

    Includes the following by default:
        .pyc files that have no accompanying .py file.

    Arguments:
        folder (str): Root folder to recursively walk.
        excludes ([callable]): List of predicate functions used to exclude files.
        includes ([callable]): List of predicate functions to include files. Overrides excludes.

    Returns:
        Generator yielding (root, subdirs, files).
    '''

    excludes = excludes or [
        exclude_names(['__pycache__', '.git', 'thumbs.db', '.venv', 'venv']),
        exclude_patterns(['*.pyc', '*.egg-info']),
    ]
    includes = includes or [include_prebuilt_pyc]

    for root, subdirs, files in os.walk(folder):
        if is_excluded(root, excludes) and not is_included(root, includes):
            subdirs[:] = []
            continue

        included_files = []
        for file in files:
            path = normalize(root, file)
            if is_excluded(path, excludes) and not is_included(path, includes):
                continue
            included_files.append(file)

        yield root, subdirs, included_files


def zip_folder(folder, where):
    '''Zip the contents of a folder.'''

    parent = os.path.dirname(where)
    if not os.path.isdir(parent):
        os.makedirs(parent)

    # TODO: Count files first so we can report progress of building zip.

    with zipfile.ZipFile(where, 'w', zipfile.ZIP_DEFLATED) as zip_file:
        for root, subdirs, files in exclusive_walk(folder):
            rel_root = os.path.relpath(root, folder)
            for file in files:
                zip_file.write(
                    os.path.join(root, file),
                    arcname=os.path.join(rel_root, file)
                )
