#!/usr/bin/env python
# cardinal_pythonlib/ui.py

"""
===============================================================================

    Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com).

    This file is part of cardinal_pythonlib.

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        https://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.

===============================================================================

**File operations.**

"""

from contextlib import contextmanager
import fnmatch
import glob
import os
import shutil
import stat
from types import TracebackType
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple

from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler

log = get_brace_style_log_with_null_handler(__name__)


# =============================================================================
# Find or require executables
# =============================================================================

def which_with_envpath(executable: str, env: Dict[str, str]) -> str:
    """
    Performs a :func:`shutil.which` command using the PATH from the specified
    environment.

    Reason: when you use ``run([executable, ...], env)`` and therefore
    ``subprocess.run([executable, ...], env=env)``, the PATH that's searched
    for ``executable`` is the parent's, not the new child's -- so you have to
    find the executable manually.

    Args:
        executable: executable to find
        env: environment to fetch the PATH variable from
    """
    oldpath = os.environ.get("PATH", "")
    os.environ["PATH"] = env.get("PATH")
    which = shutil.which(executable)
    os.environ["PATH"] = oldpath
    return which


_MISSING_COMMAND = "Missing command (must be on the PATH): "


def require_executable(executable: str) -> None:
    """
    If ``executable`` is not found by :func:`shutil.which`, raise
    :exc:`FileNotFoundError`.
    """
    if shutil.which(executable):
        return
    errmsg = _MISSING_COMMAND + executable
    log.critical(errmsg)
    raise FileNotFoundError(errmsg)


def which_and_require(executable: str, fullpath: bool = False) -> str:
    """
    Ensures that ``executable`` is on the path, and returns it (or its full
    path via :func:`shutil.which`).
    """
    w = shutil.which(executable)
    if w:
        return w if fullpath else executable
    errmsg = _MISSING_COMMAND + executable
    log.critical(errmsg)
    raise FileNotFoundError(errmsg)


# =============================================================================
# Create directories
# =============================================================================

def mkdir_p(path: str) -> None:
    """
    Makes a directory, and any intermediate (parent) directories if required.

    This is the UNIX ``mkdir -p DIRECTORY`` command; of course, we use
    :func:`os.makedirs` instead, for portability.
    """
    log.debug("mkdir -p " + path)
    os.makedirs(path, exist_ok=True)


# =============================================================================
# Change directories
# =============================================================================

@contextmanager
def pushd(directory: str) -> None:
    """
    Context manager: changes directory and preserves the original on exit.

    Example:

    .. code-block:: python

        with pushd(new_directory):
            # do things
    """
    previous_dir = os.getcwd()
    os.chdir(directory)
    yield
    os.chdir(previous_dir)


def preserve_cwd(func: Callable) -> Callable:
    """
    Decorator to preserve the current working directory in calls to the
    decorated function.

    Example:

    .. code-block:: python

        @preserve_cwd
        def myfunc():
            os.chdir("/faraway")

        os.chdir("/home")
        myfunc()
        assert os.getcwd() == "/home"
    """
    # https://stackoverflow.com/questions/169070/python-how-do-i-write-a-decorator-that-restores-the-cwd  # noqa
    def decorator(*args_, **kwargs) -> Any:
        cwd = os.getcwd()
        result = func(*args_, **kwargs)
        os.chdir(cwd)
        return result
    return decorator


def root_path() -> str:
    """
    Returns the system root directory.
    """
    # https://stackoverflow.com/questions/12041525
    return os.path.abspath(os.sep)


# =============================================================================
# Copy or move things
# =============================================================================

def copyglob(src: str, dest: str, allow_nothing: bool = False,
             allow_nonfiles: bool = False) -> None:
    """
    Copies files whose filenames match the glob src" into the directory
    "dest". Raises an error if no files are copied, unless allow_nothing is
    True.

    Args:
        src: source glob (e.g. ``/somewhere/*.txt``)
        dest: destination directory
        allow_nothing: don't raise an exception if no files are found
        allow_nonfiles: copy things that are not files too (as judged by
            :func:`os.path.isfile`).

    Raises:
        ValueError: if no files are found and ``allow_nothing`` is not set
    """
    something = False
    for filename in glob.glob(src):
        if allow_nonfiles or os.path.isfile(filename):
            shutil.copy(filename, dest)
            something = True
    if something or allow_nothing:
        return
    raise ValueError(f"No files found matching: {src}")


def moveglob(src: str, dest: str, allow_nothing: bool = False,
             allow_nonfiles: bool = False) -> None:
    """
    As for :func:`copyglob`, but moves instead.
    """
    something = False
    for filename in glob.glob(src):
        if allow_nonfiles or os.path.isfile(filename):
            shutil.move(filename, dest)
            something = True
    if something or allow_nothing:
        return
    raise ValueError(f"No files found matching: {src}")


def copy_tree_root(src_dir: str, dest_parent: str) -> None:
    """
    Copies a directory ``src_dir`` into the directory ``dest_parent``.
    That is, with a file structure like:

    .. code-block:: none

        /source/thing/a.txt
        /source/thing/b.txt
        /source/thing/somedir/c.txt

    the command

    .. code-block:: python

        copy_tree_root("/source/thing", "/dest")

    ends up creating