from __future__ import annotations

import copy
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import Text, Optional, Dict, Any, List, Set, Union

import structlog
from pypred import Predicate

import rasa.shared.utils.io
from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX
from rasa.shared.core.flows.flow_path import PathNode, FlowPath, FlowPathsList
from rasa.shared.core.flows.flow_step import FlowStep
from rasa.shared.core.flows.flow_step_links import (
    FlowStepLink,
    StaticFlowStepLink,
    IfFlowStepLink,
    ElseFlowStepLink,
)
from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence
from rasa.shared.core.flows.nlu_trigger import NLUTriggers
from rasa.shared.core.flows.steps import (
    ActionFlowStep,
    CallFlowStep,
    CollectInformationFlowStep,
    EndFlowStep,
    StartFlowStep,
)
from rasa.shared.core.flows.steps.constants import (
    CONTINUE_STEP_PREFIX,
    START_STEP,
    END_STEP,
)
from rasa.shared.core.flows.steps.continuation import ContinueFlowStep
from rasa.shared.core.slots import Slot

structlogger = structlog.get_logger()


@dataclass
class Flow:
    """Represents the configuration of a flow."""

    id: Text
    """The id of the flow."""
    custom_name: Optional[Text] = None
    """The human-readable name of the flow."""
    description: Optional[Text] = None
    """The description of the flow."""
    guard_condition: Optional[Text] = None
    """The condition that needs to be fulfilled for the flow to be startable."""
    step_sequence: FlowStepSequence = field(default_factory=FlowStepSequence.empty)
    """The steps of the flow."""
    nlu_triggers: Optional[NLUTriggers] = None
    """The list of intents, e.g. nlu triggers, that start the flow."""
    always_include_in_prompt: Optional[bool] = None
    """
    A flag that checks whether the flow should always be included in the prompt or not.
    """
    file_path: Optional[str] = None
    """The path to the file where the flow is stored."""
    persisted_slots: List[str] = field(default_factory=list)
    """The list of slots that should be persisted after the flow ends."""

    @staticmethod
    def from_json(
        flow_id: Text,
        data: Dict[Text, Any],
        file_path: Optional[Union[str, Path]] = None,
    ) -> Flow:
        """Create a Flow object from serialized data.

        Args:
            flow_id: id of the flow
            data: data for a Flow object in a serialized format.
            file_path: the file path of the flow

        Returns:
            A Flow object.
        """
        step_sequence = FlowStepSequence.from_json(flow_id, data.get("steps"))
        nlu_triggers = NLUTriggers.from_json(data.get("nlu_trigger"))

        if file_path and isinstance(file_path, Path):
            file_path = str(file_path)

        return Flow(
            id=flow_id,
            custom_name=data.get("name"),
            description=data.get("description"),
            always_include_in_prompt=data.get("always_include_in_prompt"),
            # str or bool are permitted in the flow schema but internally we want a str
            guard_condition=str(data["if"]) if "if" in data else None,
            step_sequence=Flow.resolve_default_ids(step_sequence),
            nlu_triggers=nlu_triggers,
            # If we are reading the flows in after training the file_path is part of
            # data. When the model is trained, take the provided file_path.
            file_path=data.get("file_path") if "file_path" in data else file_path,
            persisted_slots=data.get("persisted_slots", []),
        )

    def get_full_name(self) -> str:
        if self.file_path:
            return f"{self.file_path}::{self.name}"
        return self.name

    @staticmethod
    def create_default_name(flow_id: str) -> str:
        """Create a default flow name for when it is missing."""
        return flow_id.replace("_", " ").replace("-", " ")

    @staticmethod
    def resolve_default_ids(step_sequence: FlowStepSequence) -> FlowStepSequence:
        """Resolves the default ids of all steps in the sequence.

        If a step does not have an id, a default id is assigned to it based
        on the type of the step and its position in the flow.

        Similarly, if a step doesn't have an explicit next assigned we resolve
        the default next step id.

        Args:
            step_sequence: The step sequence to resolve the default ids for.

        Returns:
            The step sequence with the default ids resolved.
        """
        # assign an index to all steps
        for idx, step in enumerate(step_sequence.steps):
            step.idx = idx

        def resolve_default_next(steps: List[FlowStep], is_root_sequence: bool) -> None:
            for i, step in enumerate(steps):
                if step.next.no_link_available() and step.does_allow_for_next_step():
                    if i == len(steps) - 1:
                        if is_root_sequence:
                            # if this is the root sequence, we need to add an end step
                            # to the end of the sequence. other sequences, e.g.
                            # in branches need to explicitly add a next step.
                            step.next.links.append(StaticFlowStepLink(END_STEP))
                    else:
                        step.next.links.append(StaticFlowStepLink(steps[i + 1].id))
                for link in step.next.links:
                    if sub_steps := link.child_steps():
                        resolve_default_next(sub_steps, is_root_sequence=False)

        resolve_default_next(step_sequence.child_steps, is_root_sequence=True)
        return step_sequence

    def as_json(self) -> Dict[Text, Any]:
        """Serialize the Flow object.

        Returns:
            The Flow object as serialized data.
        """
        data: Dict[Text, Any] = {
            "id": self.id,
            "steps": self.step_sequence.as_json(),
        }
        if self.custom_name is not None:
            data["name"] = self.custom_name
        if self.description is not None:
            data["description"] = self.description
        if self.guard_condition is not None:
            data["if"] = self.guard_condition
        if self.always_include_in_prompt is not None:
            data["always_include_in_prompt"] = self.always_include_in_prompt
        if self.nlu_triggers:
            data["nlu_trigger"] = self.nlu_triggers.as_json()
        if self.file_path:
            data["file_path"] = self.file_path
        if self.persisted_slots:
            data["persisted_slots"] = self.persisted_slots

        return data

    def readable_name(self) -> str:
        """Returns the name of the flow or its id if no name is set."""
        return self.name or self.id

    def step_by_id(self, step_id: Optional[Text]) -> Optional[FlowStep]:
        """Returns the step with the given id."""
        if not step_id:
            return None

        if step_id == START_STEP:
            return StartFlowStep(self.id, self.first_step_in_flow().id)

        if step_id == END_STEP:
            return EndFlowStep(self.id)

        if step_id.startswith(CONTINUE_STEP_PREFIX):
            return ContinueFlowStep(self.id, step_id[len(CONTINUE_STEP_PREFIX) :])

        for step in self.steps_with_calls_resolved:
            if step.id == step_id:
                return step

        return None

    def first_step_in_flow(self) -> FlowStep:
        """Returns the start step of this flow."""
        if not (steps := self.steps):
            raise RuntimeError(
                f"Flow {self.id} is empty despite validation that this cannot happen."
            )
        return steps[0]

    def get_trigger_intents(self) -> Set[str]:
        """Returns the trigger intents of the flow."""
        results: Set[str] = set()

        if not self.nlu_triggers:
            return results

        for condition in self.nlu_triggers.trigger_conditions:
            results.add(condition.intent)

        return results

    @property
    def is_rasa_default_flow(self) -> bool:
        """Test whether the flow is a rasa default flow."""
        return self.id.startswith(RASA_DEFAULT_FLOW_PATTERN_PREFIX)

    def get_collect_steps(self) -> List[CollectInformationFlowStep]:
        """Return all CollectInformationFlowSteps in the flow."""
        collect_steps = []
        for step in self.steps_with_calls_resolved:
            if isinstance(step, CollectInformationFlowStep):
                collect_steps.append(step)
        return collect_steps

    @property
    def steps_with_calls_resolved(self) -> List[FlowStep]:
        """Return the steps of the flow including steps of called flows."""
        return self.step_sequence.steps_with_calls_resolved

    @property
    def steps(self) -> List[FlowStep]:
        """Return the steps of the flow without steps of called flows."""
        return self.step_sequence.steps

    @cached_property
    def fingerprint(self) -> str:
        """Create a fingerprint identifying this step sequence."""
        return rasa.shared.utils.io.deep_container_fingerprint(self.as_json())

    @property
    def utterances(self) -> Set[str]:
        """Retrieve all utterances of this flow."""
        return set().union(
            *[step.utterances for step in self.step_sequence.steps_with_calls_resolved]
        )

    @property
    def custom_actions(self) -> Set[str]:
        """Retrieve all custom actions of this flow."""
        return {
            step.custom_action
            for step in self.step_sequence.steps_with_calls_resolved
            if isinstance(step, ActionFlowStep) and step.custom_action is not None
        }

    @property
    def name(self) -> str:
        """Create a default name if none is present."""
        return self.custom_name or Flow.create_default_name(self.id)

    def is_startable(
        self,
        context: Optional[Dict[Text, Any]] = None,
        slots: Optional[Dict[Text, Slot]] = None,
    ) -> bool:
        """Return whether the start condition is satisfied.

        Args:
            context: The context data to evaluate the starting conditions against.
            slots: The slots to evaluate the starting conditions against.

        Returns:
            Whether the start condition is satisfied.
        """
        context = context or {}
        slots = slots or {}
        simplified_slots = {slot.name: slot.value for slot in slots.values()}

        # If no starting condition exists, the flow is always startable.
        if not self.guard_condition:
            return True

        # if a flow guard condition exists and the flow was started via a link,
        # e.g. is currently active, the flow is startable
        if context.get("flow_id") == self.id:
            return True

        try:
            predicate = Predicate(self.guard_condition)
            is_startable = predicate.evaluate(
                {"context": context, "slots": simplified_slots}
            )
            structlogger.debug(
                "command_generator.validate_flow_starting_conditions.result",
                predicate=predicate.description(),
                is_startable=is_startable,
                flow_id=self.id,
            )
            return is_startable
        # if there is any kind of exception when evaluating the predicate, the flow
        # is not startable
        except (TypeError, Exception) as e:
            structlogger.error(
                "command_generator.validate_flow_starting_conditions.error",
                predicate=self.guard_condition,
                context=context,
                slots=slots,
                error=str(e),
            )
            return False

    def has_action_step(self, action: Text) -> bool:
        """Check whether the flow has an action step with the given action."""
        for step in self.steps:
            if isinstance(step, ActionFlowStep) and step.action == action:
                return True
        return False

    def is_startable_only_via_link(self) -> bool:
        """Determines if the flow can be initiated exclusively through a link.

        This condition is met when a guard condition exists and is
        consistently evaluated to `False` (e.g. `if: False`).

        Returns:
            A boolean indicating if the flow initiation is link-based only.
        """
        if (
            self.guard_condition is None
            or self._contains_variables_in_guard_condition()
        ):
            return False

        try:
            predicate = Predicate(self.guard_condition)
            is_startable_via_link = not predicate.evaluate({})
            structlogger.debug(
                "flow.is_startable_only_via_link.result",
                predicate=self.guard_condition,
                is_startable_via_link=is_startable_via_link,
                flow_id=self.id,
            )
            return is_startable_via_link
        # if there is any kind of exception when evaluating the predicate, the flow
        # is not startable by link or by any other means.
        except (TypeError, Exception) as e:
            structlogger.error(
                "flow.is_startable_only_via_link.error",
                predicate=self.guard_condition,
                error=str(e),
                flow_id=self.id,
            )
            return False

    def _contains_variables_in_guard_condition(self) -> bool:
        """Determines if the guard condition contains dynamic literals.

        I.e. literals that cannot be statically resolved, indicating a variable.

        Returns:
            True if dynamic literals are present, False otherwise.
        """
        from pypred import ast
        from pypred.tiler import tile, SimplePattern

        if not self.guard_condition:
            return False

        predicate = Predicate(self.guard_condition)

        # find all literals in the AST tree
        literals = []
        tile(
            ast=predicate.ast,
            patterns=[SimplePattern("types:Literal")],
            func=lambda _, literal: literals.append(literal),
        )

        # check if there is a literal that cannot be statically resolved (variable)
        for literal in literals:
            if type(predicate.static_resolve(literal.value)) == ast.Undefined:
                return True

        return False

    def extract_all_paths(self) -> FlowPathsList:
        """Extracts all possible flow paths.

        Extracts all possible flow paths from a given flow structure by
        recursively exploring each step.
        This function initializes an empty list to collect paths, an empty path list,
        and a set of visited step IDs to prevent revisiting steps.
        It calls `go_over_steps` to recursively explore and fill the paths list.
        """
        all_paths = FlowPathsList(self.id, paths=[])
        start_step: FlowStep = self.first_step_in_flow()
        current_path: FlowPath = FlowPath(flow=self.id, nodes=[])
        visited_step_ids: Set[str] = set()

        self._go_over_steps(start_step, current_path, all_paths, visited_step_ids)

        structlogger.debug(
            "shared.core.flows.flow.extract_all_paths",
            comment="Extraction complete",
            number_of_paths=len(all_paths.paths),
            flow_name=self.name,
        )
        return all_paths

    def _go_over_steps(
        self,
        current_step: FlowStep,
        current_path: FlowPath,
        all_paths: FlowPathsList,
        visited_step_ids: Set[str],
    ) -> None:
        """Processes the flow steps recursively.

        Args:
            current_step: The current step being processed.
            current_path: The current path being constructed.
            all_paths: The list where completed paths are added.
            visited_step_ids: A set of steps that have been visited to avoid cycles.

        Returns:
            None: This function modifies all_paths in place by appending new paths
            as they are found.
        """
        # Check if the step is relevant for testable_paths extraction.
        # We only create new path nodes for ActionFlowStep, CallFlowStep and
        # CollectInformationFlowStep because these are externally visible
        # changes in the assistant's behaviour (trackable in the e2e tests).
        # For other flow steps, we only follow their links.
        # We decided to ignore calls to other flows in our coverage analysis.
        should_add_node = isinstance(
            current_step, (CollectInformationFlowStep, ActionFlowStep, CallFlowStep)
        )
        if should_add_node:
            # Add current step to the current path that is being constructed.
            current_path.nodes.append(
                PathNode(
                    flow=current_path.flow,
                    step_id=current_step.id,
                    lines=current_step.metadata["line_numbers"],
                )
            )

        if current_step.id in visited_step_ids or self.is_end_of_path(current_step):
            # Found a cycle, or reached an end step, do not proceed further.
            all_paths.paths.append(copy.deepcopy(current_path))
            # Remove the last node from the path if it was added.
            if should_add_node:
                current_path.nodes.pop()
            return

        # Mark current step as visited in this path.
        visited_step_ids.add(current_step.id)

        # Iterate over all links of the current step.
        for link in current_step.next.links:
            self._handle_link(
                current_path,
                all_paths,
                visited_step_ids,
                link,
            )

        # Backtrack the current step and remove it from the path.
        visited_step_ids.remove(current_step.id)

        # Remove the last node from the path if it was added.
        if should_add_node:
            current_path.nodes.pop()

    def _handle_link(
        self,
        current_path: FlowPath,
        all_paths: FlowPathsList,
        visited_step_ids: Set[str],
        link: FlowStepLink,
    ) -> None:
        """Handles the next step in a flow.

        Args:
            current_path: The current path being constructed.
            all_paths: The list where completed paths are added.
            visited_step_ids: A set of steps that have been visited to avoid cycles.
            link: The link to be followed.

        Returns:
            None: This function modifies all_paths in place by appending new paths
            as they are found.
        """
        # StaticFlowStepLink is a direct link to the next step.
        if isinstance(link, StaticFlowStepLink):
            # Find the step by its id and continue the path.
            if step := self._get_step_by_step_id(link.target_step_id):
                self._go_over_steps(
                    step,
                    current_path,
                    all_paths,
                    visited_step_ids,
                )
                return
        # IfFlowStepLink and ElseFlowStepLink are conditional links.
        elif isinstance(link, (IfFlowStepLink, ElseFlowStepLink)):
            if isinstance(link.target_reference, FlowStepSequence):
                # If the target is a FlowStepSequence, we need to go over all
                # child steps of the sequence.
                for child_step in link.target_reference.child_steps:
                    self._go_over_steps(
                        child_step,
                        current_path,
                        all_paths,
                        visited_step_ids,
                    )
                return
            else:
                # Find the step by its id and continue the path.
                if step := self._get_step_by_step_id(link.target_reference):
                    self._go_over_steps(
                        step,
                        current_path,
                        all_paths,
                        visited_step_ids,
                    )
                    return

    def is_end_of_path(self, step: FlowStep) -> bool:
        """Check if there is no path available from the current step."""
        if (
            len(step.next.links) == 1
            and isinstance(step.next.links[0], StaticFlowStepLink)
            and step.next.links[0].target == END_STEP
        ):
            return True
        return False

    def _get_step_by_step_id(
        self,
        step_id: Optional[str],
    ) -> Optional[FlowStep]:
        """Get a step by its id from a list of steps."""
        for step in self.steps:
            if step.id == step_id:
                return step
        return None
