import os
import os.path
import shutil
import shlex
import subprocess
import pathlib

from contextlib import contextmanager

from typing import Optional, Sequence, Iterator, Union, List

from .path import AbsolutePath, ensure_abs_path, SPath, RelativePath, cwd
from .zipper import Zipper
from .query import Query


def makeparam(default):
    def func(set=None):
        if set is None:
            return func.state
        else:
            func.state = set

    func.state = default
    return func


debug = makeparam(False)


def find(path: SPath = None, relative=False) -> Query:
    """Create a `Query` to recursively look for path under `path`.

    :param path: (optional, `cwd()`) a `str` or a `Path` target
    :param relative: (optional, False) wether to yield `RelativePath` or `AbsolutePath`
    """

    def walker() -> Iterator[Union[AbsolutePath, RelativePath]]:
        if path is None:
            _path = cwd()
        else:
            _path = ensure_abs_path(path)

        for dir, _, files in os.walk(_path.to_str()):
            if dir.startswith("/"):
                d = AbsolutePath.from_str(dir)
            else:
                d = RelativePath.from_str(dir).at(_path)
            if relative:
                _d = d.relative_to(_path)
                yield _d
                yield from (_d.add(file) for file in files)
            else:
                yield d
                yield from (d.add(file) for file in files)

    return Query(walker())


def ls(path: Optional[SPath] = None, relative: bool = True) -> Query:
    """Create a `Query` on the direct content of `path`

    :param path: (optional, `cwd()`) a `str` or a `Path` target
    :param relative: (optional, True) wether to yield `RelativePath` or `AbsolutePath`
    """

    def walker() -> Iterator[Union[AbsolutePath, RelativePath]]:
        if path is None:
            _path = cwd()
        else:
            _path = ensure_abs_path(path)

        if relative:
            yield from map(RelativePath, os.listdir(_path.to_str()))
        else:
            for name in os.listdir(_path.to_str()):
                yield _path.add(name)

    return Query(walker())


def mv(*args: SPath, force=False):
    """Move a set of source to a single target (like shell `mv`).

    :param *args: Path or str, the last one is the target
    """
    if len(args) == 1:
        raise ValueError("mv needs at least two parameters.")
    else:
        dest, *srcs = reversed(list(map(ensure_abs_path, args)))
        if dest.isdir():
            for src in srcs:
                b = src.base
                fdest = dest.add(b)
                if not force and fdest.exists():
                    raise ValueError("File already exists.")

            for src in srcs:
                b = src.base
                fdest = dest.add(b)
                if debug():
                    print(f"mv {src} {fdest}")
                else:
                    os.replace(src.to_str(), fdest.to_str())
        else:
            if len(srcs) > 1:
                raise ValueError(
                    "mv cannot move more than one source" " to the same dest"
                )
            if not force and dest.exists():
                raise ValueError("File already exists.")
            src = srcs[0]
            if debug():
                print(f"mv {src} {fdest}")
            else:
                os.replace(src.to_str(), dest.to_str())


def cp(*args: SPath, force=False):
    """Copy a set of source to a target (like shell `cp`).

    :param *args: Path or str, the last one is the target
    """
    if len(args) <= 1:
        raise ValueError("cp needs at least two parameters.")
    else:
        dest, *srcs = reversed(list(map(ensure_abs_path, args)))
        if dest.isdir():
            for src in srcs:
                b = src.base
                fdest = dest.add(b)
                if not force and fdest.exists():
                    raise ValueError(f"File already {fdest} exists.")

            for src in srcs:
                b = src.base
                fdest = dest.add(b)
                if debug():
                    print(f"cp -r {src} {fdest}")
                elif src.isdir():
                    shutil.copytree(src.to_str(), fdest.to_str())
                else:
                    shutil.copy(src.to_str(), fdest.to_str())
        else:
            if len(srcs) > 1:
                raise ValueError("cp cannot move more than one source to the same dest")
            if not force and dest.exists():
                raise ValueError("File already exists.")
            src = srcs[0]
            if debug():
                print(f"cp -r {src} {dest}")
            elif src.isdir():
                shutil.copytree(src.to_str(), dest.to_str())
            else:
                shutil.copy(src.to_str(), dest.to_str())


def rm(*args: SPath, recursive=False, force=False):
    if recursive:
        for p in map(ensure_abs_path, args):
            shutil.rmtree(p.to_str(), ignore_errors=force)
    else:
        if force:
            for p in map(ensure_abs_path, args):
                try:
                    if debug():
                        print(f"rm -rf {p}")
                    else:
                        os.remove(p.to_str())
                except Exception:
                    pass
        else:
            for p in map(ensure_abs_path, args):
                if debug():
                    print(f"rm -r {p}")
                else:
                    os.remove(p.to_str())


def mkdir(path: SPath, exist_ok=True):
    """Create a leaf directory and all intermedite ones.

    :param path: Target directory
    :param exist_ok: if False and path already exists, raise a `FileExistsError`
    """
    if debug():
        print(f"mkdir -p {path}")
    else:
        os.makedirs(ensure_abs_path(path).to_str(), exist_ok=exist_ok)


def archive(dest: SPath, *args: SPath, transform=None, format="tar.gz"):
    """Create a new archive containing a set of files."""
    if debug():
        raise NotImplementedError("archive is not yet supported in debug mode.")

    z = Zipper(format)
    if args:
        with z.open(ensure_abs_path(dest)) as ar:
            for f in map(ensure_abs_path, args):
                ar.add(f, transform=transform)
    else:
        return z.open(ensure_abs_path(dest))


def cd(to: Optional[SPath] = None):
    """Change current working directory."""
    if to is None:
        to = AbsolutePath.from_str("~")
    path = ensure_abs_path(to)
    os.chdir(path.to_str())


@contextmanager
def in_dir(d: SPath = None):
    """Context manager to temporarily change current working directory."""
    current = cwd()
    cd(d)
    try:
        yield
    finally:
        cd(current)


def sh(command: Union[str, List[str]], path: Optional[SPath] = None):
    """Run a shell command."""
    if isinstance(command, str):
        cmd = command
    else:
        cmd = " ".join(map(shlex.quote, command))

    if path:
        cwd = ensure_abs_path(path).to_str()
    else:
        cwd = None

    return subprocess.call(cmd, shell=True, cwd=cwd)


def sh_with_stdout(command: Union[str, List[str]], path: Optional[SPath] = None):
    """Run a shell command."""
    if isinstance(command, str):
        cmd = command
    else:
        cmd = " ".join(map(shlex.quote, command))

    if path:
        cwd = ensure_abs_path(path).to_str()
    else:
        cwd = None

    return subprocess.run(
        cmd, shell=True, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE
    ).stdout


def cat(*args, overwrite=False):
    """Similar to cat command.

    `cat(a, b, c, ..., dest)` copy the content of a, b, c, ... into dest.
    Note: As opposed to the command cat, `cat(dest)` create an empty file `dest`.

    :param *args: there should be at least one argument. The last one is always
    the destination file path. The others are the sources.
    :param overwrite: (optional, False) overwrite dest if True.
    """
    dest, *sources = reversed(args)
    sources = reversed(sources)

    if not sources:
        if overwrite:
            p = pathlib.Path(dest)
            p.unlink()
            p.touch()
        return

    if overwrite:
        shutil.copyfile(sources[0], dest)
        sources = sources[1:]

    # TODO use sendfile on posix for faster write
    with open(dest, "ab") as d:
        for src in sources:
            with open(src, "rb") as s:
                d.write(s.read())
