# -*- coding: utf-8 -*-
#
# Copyright 2017-2022 - Swiss Data Science Center (SDSC)
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
# Eidgenössische Technische Hochschule Zürich (ETHZ).
#
# 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
#
#     http://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.
"""Activity management."""

import itertools
from collections import defaultdict
from pathlib import Path
from typing import Dict, FrozenSet, List, Optional, Set, Tuple

import networkx

from renku.command.command_builder import inject
from renku.core import errors
from renku.core.interface.activity_gateway import IActivityGateway
from renku.core.interface.client_dispatcher import IClientDispatcher
from renku.core.interface.plan_gateway import IPlanGateway
from renku.core.util import communication
from renku.domain_model.entity import Entity
from renku.domain_model.provenance.activity import Activity
from renku.domain_model.workflow.plan import AbstractPlan


def get_activities_until_paths(
    paths: List[str],
    sources: List[str],
    activity_gateway: IActivityGateway,
    client_dispatcher: IClientDispatcher,
    revision: Optional[str] = None,
) -> Set[Activity]:
    """Get all current activities leading to `paths`, from `sources`."""
    all_activities: Dict[str, Set[Activity]] = defaultdict(set)

    def include_newest_activity(activity):
        existing_activities = all_activities[activity.association.plan.id]
        add_activity_if_recent(activity=activity, activities=existing_activities)

    commit = None

    if revision:
        client = client_dispatcher.current_client
        commit = client.repository.get_commit(revision)

    for path in paths:
        checksum = None
        if commit:
            try:
                blob = commit.tree[path]
            except KeyError:
                raise errors.GitError(f"Couldn't find file {path} at revision {revision}")
            checksum = blob.hexsha

        activities = activity_gateway.get_activities_by_generation(path, checksum=checksum)

        if len(activities) == 0:
            communication.warn(f"Path '{path}' is not generated by any workflows.")
            continue

        latest_activity = max(activities, key=lambda a: a.ended_at_time)

        upstream_chains = activity_gateway.get_upstream_activity_chains(latest_activity)

        if sources:
            # NOTE: Add the activity to check if it also matches the condition
            upstream_chains.append((latest_activity,))
            # NOTE: Only include paths that is using at least one of the sources
            upstream_chains = [c for c in upstream_chains if any(u.entity.path in sources for u in c[-1].usages)]

            # NOTE: Include activity only if any of its upstream match the condition
            if upstream_chains:
                include_newest_activity(latest_activity)
        else:
            include_newest_activity(latest_activity)

        for chain in upstream_chains:
            for activity in chain:
                include_newest_activity(activity)

    return {a for activities in all_activities.values() for a in activities}


def create_activity_graph(
    activities: List[Activity], remove_overridden_parents=True, with_inputs_outputs=False
) -> networkx.Graph:
    """Create a dependency DAG from activities."""
    by_usage: Dict[str, Set[Activity]] = defaultdict(set)
    by_generation: Dict[str, Set[Activity]] = defaultdict(set)

    overridden_activities: Dict[Activity, Set[str]] = defaultdict(set)

    graph = networkx.DiGraph()

    def connect_nodes_based_on_dependencies():
        for activity in activities:
            # NOTE: Make sure that activity is in the graph in case it has no connection to others
            graph.add_node(activity)
            if with_inputs_outputs:
                create_input_output_edges(activity)
            for usage in activity.usages:
                path = usage.entity.path
                by_usage[path].add(activity)
                parent_activities = by_generation[path]
                for parent in parent_activities:
                    create_edge(parent, activity, path)

            for generation in activity.generations:
                path = generation.entity.path
                by_generation[path].add(activity)
                child_activities = by_usage[path]
                for child in child_activities:
                    create_edge(activity, child, path)

    def create_input_output_edges(activity):
        for generation in activity.generations:
            path = generation.entity.path
            if not graph.has_node(path):
                graph.add_node(path)
            if not graph.has_edge(activity, path):
                graph.add_edge(activity, path)
        for usage in activity.usages:
            path = usage.entity.path
            if not graph.has_node(path):
                graph.add_node(path)
            if not graph.has_edge(path, activity):
                graph.add_edge(path, activity)

    def create_edge(parent, child, path: str):
        if with_inputs_outputs:
            if not graph.has_edge(parent, path):
                graph.add_edge(parent, path)
            if not graph.has_edge(path, child):
                graph.add_edge(path, child)
        else:
            if graph.has_edge(parent, child):
                return
            graph.add_edge(parent, child, path=path)

    def connect_nodes_by_execution_order():
        for path, values in by_generation.items():
            if len(values) <= 1:
                continue

            # NOTE: Order multiple activities that generate a common path
            create_order_among_activities(values, path)

    def create_order_among_activities(activities: Set[Activity], path):
        for a, b in itertools.combinations(activities, 2):
            if (networkx.has_path(graph, a, b) and path in overridden_activities[a]) or (
                networkx.has_path(graph, b, a) and path in overridden_activities[b]
            ):
                continue

            # NOTE: More recent activity should be executed after the other one
            # NOTE: This won't introduce a cycle in the graph because there is no other path between the two nodes
            comparison = a.compare_to(b)
            if comparison < 0:
                if not networkx.has_path(graph, a, b):
                    graph.add_edge(a, b)
                overridden_activities[a].add(path)
            elif comparison > 0:
                if not networkx.has_path(graph, b, a):
                    graph.add_edge(b, a)
                overridden_activities[b].add(path)
            else:
                raise ValueError(f"Cannot create an order between activities {a.id} and {b.id}")

    def remove_overridden_activities():
        to_be_removed = set()
        to_be_processed = set(overridden_activities.keys())

        while len(to_be_processed) > 0:
            activity = to_be_processed.pop()
            overridden_paths = overridden_activities[activity]
            generated_path = {g.entity.path for g in activity.generations}
            if generated_path != overridden_paths:
                continue

            # NOTE: All generated paths are overridden; there is no point in executing the activity
            to_be_removed.add(activity)

            if not remove_overridden_parents:
                continue

            # NOTE: Check if its parents can be removed as well
            for parent in graph.predecessors(activity):
                if parent in to_be_removed:
                    continue
                data = graph.get_edge_data(parent, activity)
                if data and "path" in data:
                    overridden_activities[parent].add(data["path"])
                    to_be_processed.add(parent)

        for activity in to_be_removed:
            graph.remove_node(activity)

    connect_nodes_based_on_dependencies()

    cycles = list(networkx.algorithms.cycles.simple_cycles(graph))

    if cycles:
        cycles = [map(lambda x: getattr(x, "id", x), cycle) for cycle in cycles]
        raise errors.GraphCycleError(cycles)

    connect_nodes_by_execution_order()
    remove_overridden_activities()

    return graph


def sort_activities(activities: List[Activity], remove_overridden_parents=True) -> List[Activity]:
    """Return a sorted list of activities based on their dependencies and execution order."""
    graph = create_activity_graph(activities, remove_overridden_parents)

    return list(networkx.topological_sort(graph))


@inject.autoparams()
def is_activity_valid(activity: Activity, plan_gateway: IPlanGateway) -> bool:
    """Return whether this plan is current and has not been deleted.

    Args:
        activity(Activity): The Activity whose Plan should be checked.
        plan_gateway(IPlanGateway): The injected Plan gateway.

    Returns:
        bool: True if the activities' Plan is still valid, False otherwise.

    """
    plan = activity.association.plan

    if plan.invalidated_at is not None:
        return False

    # get newest with same name
    newest_plan = plan_gateway.get_by_name(plan.name)

    if newest_plan is None or newest_plan.invalidated_at is not None:
        return False

    all_plans = plan_gateway.get_all_plans()

    derived: Optional[AbstractPlan] = plan
    while derived:
        plan = derived
        derived = next((p for p in all_plans if p.derived_from is not None and p.derived_from == plan.id), None)

    return plan.invalidated_at is None


@inject.autoparams()
def get_all_modified_and_deleted_activities_and_entities(
    repository, activity_gateway: IActivityGateway
) -> Tuple[Set[Tuple[Activity, Entity]], Set[Tuple[Activity, Entity]]]:
    """
    Return latest activities with at least one modified or deleted input along with the modified/deleted input entity.

    An activity can be repeated if more than one of its inputs are modified.

    Args:
        repository: The current ``Repository``.
        activity_gateway(IActivityGateway): The injected Activity gateway.

    Returns:
        Tuple[Set[Tuple[Activity, Entity]], Set[Tuple[Activity, Entity]]]: Tuple of modified and deleted
            activities and entities.

    """
    all_activities = activity_gateway.get_all_activities()
    relevant_activities = filter_overridden_activities(all_activities)
    return get_modified_activities(activities=relevant_activities, repository=repository)


@inject.autoparams()
def get_downstream_generating_activities(
    starting_activities: Set[Activity],
    paths: List[str],
    ignore_deleted: bool,
    client_path: Path,
    activity_gateway: IActivityGateway,
) -> List[Activity]:
    """Return activities downstream of passed activities that generate at least a path in ``paths``.

    Args:
        starting_activities(Set[Activity]): Activities to use as starting/upstream nodes.
        paths(List[str]): Optional generated paths to end downstream chains at.
        ignore_deleted(bool): Whether to ignore deleted generations.
        client_path(Path): Path to project's root directory.
        activity_gateway(IActivityGateway): The injected Activity gateway.

    Returns:
        Set[Activity]: All activities and their downstream activities.

    """
    all_activities: Dict[str, Set[Activity]] = defaultdict(set)

    def include_newest_activity(activity):
        existing_activities = all_activities[activity.association.plan.id]
        add_activity_if_recent(activity=activity, activities=existing_activities)

    def does_activity_generate_any_paths(activity) -> bool:
        is_same = any(g.entity.path in paths for g in activity.generations)
        is_parent = any(Path(p) in Path(g.entity.path).parents for p in paths for g in activity.generations)

        return is_same or is_parent

    def has_an_existing_generation(activity) -> bool:
        for generation in activity.generations:
            if (client_path / generation.entity.path).exists():
                return True

        return False

    for starting_activity in starting_activities:
        downstream_chains = activity_gateway.get_downstream_activity_chains(starting_activity)

        if paths:
            # NOTE: Add the activity to check if it also matches the condition
            downstream_chains.append((starting_activity,))
            downstream_chains = [c for c in downstream_chains if does_activity_generate_any_paths(c[-1])]
            # NOTE: Include activity only if any of its downstream matched the condition
            include_starting_activity = len(downstream_chains) > 0
        elif ignore_deleted:  # NOTE: Excluded deleted generations only if they are not passed in ``paths``
            # NOTE: Add the activity to check if it also matches the condition
            downstream_chains.append((starting_activity,))
            downstream_chains = [c for c in downstream_chains if has_an_existing_generation(c[-1])]
            # NOTE: Include activity only if any of its downstream matched the condition
            include_starting_activity = len(downstream_chains) > 0
        else:
            include_starting_activity = True

        if include_starting_activity:
            include_newest_activity(starting_activity)

        for chain in downstream_chains:
            for activity in chain:
                if not is_activity_valid(activity):
                    # don't process further downstream activities as the plan in question was deleted
                    break
                include_newest_activity(activity)

    return list({a for activities in all_activities.values() for a in activities})


def get_modified_activities(
    activities: FrozenSet[Activity], repository
) -> Tuple[Set[Tuple[Activity, Entity]], Set[Tuple[Activity, Entity]]]:
    """Get lists of activities that have modified/deleted usage entities."""
    modified = set()
    deleted = set()

    paths = []

    for activity in activities:
        for usage in activity.usages:
            paths.append(usage.entity.path)

    hashes = repository.get_object_hashes(paths=paths)

    for activity in activities:
        for usage in activity.usages:
            entity = usage.entity
            current_checksum = hashes.get(entity.path, None)
            usage_path = repository.path / usage.entity.path
            if current_checksum is None or not usage_path.exists():
                deleted.add((activity, entity))
            elif current_checksum != entity.checksum:
                modified.add((activity, entity))

    return modified, deleted


def filter_overridden_activities(activities: List[Activity]) -> FrozenSet[Activity]:
    """Filter out overridden activities from a list of activities."""
    relevant_activities: Dict[FrozenSet[str], Activity] = {}

    for activity in activities[::-1]:
        outputs = frozenset(g.entity.path for g in activity.generations)

        subset_of = set()
        superset_of = set()

        for o, a in relevant_activities.items():
            if outputs.issubset(o):
                subset_of.add((o, a))
            elif outputs.issuperset(o):
                superset_of.add((o, a))

        if not subset_of and not superset_of:
            relevant_activities[outputs] = activity
            continue

        if subset_of and any(activity.ended_at_time < a.ended_at_time for _, a in subset_of):
            # activity is a subset of another, newer activity, ignore it
            continue

        older_subsets = [o for o, a in superset_of if activity.ended_at_time > a.ended_at_time]

        for older_subset in older_subsets:
            # remove other activities that this activity is a superset of
            del relevant_activities[older_subset]

        relevant_activities[outputs] = activity

    return frozenset(relevant_activities.values())


def add_activity_if_recent(activity: Activity, activities: Set[Activity]):
    """Add ``activity`` to ``activities`` if it's not in the set or is the latest executed instance.

    Remove existing activities that were executed earlier.
    """
    if activity in activities:
        return

    for existing_activity in activities:
        if activity.has_identical_inputs_and_outputs_as(existing_activity):
            if activity.ended_at_time > existing_activity.ended_at_time:  # activity is newer
                activities.remove(existing_activity)
                activities.add(activity)
            return

    # NOTE: No similar activity was found
    activities.add(activity)
