import argparse
import json
import os
import pathlib
import re
import shutil
import textwrap
from copy import deepcopy

import numpy as np
import prettytable
import tqdm
from platformdirs import user_cache_dir

from . import cli
from . import compute_hash
from . import rsync
from . import scp
from . import search
from . import ssh
from . import yaml
from ._version import version
from .external import exec_cmd

if shutil.which("rsync") is not None:
    _copyfunc = rsync.copy
else:
    _copyfunc = scp.copy


def _force_absolute_path(root: pathlib.Path, path: pathlib.Path) -> pathlib.Path:
    """
    Force a path to be absolute.

    :param root: The root directory.
    :param path: The path that may be absolute or relative.
    :return: The absolute ``path``.
    """
    if path.is_absolute():
        return path
    return pathlib.Path(os.path.normpath(root.absolute() / path))


class Location:
    """
    Location information.

    Attributes:

    *   :py:attr:`Location.root`: The root directory.
    *   :py:attr:`Location.ssh` (optional): ``[user@]host``
    *   :py:attr:`Location.prefix` (optional): Prefix to add to all paths.
    *   :py:attr:`Location.python` (optional): The python executable on the ``ssh`` host.
    *   :py:attr:`Location.dump` (optional): Location of "dump" file -- file with list of files.
    *   :py:attr:`Location.search` (optional):
        Commands to search for files, see :py:func:`shelephant.search.search`.
    *   :py:func:`Location.files`: List of files (with properties).

    Initialize:

    *   Read from yaml file::

            location = Location.from_yaml("location.yaml")

    *   Create from scratch::

            location = Location(root="~/data", ...)
    """

    def __init__(
        self,
        root: str | pathlib.Path,
        ssh: str = None,
        mount: pathlib.Path = None,
        prefix: pathlib.Path = None,
        files: list[str] = [],
    ) -> None:
        """
        :param root: The root directory (may be relative, unless on remote SSH host).
        :param ssh: ``[user@]host``.
        :param mount: Mount location for SSH host.
        :param prefix: Prefix to add to all paths.
        :param files: List of files.
        """
        self.root = pathlib.Path(root)
        self._mount = mount is not None
        self._absroot = self.root.absolute() if not mount else mount.absolute()
        self.prefix = pathlib.Path(prefix) if prefix is not None else None
        self.ssh = ssh
        self.python = "python3"
        self.dump = None
        self.search = None

        if ssh is not None:
            assert self.root.is_absolute(), "root must be absolute path when using ssh"

        if type(files) == list:
            self._files = np.array(files)
            self._clear_all_info()
        elif type(files) == str:
            self._files = np.array([files])
            self._clear_all_info()
        elif type(files) == dict:
            self._files = np.array(list(files.keys()))
            self._clear_all_info()
            for i, file in enumerate(self._files):
                self._has_info[i] = "sha256" in files[file]
                if self._has_info[i]:
                    self._sha256[i] = files[file]["sha256"]
                    self._size[i] = files[file]["size"]
                    self._mtime[i] = files[file]["mtime"]
        else:
            raise TypeError(f"Unknown type of files: {type(files)}")

        assert np.unique(self._files).size == self._files.size, "duplicate filenames"

    def _slice(self, keep):
        """
        Slice list of files and info.

        :param keep: Slice.
        """
        self._files = self._files[keep]
        self._has_info = self._has_info[keep]
        self._sha256 = self._sha256[keep]
        self._size = self._size[keep]
        self._mtime = self._mtime[keep]
        return self

    def _clear_all_info(self):
        """
        Clear all info.
        """
        self._has_info = np.zeros(self._files.size, dtype=bool)
        self._sha256 = np.empty(self._files.size, dtype="U64")
        self._size = np.zeros(self._files.size, dtype=np.int64)
        self._mtime = np.empty(self._files.size, dtype=np.float64)
        return self

    def _append(self, files: list[str]):
        """
        Extend list of files.

        .. warning::

            ``files`` is assumed to be unique and not part of ``self._files``.
            There is no check on this.

        :param files: List of files.
        """
        files = np.array(files)
        n = files.size
        if n == 0:
            return self
        self._files = np.hstack((self._files, files))
        self._has_info = np.hstack((self._has_info, np.zeros(n, dtype=self._has_info.dtype)))
        self._sha256 = np.hstack((self._sha256, np.empty(n, dtype=self._sha256.dtype)))
        self._size = np.hstack((self._size, np.empty(n, dtype=self._size.dtype)))
        self._mtime = np.hstack((self._mtime, np.empty(n, dtype=self._mtime.dtype)))
        if self.search is not None:
            return self.sort()
        return self

    def _prune(self, files: list[str]):
        """
        Overwrite database with list of files.
        If files are already in the database, the sha256/size/mtime is kept.
        For new files the sha256/size/mtime is set to None.
        (Files that were in the database but that are not in ``files`` are removed.)

        :param files: List of files (unique, no assertion).
        """
        files = np.array(files)

        if self._files.size == files.size:
            if np.all(np.equal(self._files, files)):
                return self

        _, i, j = np.intersect1d(self._files, files, return_indices=True, assume_unique=True)
        k = np.ones(files.size, dtype=bool)
        k[j] = False
        self._slice(i)
        self._append(files[k])
        return self

    def _overwrite_dataset_from_dict(self, files: list):
        """
        Read files from list of strings or dictionaries (as stored in a YAML file).
        This overwrites the current database (including all sha256/size/mtime), with information
        present in the input.

        :param files: List of files.
        """

        fs = []
        has_info = []
        sha256 = []
        size = []
        mtime = []

        for item in files:
            if type(item) == str:
                fs.append(item)
                has_info.append(False)
                sha256.append("0" * 64)
                size.append(0)
                mtime.append(0)
            else:
                fs.append(item["path"])
                has_info.append(True)
                sha256.append(item["sha256"])
                size.append(item["size"])
                mtime.append(item["mtime"])

        self._files = np.array(fs, dtype=object)
        self._has_info = np.array(has_info, dtype=bool)
        self._sha256 = np.array(sha256, dtype="U64")
        self._size = np.array(size, dtype=np.int64)
        self._mtime = np.array(mtime, dtype=np.float64)
        return self

    def __eq__(self, other):
        """
        Check if all files and information are equal (mtime is allowed to be different).

        :param other: Other location.
        :return: True if equal, False otherwise.
        """
        if self._files.size != other._files.size:
            return False

        a = np.argsort(self._files)
        b = np.argsort(other._files)

        return (
            np.all(np.equal(self._files[a], other._files[b]))
            and np.all(np.equal(self._sha256[a], other._sha256[b]))
            and np.all(np.equal(self._size[a], other._size[b]))
            and np.all(np.equal(self._has_info[a], other._has_info[b]))
        )

    def __iadd__(self, other):
        """
        Add files from other location.

        .. todo::

            In case of duplicate files: check that the checksums and sizes are the same.
        """
        assert self.root == other.root, "root must be equal"
        assert self.ssh == other.ssh, "ssh must be equal"
        self._files = np.hstack((self._files, other._files))
        self._has_info = np.hstack((self._has_info, other._has_info))
        self._sha256 = np.hstack((self._sha256, other._sha256))
        self._size = np.hstack((self._size, other._size))
        self._mtime = np.hstack((self._mtime, other._mtime))
        self.search = None
        self.dump = None
        return self._unique()

    def __add__(self, other):
        """
        Add files from two locations.
        """
        ret = deepcopy(self)
        ret += other
        return ret._unique()

    def copy_files(self, other):
        """
        Copy files (and size/mtime/sha256) from other location.

        :param other: Other location.
        """
        self._files = other._files
        self._has_info = other._has_info
        self._sha256 = other._sha256
        self._size = other._size
        self._mtime = other._mtime
        return self

    @classmethod
    def from_yaml(cls, path: str | pathlib.Path):
        """
        Read from yaml file.

        :param path: Path to yaml file.
        :return: Location.
        """

        path = pathlib.Path(path)
        data = yaml.read(path)
        if type(data) == list:
            data = {"files": data}

        ret = cls(
            root=data.get("root", path.parent),
            ssh=data.get("ssh", None),
            prefix=data.get("prefix", None),
        )
        ret._mount = "mount" in data
        assert not ret._mount or ret.ssh is not None, "ssh must be set when using mount"
        ret._absroot = data.get("mount", _force_absolute_path(path.parent, ret.root))
        ret.dump = data.get("dump", None)
        ret.search = data.get("search", None)
        ret._overwrite_dataset_from_dict(data.get("files", []))
        if ret.search is not None:
            ret.sort()
        return ret

    def to_yaml(self, path: str | pathlib.Path, force: bool = False):
        """
        Write to yaml file.

        :param path: Path to yaml file.
        :param force: Do not prompt to overwrite file.
        """
        yaml.dump(path, self.asdict(), force=force)

    def overwrite_yaml(self, path: str | pathlib.Path):
        """
        Overwrite yaml file.
        This function only changes the file if the content has indeed changed.

        :param path: Path to yaml file.
        """
        yaml.overwrite(path, self.asdict())

    def asdict(self) -> dict:
        """
        Return as dictionary.

        :return: Dictionary.
        """

        ret = {"root": str(self.root)}

        if self.ssh is not None:
            ret["ssh"] = self.ssh

        if self.dump is not None:
            ret["dump"] = str(self.dump)

        if self.search is not None:
            ret["search"] = self.search

        if self.prefix is not None:
            ret["prefix"] = str(self.prefix)

        if len(self._files) > 0:
            ret["files"] = self.files(info=True)

        return ret

    def files(self, info: bool = True) -> list:
        """
        Return as list of files.
        Items without sha256 and size are returned as ``str``,
        items with sha256 and size are returned as ``dict``.

        :param info: Return sha256 and size (if available).
        :return: List of files.
        """

        if not info:
            return self._files.tolist()

        if not np.any(self._has_info):
            return self._files.tolist()

        ret = []

        for i, file in enumerate(self._files):
            if self._has_info[i]:
                ret += [
                    {
                        "path": str(file),
                        "sha256": str(self._sha256[i]),
                        "size": int(self._size[i]),
                        "mtime": float(self._mtime[i]),
                    }
                ]
            else:
                ret += [str(file)]

        return ret

    @property
    def hostpath(self) -> str:
        """
        Return:

        -   ``root`` if ``ssh`` is not set.
        -   ``ssh:"root"`` if ``ssh`` is set.
        """
        if self.ssh is None:
            return str(self._absroot)

        return f'{self.ssh:s}:"{str(self.root):s}"'

    def sort(self, key: str = "files"):
        """
        Sort files.

        :param key: Key to sort by (files, size, sha256).
        """

        if key == "files":
            return self._slice(np.argsort(self._files))
        if key == "size":
            return self._slice(np.argsort(self._size))
        if key == "mtime":
            return self._slice(np.argsort(self._mtime))
        if key == "sha256":
            return self._slice(np.argsort(self._sha256))

        raise ValueError(f"Unknown key: {key}")

    def _unique(self):
        """
        Remove duplicate filename.

        .. todo::

            Check that the checksums and sizes are the same.

        .. todo::

            Keep sorted?
        """
        _, idx = np.unique(self._files, return_index=True)
        return self._slice(idx)

    def isavailable(self, mount: bool = False) -> bool:
        """
        Check if location is available.

        :param mount: Check if mount is available.
        :return: True if available.
        """
        if self.ssh is None or mount:
            return self._absroot.is_dir()
        return ssh.is_dir(self.ssh, self.root)

    def remove(self, paths: list[str]):
        """
        Remove files from list of files.

        :param paths: List of paths to remove.
        """

        _, i, _ = np.intersect1d(
            self._files, np.unique(list(map(str, paths))), return_indices=True, assume_unique=True
        )
        k = np.ones(self._files.size, dtype=bool)
        k[i] = False
        return self._slice(k)

    def read(self, verbose: bool = False):
        """
        Read files from location.

        -   If ``dump`` is set, read from dump file.
            This overwrites the database
            (sha256/size/mtime will only be available if they are in the YAML file).

        -   If ``search`` is set, search for files.
            This preserves sha256/size/mtime if paths are already in the database
            (there is no check that they are still accurate).

        :param verbose: Print progress (only relevant if ``ssh`` is set).
        """
        if self.dump is None and self.search is None:
            return self

        assert not (self.dump is not None and self.search is not None)

        # overwrite dataset with content of YAML dump file, and quit
        if self.dump is not None:
            if self.ssh is None:
                return self._overwrite_dataset_from_dict(yaml.read(self._absroot / self.dump))

            with search.tempdir():
                scp.copy(self.hostpath, ".", [self.dump], progress=False)
                return self._overwrite_dataset_from_dict(yaml.read(self.dump))

        # search locally for files (the sha256/size/mtime of 'new' files is set to None)
        if self.ssh is None:
            return self._prune(
                sorted(list(map(str, search.search(*self.search, root=self._absroot))))
            )

        # search on SSH remote host for files (the sha256/size/mtime of 'new' files is set to None)
        cache_dir = ssh._shelephant_cachdir(self.ssh, self.python)
        with ssh._cachedir(self.ssh, cache_dir) as remote, search.tempdir():
            shutil.copy(pathlib.Path(__file__).parent / "search.py", "script.py")
            with open("settings.json", "w") as f:
                json.dump(self.search, f)

            host = f'{self.ssh:s}:"{str(remote):s}"'
            _copyfunc(".", host, ["script.py", "settings.json"], progress=False, verbose=verbose)
            exec_cmd(
                f'ssh {self.ssh:s} "cd {str(remote)} && {self.python} script.py {str(self.root)}"',
                verbose=verbose,
            )
            _copyfunc(host, ".", ["files.txt"], progress=False, verbose=verbose)
            return self._prune(sorted(pathlib.Path("files.txt").read_text().splitlines()))

    def has_info(self) -> bool:
        """
        Check if sha256/size/mtime is available for all files.

        :return: True if sha256/size/mtime is available for all files.
        """
        return np.all(self._has_info)

    def _getindex(self, paths: list[str]) -> np.array:
        """
        Get index of paths in dataset.

        :param paths: List of paths.
        :return: ``paths`` (sorted) and their indices in ``self._files``.
        """
        paths = np.sort(paths)
        sorter = np.argsort(self._files)
        index = sorter[np.searchsorted(self._files[sorter], paths)]
        assert np.all(self._files[index] == paths), "not all paths are in the dataset"
        return paths, index

    def _get_info(self, paths: list[pathlib.Path], sha256: bool, progress: bool, verbose: bool):
        """
        Get mtime/size/sha256 of a list of files.

        :param paths: List of paths to check.
        :param progress: Show progress bar (only relevant if ``ssh`` is not set).
        :param verbose: Show verbose output (only relevant if ``ssh`` is set).
        :return: size, mtime, sha256
        """

        if self.ssh is None:
            files = [self._absroot / f for f in paths]
            size, mtime, csum = compute_hash.compute_sha256(files, sha256=sha256, progress=progress)
            return (
                np.array(size, dtype=np.int64),
                np.array(mtime, dtype=np.float64),
                np.array(csum, dtype="U64"),
            )

        cache_dir = ssh._shelephant_cachdir(self.ssh, self.python)
        with ssh._cachedir(self.ssh, cache_dir) as remote, search.tempdir():
            files = [str(self.root / i) for i in paths]
            pathlib.Path("files.txt").write_text("\n".join(files))
            pathlib.Path("sha256.txt").write_text("")
            shutil.copy(pathlib.Path(__file__).parent / "compute_hash.py", "script.py")

            extra = ["sha256.txt"] if sha256 else []
            hostpath = f'{self.ssh:s}:"{str(remote):s}"'
            _copyfunc(
                ".", hostpath, extra + ["script.py", "files.txt"], progress=False, verbose=verbose
            )
            exec_cmd(
                f'ssh {self.ssh:s} "cd {str(remote)} && {self.python} script.py"', verbose=verbose
            )
            _copyfunc(
                hostpath, ".", extra + ["size.txt", "mtime.txt"], progress=False, verbose=verbose
            )
            size = np.array(
                list(map(int, pathlib.Path("size.txt").read_text().splitlines())),
                dtype=np.int64,
            )
            mtime = np.array(
                list(map(float, pathlib.Path("mtime.txt").read_text().splitlines())),
                dtype=np.float64,
            )
            if sha256:
                csum = np.array(pathlib.Path("sha256.txt").read_text().splitlines(), dtype="U64")
            else:
                csum = []

        return size, mtime, csum

    def check_changes(
        self, paths: list[pathlib.Path] = None, progress: bool = False, verbose: bool = False
    ):
        """
        Remove sha256 from all files of which the size/mtime has changed.

        :param paths:
            List of paths to check.
            Paths to be relative to the root of the dataset.
            Default: all files in the database.

        :param progress: Show progress bar (only relevant if ``ssh`` is not set).
        :param verbose: Show verbose output (only relevant if ``ssh`` is set).
        """
        if paths is None:
            size, mtime, _ = self._get_info(self._files, False, progress, verbose)
            rm = np.logical_or(self._size != size, self._mtime != mtime)
            self._has_info[rm] = False
            self._size[~self._has_info] = size[~self._has_info]
            self._mtime[~self._has_info] = mtime[~self._has_info]
            return self

        paths, index = self._getindex(paths)
        size, mtime, _ = self._get_info(paths, False, progress, verbose)

        changed = np.logical_or(self._size[index] != size, self._mtime[index] != mtime)
        self._has_info[index[changed]] = False
        keep = ~self._has_info[index]
        self._size[index[keep]] = size[keep]
        self._mtime[index[keep]] = mtime[keep]

        removed = mtime < 0
        if np.any(removed):
            return self.remove(paths[removed])

        return self

    def remove_info(self, paths: list[pathlib.Path] = None):
        """
        Remove sha256/size/mtime for a list of files.

        :param paths: List of paths to remove.
        """
        paths, index = self._getindex(paths)
        self._has_info[index] = False

    def getinfo(
        self,
        paths: list[pathlib.Path] = None,
        max_size: int = None,
        progress: bool = False,
        verbose: bool = False,
    ):
        """
        Compute sha256/size/mtime of all files for which this information is not available.

        To compute the sha256/size/mtime only on a fraction of files set ``max_size``.
        This will stop the computation when the total size exceeds ``max_size``.
        You can then call this function recursively (with ``clean=False``) to flush you buffer.

        :param paths: List of paths to check. Have to be relative to the root of the dataset.
        :param max_size: Compute the sha256/size/mtime until the total size exceeds ``max_size``.
        :param progress: Show progress bar (only relevant if ``ssh`` is not set).
        :param verbose: Show verbose output (only relevant if ``ssh`` is set).
        """

        if paths is None:
            paths = self._files
            index = np.arange(self._files.size)
        else:
            paths, index = self._getindex(paths)

        index = index[np.argwhere(~self._has_info[index]).flatten()]
        index = index[np.argsort(self._size[index])]
        files = self._files[index]
        size = self._size[index]

        if index.size == 0:
            return self

        if max_size is not None:
            cum_size = np.cumsum(size)
            i = np.argmax(cum_size > max_size)
            if i > 0:
                index = index[:i]
                files = files[:i]

        size, mtime, csum = self._get_info(files, True, progress, verbose)
        self._has_info[index] = True
        self._sha256[index] = csum
        self._size[index] = size
        self._mtime[index] = mtime

        removed = mtime < 0
        if np.any(removed):
            return self.remove(paths[removed])

        return self

    def diff(self, other) -> dict:
        """
        Compare the database entries of two locations.

        .. warning::

            The information is taken from the database.
            There are no checks that that information if up to date.

        :param other: Other location.
        :return:
            Dictionary with differences::

                {
                    "==" : [ ... ], # in a and b, equal sha256
                    "?=" : [ ... ], # in a and b, unknown sha256
                    "!=" : [ ... ], # in a and b, different sha256
                    "->" : [ ... ], # in a not in b
                    "<-" : [ ... ], # in b not in a
                }
        """

        inboth = [str(file) for file in np.intersect1d(self._files, other._files)]
        ret = {
            "->": [str(file) for file in np.setdiff1d(self._files, other._files)],
            "<-": [str(file) for file in np.setdiff1d(other._files, self._files)],
            "==": [],
            "?=": [],
            "!=": [],
        }

        index_self = {file: i for i, file in enumerate(self._files)}
        index_other = {file: i for i, file in enumerate(other._files)}

        for file in inboth:
            ia = index_self[file]
            ib = index_other[file]

            if self._has_info[ia] and other._has_info[ib]:
                if self._sha256[ia] == other._sha256[ib]:
                    ret["=="].append(file)
                else:
                    ret["!="].append(file)
            else:
                ret["?="].append(file)

        return ret


# https://stackoverflow.com/a/68994012/2646505
def _search_upwards_dir(dirname: str) -> pathlib.Path:
    """
    Search in the current directory and all directories above it for a directory
    with a particular name.

    :param dirname: The name of the directory to look for.
    :return: The location of the first directory found or None, if none was found.
    """
    d = pathlib.Path.cwd()
    root = pathlib.Path(d.root)

    while d != root:
        attempt = d / dirname
        if attempt.is_dir():
            return attempt
        d = d.parent

    return None


def _init_parser():
    """
    Return parser for :py:func:`shelephant init`.
    """

    desc = textwrap.dedent(
        """
        Initialize a shelephant database by creating a directory ``.shelephant``
        with an 'empty' database. Use ``shelephant add`` to add storage locations.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)
    parser.add_argument("--version", action="version", version=version)
    return parser


def init(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _init_parser()
    args = parser.parse_args(args)
    sdir = pathlib.Path(".shelephant")
    assert not sdir.is_dir(), '".shelephant" directory already exists'
    sdir.mkdir()
    (sdir / "data").mkdir()
    (sdir / "storage").mkdir()
    (sdir / "unavailable").symlink_to("dead-link")
    (sdir / "symlinks.yaml").write_text("")
    yaml.dump(sdir / "storage.yaml", ["here"])
    yaml.dump(sdir / "storage" / "here.yaml", {"root": "../.."})


def _lock_parser():
    """
    Return parser for :py:func:`shelephant lock`.
    """

    desc = textwrap.dedent(
        """
        Lock as storage location.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)
    parser.add_argument("name", type=str, help="Name of the storage location.")
    parser.add_argument("--version", action="version", version=version)
    return parser


def lock(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _lock_parser()
    args = parser.parse_args(args)
    sdir = pathlib.Path(".shelephant")
    assert args.name in yaml.read(sdir / "storage.yaml"), f"storage location '{args.name}' unknown"
    (sdir / "lock.txt").write_text(args.name)


def _add_parser():
    """
    Return parser for :py:func:`shelephant add`.
    """

    desc = textwrap.dedent(
        """
        Add a storage location to the database.
        The database in ``.shelephant`` is updated as follows:

        -   The ``name`` is added to ``.shelephant/storage.yaml``.

        -   A file ``.shelephant/storage/<name>.yaml`` is created with the search settings
            and the present state of the storage location.

        -   A symlink ``.shelephant/data/<name>`` is created to the storage location.
            (if ``--ssh`` is given, the symlink points to a dead link).

        .. note::

            A special case is

            .. code-block:: bash

                shelephant add here --rglob '*.h5'

            which helps to investigate your database directory.
            Note that ``here`` is a reserved name and that you should not specify the root.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("name", type=str, help="Name of the storage location.")
    parser.add_argument("root", type=pathlib.Path, nargs="?", help="Path to the storage location.")
    parser.add_argument("--ssh", type=str, help="SSH host (e.g. user@host).")
    parser.add_argument("--mount", type=pathlib.Path, help="Optional mount location for SSH host.")
    parser.add_argument("--prefix", type=pathlib.Path, help="Add prefix to all files.")
    parser.add_argument("--rglob", type=str, help="Search pattern for ``Path(root).rglob(...)``.")
    parser.add_argument("--glob", type=str, help="Search pattern for ``Path(root).glob(...)``.")
    parser.add_argument("--exec", type=str, help="Command to run from ``root``.")
    parser.add_argument("--skip", type=str, action="append", help="Pattern to skip (Python regex).")
    parser.add_argument("--shallow", action="store_true", help="Do not compute checksums.")
    parser.add_argument("-q", "--quiet", action="store_true", help="Do not print progress.")
    parser.add_argument("--version", action="version", version=version)
    return parser


def add(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _add_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "cannot remove location from storage location"
    assert args.name != "all", "all is a reserved name"
    assert args.name != "any", "any is a reserved name"

    if args.name == "here":
        assert args.root is None, "root is not allowed"
        args.root = pathlib.Path("../..")
    else:
        assert args.root is not None, "root is required"
        storage = yaml.read(sdir / "storage.yaml")
        assert args.name not in storage, f"storage location '{args.name}' already exists"
        if not args.root.is_absolute() and not args.ssh:
            args.root = pathlib.Path(os.path.relpath(args.root.absolute(), sdir / "storage"))
        if args.mount is not None:
            if not args.mount.is_absolute():
                args.mount = pathlib.Path(os.path.relpath(args.mount.absolute(), sdir / "storage"))

    with search.cwd(sdir):
        loc = Location(root=args.root, ssh=args.ssh, mount=args.mount, prefix=args.prefix)
        s = []
        d = {}
        if args.skip is not None:
            d["skip"] = args.skip
        if args.rglob:
            s.append({"rglob": args.rglob, **d})
        if args.glob:
            s.append({"glob": args.glob, **d})
        if args.exec:
            s.append({"exec": args.exec, **d})
        if len(s) > 0:
            loc.search = s

        loc.overwrite_yaml(f"storage/{args.name}.yaml")

        if args.name != "here":
            yaml.overwrite("storage.yaml", storage + [args.name])

            if args.root.is_absolute() and not args.ssh:
                pathlib.Path(f"data/{args.name}").symlink_to(args.root)
            elif args.ssh is not None and args.mount is not None:
                pathlib.Path(f"data/{args.name}").symlink_to(args.mount)
            elif not args.ssh:
                pathlib.Path(f"data/{args.name}").symlink_to(args.root)
            else:
                pathlib.Path(f"data/{args.name}").symlink_to(pathlib.Path("..") / "unavailable")

        opts = [args.name]
        if args.shallow:
            opts.append("--shallow")
        if args.quiet:
            opts.append("--quiet")
        update(opts)


def _remove_parser():
    """
    Return parser for :py:func:`shelephant rm`.
    """

    desc = textwrap.dedent(
        """
        Remove a storage location to the database.
        The database in ``.shelephant`` is updated as follows:

        -   The ``name`` is removed from ``.shelephant/storage.yaml``.
        -   ``.shelephant/storage/<name>.yaml`` is removed.
        -   The symlink ``.shelephant/data/<name>`` is removed.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("name", type=str, help="Name of the storage location.")
    parser.add_argument("--version", action="version", version=version)
    return parser


def remove(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _remove_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "cannot remove location from storage location"

    storage = yaml.read(sdir / "storage.yaml")
    assert args.name in storage, f"storage location '{args.name}' does not exist"
    storage.remove(args.name)
    yaml.overwrite(sdir / "storage.yaml", storage)
    os.remove(sdir / "storage" / f"{args.name}.yaml")
    (sdir / "data" / args.name).unlink()
    update([])


def _update_parser():
    """
    Return parser for :py:func:`shelephant update`.
    """

    desc = textwrap.dedent(
        """
        Update the database.
        This function always update the symbolic links, and optionally updates the available
        files and checksums of (a) )storage location(s).
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--shallow", action="store_true", help="Do not compute checksums.")
    parser.add_argument("--verbose", action="store_true", help="Verbose commands.")
    parser.add_argument(
        "--chunk",
        type=lambda x: int(float(x)),
        default=1e10,
        help="Chunk size for computing checksums (bytes).",
    )
    parser.add_argument("--force", action="store_true", help="Force update of path(s).")
    parser.add_argument("-q", "--quiet", action="store_true", help="Do not print progress.")
    parser.add_argument("name", type=str, nargs="?", help="Update storage location(s).")
    parser.add_argument(
        "path", type=pathlib.Path, nargs="*", help="Update only specific paths on location."
    )
    return parser


def update(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _update_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    base = sdir.parent
    paths = [os.path.relpath(path, base) for path in args.path]
    paths = np.unique(paths) if len(paths) > 0 else None
    lock = None if not (sdir / "lock.txt").exists() else (sdir / "lock.txt").read_text().strip()
    if args.force:
        assert paths is not None, "--force can only be used with path(s)"

    if args.name is None:
        args.name = []
    elif args.name.lower() == "all":
        assert lock is None, "cannot update all locations from storage location"
        args.name = yaml.read(sdir / "storage.yaml")
    else:
        assert lock is None, "cannot update all locations from storage location"
        assert args.name in yaml.read(sdir / "storage.yaml"), f"'{args.name}' is not a location"
        assert args.name != "here" or paths is None, "cannot specify paths for 'here'"
        args.name = [args.name]

    if lock is not None:
        args.name = [lock]

    with search.cwd(sdir):
        symlinks = yaml.read("symlinks.yaml", [])
        symlinks = {pathlib.Path(i["path"]): pathlib.Path(i["storage"]) for i in symlinks}

        # update files and info

        for name in args.name:
            # "here": search for files that are not managed by shelephant
            if name == "here" and paths is None:
                f = "storage/here.yaml"
                Location.from_yaml(f).read().remove(list(symlinks.keys())).to_yaml(f, force=True)
                continue

            # other locations: search for files (or add files by hand)
            loc = Location.from_yaml(f"storage/{name}.yaml")
            if lock is not None:
                loc.root = pathlib.Path("..")
                loc._absroot = loc.root.absolute()
                loc.ssh = None
                loc.mount = False
            if not loc.isavailable():
                continue
            if paths is None:
                loc.read(verbose=args.verbose)
            else:
                _, i, _ = np.intersect1d(paths, loc._files, return_indices=True, assume_unique=True)
                k = np.ones(paths.size, dtype=bool)
                k[i] = False
                loc._append(paths[k])

            if lock is not None:
                f = f"storage/{name}.yaml"
                Location.from_yaml(f).copy_files(loc).overwrite_yaml(f)
            else:
                loc.overwrite_yaml(f"storage/{name}.yaml")

            if args.shallow:
                continue

            # compute sha256/size/mtime of files that changed since the last update
            n = loc._files.size
            if args.force:
                loc.remove_info(paths=paths)
            else:
                loc.check_changes(paths=paths, verbose=args.verbose)

            if loc.has_info():
                if loc._files.size < n:
                    if lock is not None:
                        f = f"storage/{name}.yaml"
                        Location.from_yaml(f).copy_files(loc).overwrite_yaml(f)
                    else:
                        loc.overwrite_yaml(f"storage/{name}.yaml")
                continue

            off = np.sum(loc._size[loc._has_info])
            pbar = tqdm.tqdm(
                total=np.sum(loc._size) - off, disable=args.quiet, unit="B", unit_scale=True
            )
            while not loc.has_info():
                loc.getinfo(
                    paths=paths,
                    max_size=args.chunk,
                    progress=not args.quiet,
                    verbose=args.verbose,
                )
                if lock is not None:
                    f = f"storage/{name}.yaml"
                    Location.from_yaml(f).copy_files(loc).overwrite_yaml(f)
                else:
                    loc.overwrite_yaml(f"storage/{name}.yaml")
                pbar.n = np.sum(loc._size[loc._has_info]) - off
                pbar.refresh()

        # update symlinks
        if lock is not None:
            return

        storage = yaml.read(sdir / "storage.yaml")
        storage.remove("here")
        files = {}
        # - link to first available
        for name in storage[::-1]:
            loc = Location.from_yaml(pathlib.Path("storage") / f"{name}.yaml")
            prefix = loc.prefix if loc.prefix is not None else pathlib.Path(".")
            if loc.isavailable(mount=True):
                for f in loc.files(info=False):
                    if (loc._absroot / f).is_file():
                        files[prefix / pathlib.Path(f)] = pathlib.Path("data") / name
        # - unlinked files: link to first unavailable
        for name in storage[::-1]:
            loc = Location.from_yaml(pathlib.Path("storage") / f"{name}.yaml")
            prefix = loc.prefix if loc.prefix is not None else pathlib.Path(".")
            if not loc.isavailable(mount=True):
                for f in loc.files(info=False):
                    if prefix / pathlib.Path(f) not in files:
                        files[prefix / pathlib.Path(f)] = pathlib.Path("data") / name

        add_links = []
        rm_links = []

        for symlink in symlinks:
            if symlink not in files:
                rm_links.append(symlink)
            elif files[symlink] != symlinks[symlink]:
                add_links.append(symlink)
                rm_links.append(symlink)

        for symlink in files:
            if symlink not in symlinks:
                add_links.append(symlink)

        with search.cwd(sdir / ".."):
            for f in rm_links:
                if f.is_symlink():
                    f.unlink()

            unmanage = []
            for f in add_links:
                if f.is_file():
                    unmanage.append(f)
            for f in unmanage:
                add_links.pop(f)
                files.pop(f)

            if len(unmanage) > 0:
                print("Local files conflicting with dataset. No links are created for these files:")
                print("\n".join(map(str, unmanage)))

            # remove directories that are empty after removing old links
            for d in {i.parent for i in rm_links}:
                if not os.listdir(d):
                    d.rmdir()

            store = [{"path": str(i), "storage": str(files[i])} for i in sorted(files.keys())]
            yaml.overwrite(".shelephant/symlinks.yaml", store)

            for f in add_links:
                f.parent.mkdir(parents=True, exist_ok=True)
                s = pathlib.Path(os.path.relpath(".shelephant", f.parent)) / files[f] / f
                f.symlink_to(s)


def _cp_parser():
    """
    Return parser for :py:func:`shelephant cp`.
    """

    desc = textwrap.dedent(
        """
        Copy files between storage locations.

        .. note::

            The copied files are added to the database of the destination.
            There is no check that this fits ``dump`` and ``search`` settings.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--colors", type=str, default="dark", help="Color scheme [none, dark].")
    parser.add_argument("-f", "--force", action="store_true", help="Overwrite without prompt.")
    parser.add_argument("-q", "--quiet", action="store_true", help="Do not print progress.")
    parser.add_argument("-n", "--dry-run", action="store_true", help="Print copy-plan and exit.")
    parser.add_argument("source", type=str, help="name of the source.")
    parser.add_argument("destination", type=str, help="name of the destination.")
    parser.add_argument("path", type=pathlib.Path, nargs="+", help="path(s) to copy.")
    return parser


def cp(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _cp_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "cannot remove location from storage location"
    assert args.destination != "here", "Cannot copy to here."
    storage = yaml.read(sdir / "storage.yaml")
    assert args.source in storage, f"Unknown storage location {args.source}"
    assert args.destination in storage, f"Unknown storage location {args.destination}"
    base = sdir.parent
    paths = [os.path.relpath(path, base) for path in args.path]

    with search.cwd(sdir):
        opts = [f"storage/{args.source}.yaml", f"storage/{args.destination}.yaml"]
        opts += ["--colors", args.colors]
        opts += ["--force"] if args.force else []
        opts += ["--quiet"] if args.quiet else []
        opts += ["--dry-run"] if args.dry_run else []
        changed = cli.shelephant_cp(opts, paths)

    if not args.dry_run and len(changed) > 0:
        _, j, _ = np.intersect1d(paths, changed, return_indices=True, assume_unique=True)
        changed = np.array(args.path)[j]
        update(["--quiet", "--force", args.destination] + list(map(str, changed)))


def _mv_parser():
    """
    Return parser for :py:func:`shelephant mv`.
    """

    desc = textwrap.dedent(
        """
        Move files from one storage location to another.

        .. note::

            The copied files are added to the database of the destination.
            There is no check that this fits ``dump`` and ``search`` settings.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--colors", type=str, default="dark", help="Color scheme [none, dark].")
    parser.add_argument("-f", "--force", action="store_true", help="Overwrite without prompt.")
    parser.add_argument("-q", "--quiet", action="store_true", help="Do not print progress.")
    parser.add_argument("-n", "--dry-run", action="store_true", help="Print copy-plan and exit.")
    parser.add_argument("source", type=str, help="name of the source.")
    parser.add_argument("destination", type=str, help="name of the destination.")
    parser.add_argument("path", type=pathlib.Path, nargs="+", help="path(s) to copy.")
    return parser


def mv(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _mv_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "cannot remove location from storage location"
    assert args.destination != "here", "Cannot copy to here."
    storage = yaml.read(sdir / "storage.yaml")
    assert args.source in storage, f"Unknown storage location {args.source}"
    assert args.destination in storage, f"Unknown storage location {args.destination}"
    base = sdir.parent
    paths = [os.path.relpath(path, base) for path in args.path]

    with search.cwd(sdir):
        dest = Location.from_yaml(f"storage/{args.destination}.yaml")
        assert dest.ssh is None, "Cannot move to remote location."
        opts = [f"storage/{args.source}.yaml", str(dest._absroot)]
        opts += ["--colors", args.colors]
        opts += ["--force"] if args.force else []
        opts += ["--quiet"] if args.quiet else []
        opts += ["--dry-run"] if args.dry_run else []
        cli.shelephant_mv(opts, paths)

    if not args.dry_run:
        with search.cwd(sdir):
            f = f"storage/{args.source}.yaml"
            Location.from_yaml(f).remove(paths).overwrite_yaml(f)
        update(["--quiet", "--force", args.destination] + list(map(str, args.path)))


def _rm_parser():
    """
    Return parser for :py:func:`shelephant rm`.
    """

    desc = textwrap.dedent(
        """
        Remove files from a storage location.

        .. warning::

            This remove the actual data (and the link, if there is no alternative source left).
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("-f", "--force", action="store_true", help="Overwrite without prompt.")
    parser.add_argument("-q", "--quiet", action="store_true", help="Do not print progress.")
    parser.add_argument("-n", "--dry-run", action="store_true", help="Print copy-plan and exit.")
    parser.add_argument("source", type=str, help="name of the source.")
    parser.add_argument("path", type=pathlib.Path, nargs="+", help="path(s) to remove.")
    return parser


def rm(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _rm_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "cannot remove location from storage location"
    storage = yaml.read(sdir / "storage.yaml")
    assert args.source in storage, f"Unknown storage location {args.source}"
    base = sdir.parent
    paths = [os.path.relpath(path, base) for path in args.path]

    with search.cwd(sdir):
        opts = [f"storage/{args.source}.yaml"]
        opts += ["--force"] if args.force else []
        opts += ["--quiet"] if args.quiet else []
        opts += ["--dry-run"] if args.dry_run else []
        cli.shelephant_rm(opts, paths)

    if not args.dry_run:
        with search.cwd(sdir):
            f = f"storage/{args.source}.yaml"
            Location.from_yaml(f).remove(paths).overwrite_yaml(f)
            update([])


def _pwd_parser():
    """
    Return parser for :py:func:`shelephant pwd`.
    """

    desc = textwrap.dedent(
        """
        Change the current working directory to a storage location.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--base", action="store_true", help="Print the base directory.")
    parser.add_argument("--abspath", action="store_true", help="Print absolute path.")
    parser.add_argument("source", type=str, help="name of the source.")
    return parser


def pwd(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _pwd_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    assert not (sdir / "lock.txt").exists(), "not available from storage location"
    storage = yaml.read(sdir / "storage.yaml")
    assert args.source in storage, f"Unknown storage location {args.source}"

    cwd = pathlib.Path.cwd()
    post = os.path.relpath(cwd, sdir / "..")

    with search.cwd(sdir):
        f = f"storage/{args.source}.yaml"
        loc = Location.from_yaml(f)
        prefix = loc.prefix
        root = sdir / "storage" / loc.root

    if prefix is not None:
        common = os.path.commonprefix([prefix, post])
        post = post[len(common) :]

    if args.base:
        post = ""

    if args.abspath:
        print(os.path.normpath(root / post))
    else:
        print(os.path.relpath(root / post, os.getcwd()))


def _status_parser():
    """
    Return parser for :py:func:`shelephant status`.
    """

    desc = textwrap.dedent(
        """
        Status of the storage locations.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--min-copies", type=int, help="Show files with minimal number of copies.")
    parser.add_argument("--copies", type=int, help="Show files with specific number of copies.")
    parser.add_argument("--ne", action="store_true", help="Show files with unequal copies.")
    parser.add_argument("--na", action="store_true", help="Show files unavailable somewhere.")
    parser.add_argument("--unknown", action="store_true", help="Show files with unknown sha256.")
    parser.add_argument("--table", type=str, default="SINGLE_BORDER", help="Select print style.")
    parser.add_argument("--in-use", type=str, help="Select storage location in use.")
    parser.add_argument("--output", type=pathlib.Path, help="Dump to YAML file.")
    parser.add_argument("--copy", type=str, nargs=2, help="Copy file selection.")
    parser.add_argument(
        "-b",
        "--relative-to-base",
        action="store_true",
        help="Show path relative to base directory of dataset.",
    )
    parser.add_argument(
        "path",
        type=str,
        nargs="*",
        help="Filter to paths (either one directory, or multiple files).",
    )
    return parser


def status(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _status_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    base = sdir.parent
    cwd = os.path.relpath(pathlib.Path.cwd(), base)
    paths = [os.path.relpath(path, base) for path in args.path]

    if args.output is not None:
        raise NotImplementedError

    if args.copy is not None:
        raise NotImplementedError

    with search.cwd(sdir):
        symlinks = np.sort([i["path"] for i in yaml.read("symlinks.yaml", [])])
        storage = yaml.read(sdir / "storage.yaml")
        storage.remove("here")
        extra = Location.from_yaml("storage/here.yaml").files(info=False)

        sha = "x" * np.ones((len(symlinks), len(storage)), dtype=object)
        mtime = np.inf * np.ones(sha.shape, dtype=np.float64)
        inuse = "----" * np.ones((len(symlinks)), dtype=object)

        for iname, name in enumerate(storage[::-1]):
            loc = Location.from_yaml(pathlib.Path("storage") / f"{name}.yaml")
            if loc.prefix is not None:
                files = np.array([str(loc.prefix / f) for f in loc._files])
            else:
                files = loc._files
            sorter = np.argsort(files)
            idx = np.searchsorted(symlinks, files[sorter])
            s = np.array(loc._sha256)[sorter]
            m = np.array(loc._mtime)[sorter]
            h = ~loc._has_info[sorter]
            if np.any(h):
                s[h] = "?"
                m[h] = np.inf
            sha[idx, -1 - iname] = s
            mtime[idx, -1 - iname] = m
            if loc.isavailable(mount=True):
                inuse[idx] = name

    for i in range(sha.shape[0]):
        _sha = sha[i, :]
        unique = np.unique(_sha)
        info = np.logical_and(unique != "x", unique != "?")

        if np.sum(info) == 0:
            continue

        if np.sum(info) == 1:
            _sha[_sha == unique[info][0]] = "=="
            sha[i, :] = _sha
            continue

        info = np.logical_and(_sha != "x", _sha != "?")
        _mtime = mtime[i, :]
        _mtime[~info] = np.inf
        sorter = np.array(list(map(str, 1 + np.argsort(_mtime))), dtype=object)
        _sha[info] = sorter[info]
        sha[i, :] = _sha

    data = np.hstack((np.array([symlinks]).T, inuse.reshape(-1, 1), sha))

    e = "x" * np.ones((len(extra), data.shape[1]), dtype=object)
    e[:, 0] = extra
    e[:, 1] = "here"
    data = np.vstack((data, e))

    if args.min_copies is not None:
        data = data[np.sum(data[:, 2:] == "==", axis=1) >= args.min_copies]
    if args.copies is not None:
        data = data[np.sum(data[:, 2:] == "==", axis=1) == args.copies]
    if args.ne:
        data = data[np.sum(data[:, 2:] == "1", axis=1) > 0]
    if args.na:
        data = data[np.sum(data[:, 2:] == "x", axis=1) > 0]
    if args.unknown:
        data = data[np.sum(data[:, 2:] == "?=", axis=1) > 0]
    if args.in_use is not None:
        data = data[data[:, 1] == args.in_use]

    if len(paths) > 0:
        keep = np.zeros(data.shape[0], dtype=bool)
        for path in paths:
            keep = np.logical_or(
                keep, [os.path.commonpath([str(i), path]) == path for i in data[:, 0]]
            )
        data = data[keep]

    if not args.relative_to_base:
        data[:, 0] = [os.path.relpath(i, cwd) for i in data[:, 0]]

    out = prettytable.PrettyTable()
    if args.table == "PLAIN_COLUMNS":
        out.set_style(prettytable.PLAIN_COLUMNS)
    elif args.table == "SINGLE_BORDER":
        out.set_style(prettytable.SINGLE_BORDER)

    out.field_names = ["path", "in use"] + storage

    out.align["path"] = "l"
    out.align["in use"] = "l"
    for name in storage:
        out.align[name] = "c"

    for row in data:
        out.add_row(row)

    print(out.get_string())


def _info_parser():
    """
    Return parser for :py:func:`shelephant info`.
    """

    desc = textwrap.dedent(
        """
        Show global information about dataset.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    parser.add_argument("--cachedir", action="store_true", help="Print cachedir.")
    parser.add_argument(
        "--basedir", action="store_true", help="Print basedir (that contain '.shelephant')."
    )
    return parser


def info(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _info_parser()
    args = parser.parse_args(args)

    if args.cachedir:
        print(user_cache_dir("shelephant", "tdegeus"))
        return

    if args.basedir:
        print(_search_upwards_dir(".shelephant").parent)
        return


def _find_matching(
    text: str,
    opening: str,
    closing: str,
) -> dict:
    r"""
    Find matching 'brackets'.

    :param text: The string to consider.
    :param opening: The opening bracket (e.g. "(", "[", "{").
    :param closing: The closing bracket (e.g. ")", "]", "}").
    :return: Dictionary with ``{index_opening: index_closing}``
    """

    opening = [i.span()[0] for i in re.finditer(opening, text)]
    closing = [i.span()[1] for i in re.finditer(closing, text)]

    if len(opening) == 0:
        return {}

    if len(opening) > len(closing):
        raise IndexError("Unmatching opening...closing found")

    opening = np.array(opening, dtype=int)
    closing = np.array(closing, dtype=int) * -1
    brackets = np.concatenate([opening, closing])
    brackets = brackets[np.argsort(np.abs(brackets))]

    ret = {}
    stack = []

    for i in brackets:
        if i >= 0:
            stack.append(i)
        else:
            if len(stack) == 0:
                raise IndexError(f"No closing {closing} at: {i:d}")
            j = stack.pop()
            ret[j] = -1 * i

    if len(stack) > 0:
        i = stack.pop()
        raise IndexError(f"No opening {opening} at: {i:d}")

    return ret


def _linenumbers(text: str) -> list[int]:
    """
    Return the line-number of each character in ``text``.

    :param text: The text to consider.
    :return: List of line-numbers.
    """
    lineno = np.empty(len(text), dtype=int)
    i = 0
    line = 0
    for line, match in enumerate(re.finditer(r"\n", text)):
        lineno[i : match.span()[0]] = line
        i = match.span()[0]
        lineno[i] = line
        i += 1
    lineno[i:] = line + 1
    lineno = np.append(lineno, line + 2)
    return lineno


def _gitignore_parser():
    """
    Return parser for :py:func:`shelephant gitignore`.
    """

    desc = textwrap.dedent(
        """
        Add all symbolic links at ``.shelephant`` to dataset's ``.gitignore``.
        Note that this is not ``.shelephant/.gitignore`` but the one in the dataset's root.
        """
    )

    class MyFmt(
        argparse.RawDescriptionHelpFormatter,
        argparse.ArgumentDefaultsHelpFormatter,
        argparse.MetavarTypeHelpFormatter,
    ):
        pass

    parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc)

    parser.add_argument("--version", action="version", version=version)
    return parser


def gitignore(args: list[str]):
    """
    Command-line tool, see ``--help``.

    :param args: Command-line arguments (should be all strings).
    """

    parser = _gitignore_parser()
    args = parser.parse_args(args)
    sdir = _search_upwards_dir(".shelephant")
    assert sdir is not None, "Not a shelephant dataset"
    path = sdir / ".." / ".gitignore"

    if path.exists():
        ignore = path.read_text()
        brackets = _find_matching(ignore, re.escape("# <shelephant>"), re.escape("# </shelephant>"))
        if len(brackets) > 0:
            assert len(brackets) == 1, "Multiple shelephant sections found"
            brackets = [(key, value) for key, value in brackets.items()][0]
            lineno = _linenumbers(ignore)
            ignore = ignore.splitlines()
            ignore = (
                "\n".join(ignore[: lineno[brackets[0]]]).rstrip()
                + "\n"
                + "\n".join(ignore[lineno[brackets[1]] + 1 :]).lstrip()
            )
    else:
        ignore = ""

    with search.cwd(sdir):
        symlinks = yaml.read("symlinks.yaml", [])
        symlinks = [i["path"] for i in symlinks]

    if len(symlinks) > 0:
        ignore += "\n# <shelephant>\n" + "\n".join(symlinks) + "\n# </shelephant>\n"
    path.write_text(ignore.strip())


def git(args: list[str]):
    """
    Run git from ``.shelephant`` directory.

    :param args: Command-line arguments (should be all strings).
    """
    with search.cwd(_search_upwards_dir(".shelephant")):
        print(exec_cmd(f"git {' '.join(args)}"))
