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

from copy import deepcopy
from typing import List

from botframework.connector.token_api.models import TokenExchangeRequest
from botbuilder.schema import (
    Activity,
    ActivityTypes,
    ExpectedReplies,
    DeliveryModes,
    SignInConstants,
    TokenExchangeInvokeRequest,
)
from botbuilder.core import BotAdapter, TurnContext, ExtendedUserTokenProvider
from botbuilder.core.card_factory import ContentTypes
from botbuilder.core.skills import SkillConversationIdFactoryOptions
from botbuilder.dialogs import (
    Dialog,
    DialogContext,
    DialogEvents,
    DialogReason,
    DialogInstance,
)

from .begin_skill_dialog_options import BeginSkillDialogOptions
from .skill_dialog_options import SkillDialogOptions


class SkillDialog(Dialog):
    SKILLCONVERSATIONIDSTATEKEY = (
        "Microsoft.Bot.Builder.Dialogs.SkillDialog.SkillConversationId"
    )

    def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str):
        super().__init__(dialog_id)
        if not dialog_options:
            raise TypeError("SkillDialog.__init__(): dialog_options cannot be None.")

        self.dialog_options = dialog_options
        self._deliver_mode_state_key = "deliverymode"

    async def begin_dialog(self, dialog_context: DialogContext, options: object = None):
        """
        Method called when a new dialog has been pushed onto the stack and is being activated.
        :param dialog_context: The dialog context for the current turn of conversation.
        :param options: (Optional) additional argument(s) to pass to the dialog being started.
        """
        dialog_args = self._validate_begin_dialog_args(options)

        # Create deep clone of the original activity to avoid altering it before forwarding it.
        skill_activity: Activity = deepcopy(dialog_args.activity)

        # Apply conversation reference and common properties from incoming activity before sending.
        TurnContext.apply_conversation_reference(
            skill_activity,
            TurnContext.get_conversation_reference(dialog_context.context.activity),
            is_incoming=True,
        )

        # Store delivery mode in dialog state for later use.
        dialog_context.active_dialog.state[
            self._deliver_mode_state_key
        ] = dialog_args.activity.delivery_mode

        # Create the conversationId and store it in the dialog context state so we can use it later
        skill_conversation_id = await self._create_skill_conversation_id(
            dialog_context.context, dialog_context.context.activity
        )
        dialog_context.active_dialog.state[
            SkillDialog.SKILLCONVERSATIONIDSTATEKEY
        ] = skill_conversation_id

        # Send the activity to the skill.
        eoc_activity = await self._send_to_skill(
            dialog_context.context, skill_activity, skill_conversation_id
        )
        if eoc_activity:
            return await dialog_context.end_dialog(eoc_activity.value)

        return self.end_of_turn

    async def continue_dialog(self, dialog_context: DialogContext):
        if not self._on_validate_activity(dialog_context.context.activity):
            return self.end_of_turn

        # Handle EndOfConversation from the skill (this will be sent to the this dialog by the SkillHandler if
        # received from the Skill)
        if dialog_context.context.activity.type == ActivityTypes.end_of_conversation:
            return await dialog_context.end_dialog(
                dialog_context.context.activity.value
            )

        # Create deep clone of the original activity to avoid altering it before forwarding it.
        skill_activity = deepcopy(dialog_context.context.activity)

        skill_activity.delivery_mode = dialog_context.active_dialog.state[
            self._deliver_mode_state_key
        ]

        # Just forward to the remote skill
        skill_conversation_id = dialog_context.active_dialog.state[
            SkillDialog.SKILLCONVERSATIONIDSTATEKEY
        ]
        eoc_activity = await self._send_to_skill(
            dialog_context.context, skill_activity, skill_conversation_id
        )
        if eoc_activity:
            return await dialog_context.end_dialog(eoc_activity.value)

        return self.end_of_turn

    async def reprompt_dialog(  # pylint: disable=unused-argument
        self, context: TurnContext, instance: DialogInstance
    ):
        # Create and send an event to the skill so it can resume the dialog.
        reprompt_event = Activity(
            type=ActivityTypes.event, name=DialogEvents.reprompt_dialog
        )

        # Apply conversation reference and common properties from incoming activity before sending.
        TurnContext.apply_conversation_reference(
            reprompt_event,
            TurnContext.get_conversation_reference(context.activity),
            is_incoming=True,
        )

        # connection Name is not applicable for a RePrompt, as we don't expect as OAuthCard in response.
        skill_conversation_id = instance.state[SkillDialog.SKILLCONVERSATIONIDSTATEKEY]
        await self._send_to_skill(context, reprompt_event, skill_conversation_id)

    async def resume_dialog(  # pylint: disable=unused-argument
        self, dialog_context: "DialogContext", reason: DialogReason, result: object
    ):
        await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog)
        return self.end_of_turn

    async def end_dialog(
        self, context: TurnContext, instance: DialogInstance, reason: DialogReason
    ):
        # Send of of conversation to the skill if the dialog has been cancelled.
        if reason in (DialogReason.CancelCalled, DialogReason.ReplaceCalled):
            activity = Activity(type=ActivityTypes.end_of_conversation)

            # Apply conversation reference and common properties from incoming activity before sending.
            TurnContext.apply_conversation_reference(
                activity,
                TurnContext.get_conversation_reference(context.activity),
                is_incoming=True,
            )
            activity.channel_data = context.activity.channel_data
            activity.additional_properties = context.activity.additional_properties

            # connection Name is not applicable for an EndDialog, as we don't expect as OAuthCard in response.
            skill_conversation_id = instance.state[
                SkillDialog.SKILLCONVERSATIONIDSTATEKEY
            ]
            await self._send_to_skill(context, activity, skill_conversation_id)

        await super().end_dialog(context, instance, reason)

    def _validate_begin_dialog_args(self, options: object) -> BeginSkillDialogOptions:
        if not options:
            raise TypeError("options cannot be None.")

        dialog_args = BeginSkillDialogOptions.from_object(options)

        if not dialog_args:
            raise TypeError(
                "SkillDialog: options object not valid as BeginSkillDialogOptions."
            )

        if not dialog_args.activity:
            raise TypeError(
                "SkillDialog: activity object in options as BeginSkillDialogOptions cannot be None."
            )

        return dialog_args

    def _on_validate_activity(
        self, activity: Activity  # pylint: disable=unused-argument
    ) -> bool:
        """
        Validates the activity sent during continue_dialog.

        Override this method to implement a custom validator for the activity being sent during continue_dialog.
        This method can be used to ignore activities of a certain type if needed.
        If this method returns false, the dialog will end the turn without processing the activity.
        """
        return True

    async def _send_to_skill(
        self, context: TurnContext, activity: Activity, skill_conversation_id: str
    ) -> Activity:
        if activity.type == ActivityTypes.invoke:
            # Force ExpectReplies for invoke activities so we can get the replies right away and send
            # them back to the channel if needed. This makes sure that the dialog will receive the Invoke
            # response from the skill and any other activities sent, including EoC.
            activity.delivery_mode = DeliveryModes.expect_replies

        # Always save state before forwarding
        # (the dialog stack won't get updated with the skillDialog and things won't work if you don't)
        await self.dialog_options.conversation_state.save_changes(context, True)

        skill_info = self.dialog_options.skill
        response = await self.dialog_options.skill_client.post_activity(
            self.dialog_options.bot_id,
            skill_info.app_id,
            skill_info.skill_endpoint,
            self.dialog_options.skill_host_endpoint,
            skill_conversation_id,
            activity,
        )

        # Inspect the skill response status
        if not 200 <= response.status <= 299:
            raise Exception(
                f'Error invoking the skill id: "{skill_info.id}" at "{skill_info.skill_endpoint}"'
                f" (status is {response.status}). \r\n {response.body}"
            )

        eoc_activity: Activity = None
        if activity.delivery_mode == DeliveryModes.expect_replies and response.body:
            # Process replies in the response.Body.
            response.body: List[Activity]
            response.body = ExpectedReplies().deserialize(response.body).activities
            # Track sent invoke responses, so more than one is not sent.
            sent_invoke_response = False

            for from_skill_activity in response.body:
                if from_skill_activity.type == ActivityTypes.end_of_conversation:
                    # Capture the EndOfConversation activity if it was sent from skill
                    eoc_activity = from_skill_activity

                    # The conversation has ended, so cleanup the conversation id
                    await self.dialog_options.conversation_id_factory.delete_conversation_reference(
                        skill_conversation_id
                    )
                elif not sent_invoke_response and await self._intercept_oauth_cards(
                    context, from_skill_activity, self.dialog_options.connection_name
                ):
                    # Token exchange succeeded, so no oauthcard needs to be shown to the user
                    sent_invoke_response = True
                else:
                    # If an invoke response has already been sent we should ignore future invoke responses as this
                    # represents a bug in the skill.
                    if from_skill_activity.type == ActivityTypes.invoke_response:
                        if sent_invoke_response:
                            continue
                        sent_invoke_response = True
                    # Send the response back to the channel.
                    await context.send_activity(from_skill_activity)

        return eoc_activity

    async def _create_skill_conversation_id(
        self, context: TurnContext, activity: Activity
    ) -> str:
        # Create a conversationId to interact with the skill and send the activity
        conversation_id_factory_options = SkillConversationIdFactoryOptions(
            from_bot_oauth_scope=context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY),
            from_bot_id=self.dialog_options.bot_id,
            activity=activity,
            bot_framework_skill=self.dialog_options.skill,
        )
        skill_conversation_id = await self.dialog_options.conversation_id_factory.create_skill_conversation_id(
            conversation_id_factory_options
        )
        return skill_conversation_id

    async def _intercept_oauth_cards(
        self, context: TurnContext, activity: Activity, connection_name: str
    ):
        """
        Tells is if we should intercept the OAuthCard message.
        """
        if not connection_name or not isinstance(
            context.adapter, ExtendedUserTokenProvider
        ):
            # The adapter may choose not to support token exchange, in which case we fallback to
            # showing an oauth card to the user.
            return False

        oauth_card_attachment = next(
            attachment
            for attachment in activity.attachments
            if attachment.content_type == ContentTypes.oauth_card
        )
        if oauth_card_attachment:
            oauth_card = oauth_card_attachment.content
            if (
                oauth_card
                and oauth_card.token_exchange_resource
                and oauth_card.token_exchange_resource.uri
            ):
                try:
                    result = await context.adapter.exchange_token(
                        turn_context=context,
                        connection_name=connection_name,
                        user_id=context.activity.from_property.id,
                        exchange_request=TokenExchangeRequest(
                            uri=oauth_card.token_exchange_resource.uri
                        ),
                    )

                    if result and result.token:
                        # If token above is null, then SSO has failed and hence we return false.
                        # If not, send an invoke to the skill with the token.
                        return await self._send_token_exchange_invoke_to_skill(
                            activity,
                            oauth_card.token_exchange_resource.id,
                            oauth_card.connection_name,
                            result.token,
                        )
                except:
                    # Failures in token exchange are not fatal. They simply mean that the user needs
                    # to be shown the OAuth card.
                    return False

        return False

    async def _send_token_exchange_invoke_to_skill(
        self,
        incoming_activity: Activity,
        request_id: str,
        connection_name: str,
        token: str,
    ):
        activity = incoming_activity.create_reply()
        activity.type = ActivityTypes.invoke
        activity.name = SignInConstants.token_exchange_operation_name
        activity.value = TokenExchangeInvokeRequest(
            id=request_id,
            token=token,
            connection_name=connection_name,
        )

        # route the activity to the skill
        skill_info = self.dialog_options.skill
        response = await self.dialog_options.skill_client.post_activity(
            self.dialog_options.bot_id,
            skill_info.app_id,
            skill_info.skill_endpoint,
            self.dialog_options.skill_host_endpoint,
            incoming_activity.conversation.id,
            activity,
        )

        # Check response status: true if success, false if failure
        return response.is_successful_status_code()
