# Copyright (c) 2018 The Regents of the University of Michigan
# All rights reserved.
# This software is licensed under the BSD 3-Clause License.
"""Linked view classes."""

import errno
import logging
import os
import sys
from itertools import chain

from ._utility import _mkdir_p

logger = logging.getLogger(__name__)


def create_linked_view(project, prefix=None, job_ids=None, path=None):
    """Create or update a persistent linked view of the selected data space.

    Parameters
    ----------
    project : signac.Project
        Project handle.
    prefix : str, optional
        The path where the linked view will be created or updated (Default value = None).
    job_ids : iterable, optional
        If None (the default), create the view for the complete data space,
        otherwise only for this iterable of job ids.
    path : str or callable, optional
        The path (function) used to structure the linked data space (Default value = None).

    Returns
    -------
    dict
        A dictionary that maps the source directory paths to the linked
        directory paths.

    Raises
    ------
    OSError
        Linked views cannot be created on Windows because
        symbolic links are not supported by the platform.
    RuntimeError
        When state points contain one of ``[os.sep, " ", "*"]``.

    """
    from .import_export import _check_directory_structure_validity, _make_path_function

    # Windows does not support the creation of symbolic links.
    if sys.platform == "win32":
        raise OSError(
            "signac cannot create linked views on Windows, because "
            "symbolic links are not supported by the platform."
        )

    if prefix is None:
        prefix = "view"

    if job_ids is None:
        jobs = list(project)
    else:
        jobs = list(project.open_job(id=job_id) for job_id in job_ids)

    key_list = [k for job in jobs for k in job.statepoint().keys()]
    value_list = [v for job in jobs for v in job.statepoint().values()]
    item_list = key_list + value_list
    bad_chars = [os.sep, " ", "*"]
    bad_items = [
        item
        for item in item_list
        for char in bad_chars
        if isinstance(item, str) and char in item
    ]

    if any(bad_items):
        err_msg = " ".join(
            [
                f"In order to use view, state points should not contain {bad_chars}:",
                *bad_items,
            ]
        )
        raise RuntimeError(err_msg)

    path_function = _make_path_function(jobs, path)

    links = {}
    for job in jobs:
        paths = os.path.join(path_function(job), "job")
        links[paths] = job.path
    if not links:  # data space contains less than two elements
        for job in project.find_jobs():
            links["./job"] = job.path
        assert len(links) < 2
    _check_directory_structure_validity(links.keys())
    _update_view(prefix, links)

    return links


def _update_view(prefix, links, leaf="job"):
    """Update an existing linked view hierarchy in place.

    Parameters
    ----------
    prefix : str
        The path where the linked view will be created or updated (Default value = None).
    links : dict
        Linked view .
    leaf : str
        The name of the leaf directories in the view
        directory tree (Default value = 'job').

    """
    obsolete, to_update, new = _analyze_view(prefix, links)
    num_ops = len(obsolete) + 2 * len(to_update) + len(new)
    if num_ops:
        logger.info(f"Generating current view in '{prefix}' ({num_ops} operations)...")
    else:
        logger.info(f"View in '{prefix}' is up to date.")
        return
    logger.debug(f"Removing {len(obsolete)} obsolete links.")
    for path in obsolete:
        p = os.path.join(prefix, path)
        try:
            os.unlink(p)
        except OSError:
            os.rmdir(p)
    logger.debug(
        "Creating {} new and updating {} existing links.".format(
            len(new), len(to_update)
        )
    )
    for path in to_update:
        os.unlink(os.path.join(prefix, path))
    for path in chain(new, to_update):
        dst = os.path.join(prefix, path)
        src = os.path.relpath(links[path], os.path.split(dst)[0])
        _make_link(src, dst)


def _analyze_view(prefix, links, leaf="job"):
    """Analyze an existing view to prepare for update.

    Parameters
    ----------
    prefix : str
        The path where the linked view will be created or updated (Default value = None).
    links : dict
        Linked view.
    leaf : str
        The name of the leaf directories in the view
        directory tree (Default value = 'job').

    Returns
    -------
    tuple
        tuple that contains: (list of outdated links, list of links to update, set of new links).

    """
    logger.info(f"Analyzing view prefix '{prefix}'...")
    existing_paths = {os.path.join(p, leaf) for p in _find_all_links(prefix, leaf)}
    existing_tree = _build_tree(existing_paths)
    for path in links:
        _color_path(existing_tree, path.split(os.sep))
    obsolete = []
    dead_branches = _find_dead_branches(existing_tree)
    for branch in reversed(sorted(dead_branches, key=len)):
        if branch:
            obsolete.append(os.path.join(*(n.name for n in branch)))
    if "." in obsolete:
        obsolete.remove(".")
    keep_or_update = existing_paths.intersection(links.keys())
    new = set(links.keys()).difference(keep_or_update)
    to_update = [
        p
        for p in keep_or_update
        if os.path.realpath(os.path.join(prefix, p)) != links[p]
    ]
    return obsolete, to_update, new


def _make_link(src, dst):
    """Create a symbolic link and all directories leading to it.

    Parameters
    ----------
    src : str
        Name of directory/file to create a symbolic link.
    dst : str
        Destination symbolic link directory/file name.

    """
    _mkdir_p(os.path.dirname(dst))
    try:
        os.symlink(src, dst, target_is_directory=True)
    except OSError as error:
        if error.errno == errno.EEXIST:
            if os.path.realpath(src) == os.path.realpath(dst):
                return
        raise


def _find_all_links(root, leaf="job"):
    """Find all symbolic links under root.

    Parameters
    ----------
    root : str
        Project root directory.
    leaf : str
        The name of the leaf directories in the view
        directory tree (Default value = 'job').

    Yields
    ------
    str
        Relative path to root from ``leaf``.

    """
    for dirpath, dirnames, filenames in os.walk(root):
        for dirname in dirnames:
            if dirname == leaf:
                yield os.path.relpath(dirpath, root)
                break
        for filename in filenames:
            if filename == leaf:
                yield os.path.relpath(dirpath, root)
                break


class _Node:
    """Generic graph-node class."""

    def __init__(self, name=None, value=None):
        self.name = name
        self.value = value
        self.children = {}

    def get_child(self, name):
        """Get child node corresponding to the name passed.

        Parameters
        ----------
        name : str
            Name of child node to get.

        Returns
        -------
        :class:`~signac.linked_view._Node`
            The requested child node.

        """
        return self.children.setdefault(name, type(self)(name))

    def __str__(self):
        return f"_Node({self.name}, {self.value})"

    __repr__ = __str__


def _build_tree(paths):
    """Build a graph structure for paths.

    Parameters
    ----------
    paths : list
        A list of paths to views that already exist.

    Returns
    -------
    :class:`~signac.linked_view._Node`
        Graph structure for path.

    """
    root = _Node()
    for path in paths:
        node = root
        for p in path.split(os.sep):
            node = node.get_child(p)
    return root


def _color_path(root, path):
    """Color the path from root by setting value to True.

    Parameters
    ----------
    root : :class:`~signac.linked_view._Node`
        Root node.
    path : list
        The name of the directory/file to color (set value to True).

    """
    root.value = True
    for name in path:
        root = root.get_child(name)
        root.value = True


def _find_dead_branches(root, branch=None):
    """Find all branches considered dead (not-colored).

    Parameters
    ----------
    root : :class:`~signac.linked_view._Node`
        Root node.
    branch : list, optional
        The current list of branches that have been collected,
        used in recursive calls to build up the branches starting
        at the root (Default value = None).

    Yields
    ------
    list
        Branches that are considered as dead (not-colored).

    """
    if branch is None:
        branch = []
    else:
        branch = list(branch) + [root]
    if root.children:
        for child in root.children.values():
            yield from _find_dead_branches(child, branch)
    if not root.value:
        yield branch
