# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from botframework.connector.auth import (
    ClaimsIdentity,
    SkillValidation,
    AuthenticationConstants,
    GovernmentConstants,
)
from botbuilder.core import BotAdapter, StatePropertyAccessor, TurnContext
from botbuilder.core.skills import SkillHandler, SkillConversationReference
import botbuilder.dialogs as dialogs  # pylint: disable=unused-import
from botbuilder.dialogs.memory import DialogStateManager
from botbuilder.dialogs.dialog_context import DialogContext
from botbuilder.dialogs.dialog_turn_result import DialogTurnResult
from botbuilder.dialogs import (
    DialogEvents,
    DialogSet,
    DialogTurnStatus,
)
from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes


class DialogExtensions:
    @staticmethod
    async def run_dialog(
        dialog: "dialogs.Dialog",
        turn_context: TurnContext,
        accessor: StatePropertyAccessor,
    ):
        """
        Creates a dialog stack and starts a dialog, pushing it onto the stack.
        """

        dialog_set = DialogSet(accessor)
        dialog_set.add(dialog)

        dialog_context: DialogContext = await dialog_set.create_context(turn_context)

        await DialogExtensions._internal_run(turn_context, dialog.id, dialog_context)

    @staticmethod
    async def _internal_run(
        context: TurnContext, dialog_id: str, dialog_context: DialogContext
    ) -> DialogTurnResult:
        # map TurnState into root dialog context.services
        for key, service in context.turn_state.items():
            dialog_context.services[key] = service

        # get the DialogStateManager configuration
        dialog_state_manager = DialogStateManager(dialog_context)
        await dialog_state_manager.load_all_scopes()
        dialog_context.context.turn_state[
            dialog_state_manager.__class__.__name__
        ] = dialog_state_manager

        # Loop as long as we are getting valid OnError handled we should continue executing the actions for the turn.

        # NOTE: We loop around this block because each pass through we either complete the turn and break out of the
        # loop or we have had an exception AND there was an OnError action which captured the error.  We need to
        # continue the turn based on the actions the OnError handler introduced.
        end_of_turn = False
        while not end_of_turn:
            try:
                dialog_turn_result = await DialogExtensions.__inner_run(
                    context, dialog_id, dialog_context
                )

                # turn successfully completed, break the loop
                end_of_turn = True
            except Exception as err:
                # fire error event, bubbling from the leaf.
                handled = await dialog_context.emit_event(
                    DialogEvents.error, err, bubble=True, from_leaf=True
                )

                if not handled:
                    # error was NOT handled, throw the exception and end the turn. (This will trigger the
                    # Adapter.OnError handler and end the entire dialog stack)
                    raise

        # save all state scopes to their respective botState locations.
        await dialog_state_manager.save_all_changes()

        # return the redundant result because the DialogManager contract expects it
        return dialog_turn_result

    @staticmethod
    async def __inner_run(
        turn_context: TurnContext, dialog_id: str, dialog_context: DialogContext
    ) -> DialogTurnResult:
        # Handle EoC and Reprompt event from a parent bot (can be root bot to skill or skill to skill)
        if DialogExtensions.__is_from_parent_to_skill(turn_context):
            # Handle remote cancellation request from parent.
            if turn_context.activity.type == ActivityTypes.end_of_conversation:
                if not dialog_context.stack:
                    # No dialogs to cancel, just return.
                    return DialogTurnResult(DialogTurnStatus.Empty)

                # Send cancellation message to the dialog to ensure all the parents are canceled
                # in the right order.
                return await dialog_context.cancel_all_dialogs(True)

            # Handle a reprompt event sent from the parent.
            if (
                turn_context.activity.type == ActivityTypes.event
                and turn_context.activity.name == DialogEvents.reprompt_dialog
            ):
                if not dialog_context.stack:
                    # No dialogs to reprompt, just return.
                    return DialogTurnResult(DialogTurnStatus.Empty)

                await dialog_context.reprompt_dialog()
                return DialogTurnResult(DialogTurnStatus.Waiting)

        # Continue or start the dialog.
        result = await dialog_context.continue_dialog()
        if result.status == DialogTurnStatus.Empty:
            result = await dialog_context.begin_dialog(dialog_id)

        await DialogExtensions._send_state_snapshot_trace(dialog_context)

        # Skills should send EoC when the dialog completes.
        if (
            result.status == DialogTurnStatus.Complete
            or result.status == DialogTurnStatus.Cancelled
        ):
            if DialogExtensions.__send_eoc_to_parent(turn_context):
                activity = Activity(
                    type=ActivityTypes.end_of_conversation,
                    value=result.result,
                    locale=turn_context.activity.locale,
                    code=EndOfConversationCodes.completed_successfully
                    if result.status == DialogTurnStatus.Complete
                    else EndOfConversationCodes.user_cancelled,
                )
                await turn_context.send_activity(activity)

        return result

    @staticmethod
    def __is_from_parent_to_skill(turn_context: TurnContext) -> bool:
        if turn_context.turn_state.get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY):
            return False

        claims_identity = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY)
        return isinstance(
            claims_identity, ClaimsIdentity
        ) and SkillValidation.is_skill_claim(claims_identity.claims)

    @staticmethod
    async def _send_state_snapshot_trace(dialog_context: DialogContext):
        """
        Helper to send a trace activity with a memory snapshot of the active dialog DC.
        :param dialog_context:
        :return:
        """
        claims_identity = dialog_context.context.turn_state.get(
            BotAdapter.BOT_IDENTITY_KEY, None
        )
        trace_label = (
            "Skill State"
            if isinstance(claims_identity, ClaimsIdentity)
            and SkillValidation.is_skill_claim(claims_identity.claims)
            else "Bot State"
        )
        # send trace of memory
        snapshot = DialogExtensions._get_active_dialog_context(
            dialog_context
        ).state.get_memory_snapshot()
        trace_activity = Activity.create_trace_activity(
            "BotState",
            "https://www.botframework.com/schemas/botState",
            snapshot,
            trace_label,
        )
        await dialog_context.context.send_activity(trace_activity)

    @staticmethod
    def __send_eoc_to_parent(turn_context: TurnContext) -> bool:
        claims_identity = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY)
        if isinstance(
            claims_identity, ClaimsIdentity
        ) and SkillValidation.is_skill_claim(claims_identity.claims):
            # EoC Activities returned by skills are bounced back to the bot by SkillHandler.
            # In those cases we will have a SkillConversationReference instance in state.
            skill_conversation_reference: SkillConversationReference = (
                turn_context.turn_state.get(
                    SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY
                )
            )
            if skill_conversation_reference:
                # If the skillConversationReference.OAuthScope is for one of the supported channels,
                # we are at the root and we should not send an EoC.
                return (
                    skill_conversation_reference.oauth_scope
                    != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
                    and skill_conversation_reference.oauth_scope
                    != GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
                )
            return True

        return False

    @staticmethod
    def _get_active_dialog_context(dialog_context: DialogContext) -> DialogContext:
        """
        Recursively walk up the DC stack to find the active DC.
        :param dialog_context:
        :return:
        """
        child = dialog_context.child
        if not child:
            return dialog_context

        return DialogExtensions._get_active_dialog_context(child)
