import os
import os.path as op

import click

from .base import dandiset_path_option, devel_debug_option, lgr, map_to_click_exceptions
from ..consts import file_operation_modes


@click.command()
@dandiset_path_option(
    help="The root directory of the Dandiset to organize files under. "
    "If not specified, the Dandiset under the current directory is assumed. "
    "For 'simulate' mode, the target Dandiset/directory must not exist.",
    type=click.Path(dir_okay=True, file_okay=False),
)
@click.option(
    "--invalid",
    help="What to do if files without sufficient metadata are encountered.",
    type=click.Choice(["fail", "warn"]),
    default="fail",
    show_default=True,
)
@click.option(
    "-f",
    "--files-mode",
    help="If 'dry' - no action is performed, suggested renames are printed. "
    "If 'simulate' - hierarchy of empty files at --dandiset-path is created. "
    "Note that previous layout should be removed prior to this operation.  "
    "If 'auto' - whichever of symlink, hardlink, copy is allowed by system. "
    "The other modes (copy, move, symlink, hardlink) define how data files "
    "should be made available.",
    type=click.Choice(file_operation_modes),
    default="auto",
    show_default=True,
)
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
@devel_debug_option()
@map_to_click_exceptions
def organize(
    paths,
    dandiset_path=None,
    invalid="fail",
    files_mode="auto",
    devel_debug=False,
):
    """(Re)organize files according to the metadata.

    The purpose of this command is to take advantage of metadata contained in
    .nwb files to provide datasets with consistently-named files whose names
    reflect the data they contain.

    .nwb files are organized into a hierarchy of subfolders, one per "subject",
    e.g., `sub-0001` if an .nwb file contained a Subject group with
    `subject_id=0001`.  Each file in a subject-specific subfolder follows the
    pattern:

        sub-<subject_id>[_key-<value>][_mod1+mod2+...].nwb

    where the following keys are considered if present in the data:

    \b
        ses -- session_id
        tis -- tissue_sample_id
        slice -- slice_id
        cell -- cell_id

    and `modX` are "modalities" as identified based on detected neural data
    types (such as "ecephys", "icephys") per extensions found in nwb-schema
    definitions:
    https://github.com/NeurodataWithoutBorders/nwb-schema/tree/dev/core

    In addition, an "obj" key with a value corresponding to the crc32 checksum
    of "object_id" is added if the aforementioned keys and the list of
    modalities are not sufficient to disambiguate different files.

    You can visit https://dandiarchive.org for a growing collection of
    (re)organized dandisets.
    """
    from ..dandiset import Dandiset
    from ..metadata import get_metadata
    from ..organize import (
        create_unique_filenames_from_metadata,
        detect_link_type,
        filter_invalid_metadata_rows,
    )
    from ..pynwb_utils import ignore_benign_pynwb_warnings
    from ..utils import Parallel, copy_file, delayed, find_files, load_jsonl, move_file

    in_place = False  # If we deduce that we are organizing in-place

    # will come handy when dry becomes proper separate option
    def dry_print(msg):
        print(f"DRY: {msg}")

    if files_mode == "dry":

        def act(func, *args, **kwargs):
            dry_print(f"{func.__name__} {args}, {kwargs}")

    else:

        def act(func, *args, **kwargs):
            lgr.debug("%s %s %s", func.__name__, args, kwargs)
            return func(*args, **kwargs)

    if dandiset_path is None:
        dandiset = Dandiset.find(os.curdir)
        if not dandiset:
            raise ValueError(
                "No --dandiset-path was provided, and no dandiset was found "
                "in/above current directory"
            )
        dandiset_path = dandiset.path
        del dandiset

    # Early checks to not wait to fail
    if files_mode == "simulate":
        # in this mode we will demand the entire output folder to be absent
        if op.exists(dandiset_path):
            # TODO: RF away
            raise RuntimeError(
                "In simulate mode %r (--dandiset-path) must not exist, we will create it."
                % dandiset_path
            )

    ignore_benign_pynwb_warnings()

    if not paths:
        try:
            Dandiset(dandiset_path)
        except Exception as exc:
            lgr.debug("Failed to find dandiset at %s: %s", dandiset_path, exc)
            raise ValueError(
                f"No dandiset was found at {dandiset_path}, and no "
                f"paths were provided"
            )
        if files_mode not in ("dry", "move"):
            raise ValueError(
                "Only 'dry' or 'move' mode could be used to operate in-place "
                "within a dandiset (no paths were provided)"
            )
        lgr.info(f"We will organize {dandiset_path} in-place")
        in_place = True
        paths = dandiset_path

    if len(paths) == 1 and paths[0].endswith(".json"):
        # Our dumps of metadata
        metadata = load_jsonl(paths[0])
    else:
        paths = list(find_files(r"\.nwb\Z", paths=paths))
        lgr.info("Loading metadata from %d files", len(paths))
        # Done here so we could still reuse cached 'get_metadata'
        # without having two types of invocation and to guard against
        # problematic ones -- we have an explicit option on how to
        # react to those
        # Doesn't play nice with Parallel
        # with tqdm.tqdm(desc="Files", total=len(paths), unit="file", unit_scale=False) as pbar:
        failed = []

        def _get_metadata(path):
            try:
                meta = get_metadata(path)
            except Exception as exc:
                meta = {}
                failed.append(path)
                # pbar.desc = "Files (%d failed)" % len(failed)
                lgr.debug("Failed to get metadata for %s: %s", path, exc)
            # pbar.update(1)
            meta["path"] = path
            return meta

        if not devel_debug:
            # Note: It is Python (pynwb) intensive, not IO, so ATM there is little
            # to no benefit from Parallel without using multiproc!  But that would
            # complicate progress bar indication... TODO
            metadata = list(
                Parallel(n_jobs=-1, verbose=10)(
                    delayed(_get_metadata)(path) for path in paths
                )
            )
        else:
            metadata = list(map(_get_metadata, paths))
        if failed:
            lgr.warning(
                "Failed to load metadata for %d out of %d files",
                len(failed),
                len(paths),
            )

    metadata, skip_invalid = filter_invalid_metadata_rows(metadata)
    if skip_invalid:
        msg = (
            "%d out of %d files were found not containing all necessary "
            "metadata: %s"
            % (
                len(skip_invalid),
                len(metadata) + len(skip_invalid),
                ", ".join(m["path"] for m in skip_invalid),
            )
        )
        if invalid == "fail":
            raise ValueError(msg)
        elif invalid == "warn":
            lgr.warning(msg + " They will be skipped")
        else:
            raise ValueError(f"invalid has an invalid value {invalid}")

    if not op.exists(dandiset_path):
        act(os.makedirs, dandiset_path)

    if files_mode == "auto":
        files_mode = detect_link_type(dandiset_path)

    metadata = create_unique_filenames_from_metadata(metadata)

    # Verify first that the target paths do not exist yet, and fail if they do
    # Note: in "simulate" mode we do early check as well, so this would be
    # duplicate but shouldn't hurt
    existing = []
    for e in metadata:
        dandi_fullpath = op.join(dandiset_path, e["dandi_path"])
        if op.exists(dandi_fullpath):
            # It might be the same file, then we would not complain
            if not (
                op.realpath(e["path"])
                == op.realpath(op.join(dandiset_path, e["dandi_path"]))
            ):
                existing.append(dandi_fullpath)
            # TODO: it might happen that with "move" we are renaming files
            # so there is an existing, which also gets moved away "first"
            # May be we should RF so the actual loop below would be first done
            # "dry", collect info on what is actually to be done, and then we would complain here
    if existing:
        raise AssertionError(
            "%d paths already exist: %s%s.  Remove them first."
            % (
                len(existing),
                ", ".join(existing[:5]),
                " and more" if len(existing) > 5 else "",
            )
        )

    # we should take additional care about paths if both top_path and
    # provided paths are relative
    use_abs_paths = op.isabs(dandiset_path) or any(
        op.isabs(e["path"]) for e in metadata
    )
    skip_same = []
    acted_upon = []
    for e in metadata:
        dandi_path = e["dandi_path"]
        dandi_fullpath = op.join(dandiset_path, dandi_path)
        dandi_abs_fullpath = (
            op.abspath(dandi_fullpath)
            if not op.isabs(dandi_fullpath)
            else dandi_fullpath
        )
        dandi_dirpath = op.dirname(dandi_fullpath)  # could be sub-... subdir

        e_path = e["path"]
        e_abs_path = e_path

        if not op.isabs(e_path):
            e_abs_path = op.abspath(e_path)
            if use_abs_paths:
                e_path = e_abs_path
            elif files_mode == "symlink":  # path should be relative to the target
                e_path = op.relpath(e_abs_path, dandi_dirpath)

        if dandi_abs_fullpath == e_abs_path:
            lgr.debug("Skipping %s since the same in source/destination", e_path)
            skip_same.append(e)
            continue
        elif files_mode == "symlink" and op.realpath(dandi_abs_fullpath) == op.realpath(
            e_abs_path
        ):
            lgr.debug(
                "Skipping %s since mode is symlink and both resolve to the same path",
                e_path,
            )
            skip_same.append(e)
            continue

        if (
            files_mode == "dry"
        ):  # TODO: this is actually a files_mode on top of modes!!!?
            dry_print(f"{e_path} -> {dandi_path}")
        else:
            if not op.exists(dandi_dirpath):
                os.makedirs(dandi_dirpath)
            if files_mode == "simulate":
                os.symlink(e_path, dandi_fullpath)
                continue
            #
            if files_mode == "symlink":
                os.symlink(e_path, dandi_fullpath)
            elif files_mode == "hardlink":
                os.link(e_path, dandi_fullpath)
            elif files_mode == "copy":
                copy_file(e_path, dandi_fullpath)
            elif files_mode == "move":
                move_file(e_path, dandi_fullpath)
            else:
                raise NotImplementedError(files_mode)
            acted_upon.append(e)

    if acted_upon and in_place:
        # We might need to cleanup a bit - e.g. prune empty directories left
        # by the move in in-place mode
        dirs = set(op.dirname(e["path"]) for e in acted_upon)
        for d in sorted(dirs)[::-1]:  # from longest to shortest
            if op.exists(d):
                try:
                    os.rmdir(d)
                    lgr.info(f"Removed empty directory {d}")
                except Exception as exc:
                    lgr.debug("Failed to remove directory %s: %s", d, exc)

    def msg_(msg, n, cond=None):
        if hasattr(n, "__len__"):
            n = len(n)
        if cond is None:
            cond = bool(n)
        if not cond:
            return ""
        return msg % n

    lgr.info(
        "Organized %d%s paths%s.%s Visit %s/",
        len(acted_upon),
        msg_(" out of %d", metadata, len(metadata) != len(acted_upon)),
        msg_(" (%d same existing skipped)", skip_same),
        msg_(" %d invalid not considered.", skip_invalid),
        dandiset_path.rstrip("/"),
    )
