"""
whatsapp.bindings.azure
~~~~~~~~~~~~~~~~~~~~~~
Azure Binding Client for wasup.py.

:copyright: (c) 2025-present June
:license: MIT, see LICENSE for more details.
"""

import asyncio
import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union

from aiohttp.web import Application, AppRunner, Request, Response, TCPSite, json_response
from azure.communication.messages.models import (
    ActionGroup,
    ActionGroupContent,
    ActionGroupItem,
    AudioNotificationContent,
    ButtonContent,
    ButtonSetContent,
    DocumentNotificationContent,
    ImageNotificationContent,
    InteractiveMessage,
    InteractiveNotificationContent,
    LinkContent,
    ReactionNotificationContent,
    StickerNotificationContent,
    TextMessageContent,
    TextNotificationContent,
    VideoNotificationContent,
    WhatsAppButtonActionBindings,
    WhatsAppListActionBindings,
    WhatsAppUrlActionBindings,
)

from whatsapp.bindings import EMOJI_PATTERN, BaseBindingClient, ChatContext
from whatsapp.bindings.azure.models import AdvancedMessageReceivedEvent, MediaContent
from whatsapp.messages import (
    AudioMessage,
    DocumentMessage,
    ImageMessage,
    InteractiveButtonMessage,
    InteractiveListMessage,
    InteractiveUrlMessage,
    ReactionMessage,
    StickerMessage,
    TextMessage,
    VideoMessage,
)
from whatsapp.messages.dialogs import Dialog, DialogContext, DialogInstance

if TYPE_CHECKING:
    from azure.communication.messages.aio import NotificationMessagesClient

    from ... import Bot

MESSAGE_DEDUP_TTL_SECONDS = 300


class AzureBindingClient(BaseBindingClient):
    """Azure Communication Services binding client for WhatsApp.

    Handles incoming WhatsApp messages from Azure ACS, processes them through
    the dialog system, and sends responses back through the Azure SDK.
    """

    def __init__(
        self,
        ctx: "Bot",
        client: "NotificationMessagesClient",
        route: str = "/api/events",
        on_error=None,
        channel_id: str = None,
        on_message: Optional[Callable[[str, Any], Awaitable[bool]]] = None,
    ):
        """
        Initialize the Azure binding client.

        Args:
            ctx: The Bot instance.
            client: Azure NotificationMessagesClient for sending messages.
            route: HTTP route for receiving Event Grid webhooks. Defaults to "/api/events".
            on_error: Optional callback function for handling errors.
        """
        # initialize
        self.ctx = ctx
        self.client = client
        self.route = route
        self.on_error = on_error
        self.channel_id = channel_id
        self.on_message = on_message

        # message deduplication cache: {user_id: [message_ids]} (fallback when no Redis)
        self._processed_messages: dict[str, list[str]] = {}

        # load middleware
        self.inactivity_manager = ctx.inactivity_manager
        self.inactivity_manager.set_adapter(self)
        self.session_store = ctx.session_store
        self.message = self.MessageHandler(self.client)

    _MEDIA_WARNINGS = {
        "image": "Image messages are not supported. Please send a text message.",
        "video": "Video messages are not supported. Please send a text message.",
        "audio": "Audio messages are not supported. Please send a text message.",
        "voice": "Voice messages are not supported. Please send a text message.",
        "document": "Document messages are not supported. Please send a text message.",
        "sticker": "Sticker messages are not supported. Please send a text message.",
    }

    # --- Deduplication ---
    async def is_duplicate_message(self, message_id: str) -> bool:
        """Check if a message was already processed. Returns True if duplicate.

        Uses Redis SET NX when available; falls back to an in-memory per-user list.

        Args:
            message_id: The unique message ID from the event.
        """
        redis = getattr(self.session_store, "client", None)
        if redis:
            key = f"msg_processed:{message_id}"
            result = await redis.set(key, "1", nx=True, ex=MESSAGE_DEDUP_TTL_SECONDS)
            return result is None  # None means key already existed (duplicate)
        else:
            # in-memory fallback: keep last 10 per message_id (global list)
            processed = self._processed_messages.get("__global__", [])
            if message_id in processed:
                return True
            processed.append(message_id)
            self._processed_messages["__global__"] = processed[-10:]
            return False

    # --- Event Handling ---
    async def process_event(self, req: Request) -> Response:
        """
        Process incoming ACS Advanced Messaging events.

        Handles Event Grid subscription validation and processes incoming messages/status updates.
        Messages are processed asynchronously and session state is persisted.

        Args:
            req: The aiohttp Request object from Event Grid.

        Returns:
            Response with status 200 on success, 400 on bad requests.
        """
        # handle event grid subscription validation
        aeg_event_type = req.headers.get("aeg-event-type", "")
        try:
            body = await req.json()
        except Exception:
            return Response(status=400)

        # event grid may send a list of events
        events = body if isinstance(body, list) else [body]

        # subscription validation (header-based or payload-based) for event grid
        if aeg_event_type.lower() == "subscriptionvalidation":
            validation_code = events[0].get("data", {}).get("validationCode") if events else None
            if validation_code:
                return json_response({"validationResponse": validation_code})
        # old schema validation
        if events and events[0].get("eventType") == "Microsoft.EventGrid.SubscriptionValidationEvent":
            validation_code = events[0].get("data", {}).get("validationCode")
            if validation_code:
                return json_response({"validationResponse": validation_code})

        # process acs advanced messaging events
        tasks = []
        for event in events:
            event_type = event.get("eventType") or event.get("type") or ""
            data = event.get("data", {})

            if event_type.endswith("AdvancedMessageReceived"):
                try:
                    logging.info(f"Message received: {data}")
                    typed_event = AdvancedMessageReceivedEvent.from_dict(data)
                    tasks.append(asyncio.create_task(self._process_message(typed_event)))
                except Exception as e:
                    logging.error(f"Error parsing AdvancedMessageReceived event: {e}")

            elif event_type.endswith("AdvancedMessageDeliveryStatusUpdated"):
                logging.debug(f"Delivery status updated: {data}")

        if tasks:
            results = await asyncio.gather(*tasks, return_exceptions=True)
            for i, result in enumerate(results):
                if isinstance(result, Exception):
                    logging.error(f"Error processing message task {i}: {result}", exc_info=result)

        return Response(status=200)

    async def _process_message(self, event: AdvancedMessageReceivedEvent):
        """
        Process individual message events and execute dialog logic.

        Creates a chat context, retrieves or initializes session state,
        executes the dialog flow, and persists the session.

        Args:
            event: The incoming message event from Azure ACS.
        """
        if not self.client:
            logging.error("NotificationMessagesClient is not initialized. Cannot reply.")
            return

        # extract ids
        from_id = event.from_id
        to_id = event.to

        # channel filter: if channel_id is set, only process messages for that channel
        if self.channel_id and to_id != self.channel_id:
            logging.warning(f"Message for channel {to_id} ignored (expected {self.channel_id})")
            return

        # deduplication: drop messages already processed (handles Azure duplicate events)
        if await self.is_duplicate_message(event.message_id):
            logging.debug(f"Duplicate message {event.message_id} ignored")
            return

        # extract text early (needed for dc construction and emoji check)
        text = event.content
        if not text and event.interactive:
            if event.interactive.list_reply:
                text = event.interactive.list_reply.id
            elif event.interactive.button_reply:
                text = event.interactive.button_reply.id

        # retrieve session
        start = time.perf_counter_ns()
        stored_stack = await self.session_store.get(from_id)
        end = time.perf_counter_ns()
        logging.info(f"Session for {from_id} retrieved in {end - start} ns: {stored_stack}")

        stack = []
        if stored_stack:
            stack = [DialogInstance(id=item["id"], state=item.get("state", {})) for item in stored_stack]

        # resolve incoming media before building context
        message_type = event.message_type.lower() if event.message_type else "text"
        allowed_types = ["text", "interactive", "button", "reaction"]
        incoming_media = None
        if message_type not in allowed_types:
            if await self._step_accepts_media(from_id, message_type, stored_stack):
                incoming_media = event.media

        # build chat context and dialog context
        chat_ctx = ChatContext(
            client=self,
            channel_id=to_id,
            from_id=from_id,
            to=to_id,
            message_id=event.message_id,
            last_incoming_text=text,
            incoming_media=incoming_media,
        )
        chat_ctx._retrieval_time_ns = end - start

        dc = DialogContext(
            ctx=chat_ctx, dialogs=self.ctx.dialog_registry, stack=stack, session_store=self.session_store
        )

        # warn for unsupported media types
        if message_type not in allowed_types and incoming_media is None:
            warning = self._MEDIA_WARNINGS.get(
                message_type, "This message type is not supported. Please send a text message."
            )
            await dc.message.send(TextMessage(dc.ctx, warning))
            return

        # warn for emoji-only messages
        if text and EMOJI_PATTERN.search(text):
            await dc.message.send(TextMessage(dc.ctx, "Emoji messages are not supported. Please send a text message."))
            return

        # on_message hook: if provided, let caller handle the message
        if self.on_message:
            handled = await self.on_message(text, dc)
            if handled:
                if dc._finished:
                    await self.session_store.delete(from_id)
                    await self.inactivity_manager.cancel(from_id)
                else:
                    await self.session_store.set(from_id, dc.stack)
                return

        # run dialog logic
        try:
            if not dc.active_dialog:
                # start main menu dialog
                main_id = Dialog.get_main_dialog_id()
                if main_id and main_id in self.ctx.dialog_registry:
                    await dc.begin_dialog(main_id)
                else:
                    logging.error("No main dialog registered or found.")
            else:
                # continue existing dialog
                await dc.continue_dialog()

            # if dialog ended (completed/cancelled) and stack is empty, maybe restart?
            # if result and result.status == DialogTurnStatus.Complete and not dc.active_dialog:
            #     logic to restart or just stay silent?
            #     usually for a MainMenu bot, we might want to reset or just wait for next trigger.
            #     pass

        except Exception as e:
            logging.error(f"Error running dialog: {e}", exc_info=True)
            if self.on_error:
                try:
                    await self.on_error(e, dc)
                except Exception as handler_error:
                    logging.error(f"Error in on_error handler: {handler_error}", exc_info=True)

        # handle session persistence
        if dc._finished:
            # delete session and cancel timers
            await self.session_store.delete(from_id)
            await self.inactivity_manager.cancel(from_id)
        else:
            # save session
            await self.session_store.set(from_id, dc.stack)

    async def download_media(self, media: MediaContent, path: Path = None) -> bytes:
        """Download media content by ID.

        Args:
            media: The MediaContent object containing media metadata.a to download.
            path: Optional file path to save the downloaded media. If not provided, media will be returned as bytes.

        Returns:
            bytes: The downloaded media content as bytes.
        """
        logging.info(f"Downloading media with id {media.id} from Azure...")
        try:
            iterator = await self.client.download_media(media.id)
            data = b"".join([chunk async for chunk in iterator])
            logging.debug(f"Downloaded {len(data)} bytes for media {media.id}")

            if path:
                full_path = path / media.file_name
                try:
                    with open(full_path, "wb") as f:
                        f.write(data)
                        logging.info(f"Media {media.id} saved to {full_path}")
                except IOError as e:
                    logging.error(f"Failed to write media {media.id} to {full_path}: {e}")
                    raise

            return data
        except Exception as e:
            logging.error(f"Error downloading media {media.id}: {e}", exc_info=True)
            raise

    # --- Callable ---
    async def run(self):
        """
        Run the Azure Binding Client.

        Starts an aiohttp web server listening for Event Grid webhooks
        on the configured route and port. Blocks until interrupted.
        """
        # initialize server
        app = Application()  # TODO: add error middleware
        app.router.add_post(self.route, self.process_event)

        # start server using AppRunner to avoid threading issues
        runner = AppRunner(app)
        await runner.setup()
        site = TCPSite(runner, "localhost", self.ctx.port)
        await site.start()
        logging.info(f"Azure Binding Server started on port {self.ctx.port}")

        # keep the server running
        try:
            await asyncio.Event().wait()
        except KeyboardInterrupt:
            logging.info("Shutting down Azure Binding Server...")
            await runner.cleanup()

    # --- Message Wrappers ---
    class MessageHandler:
        """Message handler for parsing and sending messages through Azure ACS."""

        def __init__(self, client: "NotificationMessagesClient"):
            """
            Initialize the message handler.

            Args:
                client: The Azure NotificationMessagesClient.
            """
            self.client = client

        async def parse_message(
            self,
            msg: Union[
                TextMessage,
                ImageMessage,
                DocumentMessage,
                VideoMessage,
                AudioMessage,
                StickerMessage,
                ReactionMessage,
                InteractiveListMessage,
                InteractiveButtonMessage,
                InteractiveUrlMessage,
            ],
        ) -> Any:
            """
            Converts message objects into ACS Notification Content.

            Args:
                msg: The message object to parse (any supported message type).

            Returns:
                Azure SDK notification content object ready to send.

            Raises:
                ValueError: If the message type is not supported.
            """
            message_type = type(msg).__name__  # using this as match/case checks patterns in order
            logging.debug(
                f"Parsing message of type: {message_type}"
            )  # since each message_type is inherited from TextMessage

            match message_type:
                case "TextMessage":
                    logging.debug(f"TextMessage content: {msg.text}")
                    return TextNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], content=msg.text
                    )

                case "ImageMessage":
                    logging.debug(f"ImageMessage URI: {msg.media_uri}")
                    return ImageNotificationContent(
                        channel_registration_id=msg.channel_id,
                        to=[msg.to],
                        media_uri=msg.media_uri,
                        content=f"{msg.caption}",
                    )

                case "DocumentMessage":
                    logging.debug(f"DocumentMessage URI: {msg.media_uri}")
                    return DocumentNotificationContent(
                        channel_registration_id=msg.channel_id,
                        to=[msg.to],
                        media_uri=msg.media_uri,
                        file_name=msg.file_name,
                        caption=msg.caption,
                    )

                case "VideoMessage":
                    logging.debug(f"VideoMessage URI: {msg.media_uri}")
                    return VideoNotificationContent(
                        channel_registration_id=msg.channel_id,
                        to=[msg.to],
                        media_uri=msg.media_uri,
                        caption=msg.caption,
                    )

                case "AudioMessage":
                    logging.debug(f"AudioMessage URI: {msg.media_uri}")
                    return AudioNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], media_uri=msg.media_uri
                    )

                case "StickerMessage":
                    logging.debug(f"StickerMessage URI: {msg.media_uri}")
                    return StickerNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], media_uri=msg.media_uri
                    )

                case "ReactionMessage":
                    logging.debug(f"ReactionMessage: {msg.emoji} to message {msg.message_id}")
                    return ReactionNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], emoji=msg.emoji, message_id=msg.message_id
                    )

                case "InteractiveListMessage":
                    logging.debug(f"InteractiveListMessage with {len(msg.groups)} groups")
                    # build list action groups
                    groups = []
                    for group in msg.groups:
                        items = [
                            ActionGroupItem(id=item.id, title=item.title, description=item.description)
                            for item in group.items
                        ]
                        groups.append(ActionGroup(title=group.title, items_property=items))

                    action_content = ActionGroupContent(title=msg.button_title, groups=groups)

                    # build interactive message
                    interactive_msg = InteractiveMessage(
                        body=TextMessageContent(text=msg.body),
                        action=WhatsAppListActionBindings(content=action_content),
                    )

                    # add optional parameters if parsed
                    if msg.header:
                        interactive_msg.header = TextMessageContent(text=msg.header)
                    if msg.footer:
                        interactive_msg.footer = TextMessageContent(text=msg.footer)

                    return InteractiveNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], interactive_message=interactive_msg
                    )

                case "InteractiveButtonMessage":
                    logging.debug(f"InteractiveButtonMessage with {len(msg.buttons)} buttons")
                    # build button set
                    buttons = [ButtonContent(id=btn.id, title=btn.title) for btn in msg.buttons]
                    button_set = ButtonSetContent(buttons=buttons)

                    # build interactive message
                    interactive_msg = InteractiveMessage(
                        body=TextMessageContent(text=msg.body), action=WhatsAppButtonActionBindings(content=button_set)
                    )

                    # add optional parameters if parsed
                    if msg.header:
                        interactive_msg.header = TextMessageContent(text=msg.header)
                    if msg.footer:
                        interactive_msg.footer = TextMessageContent(text=msg.footer)

                    return InteractiveNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], interactive_message=interactive_msg
                    )

                case "InteractiveUrlMessage":
                    logging.debug(f"InteractiveUrlMessage with URL: {msg.url}")
                    # build URL link content
                    link_content = LinkContent(title=msg.title, url=msg.url)

                    # build interactive message
                    interactive_msg = InteractiveMessage(
                        body=TextMessageContent(text=msg.body), action=WhatsAppUrlActionBindings(content=link_content)
                    )

                    # add optional parameters if parsed
                    if msg.header:
                        interactive_msg.header = TextMessageContent(text=msg.header)
                    if msg.footer:
                        interactive_msg.footer = TextMessageContent(text=msg.footer)

                    return InteractiveNotificationContent(
                        channel_registration_id=msg.channel_id, to=[msg.to], interactive_message=interactive_msg
                    )

                case _:
                    raise ValueError(f"Unsupported message type: {message_type}")

        async def send_message(
            self,
            message: Union[
                TextMessage,
                ImageMessage,
                DocumentMessage,
                VideoMessage,
                AudioMessage,
                StickerMessage,
                ReactionMessage,
                InteractiveListMessage,
                InteractiveButtonMessage,
                InteractiveUrlMessage,
            ],
        ) -> Any:
            """
            Send a message through ACS Advanced Messaging.

            Parses the message object to Azure notification content and sends it
            via the Azure SDK.

            Args:
                message: The message to send (any supported message type).

            Returns:
                The response from the Azure SDK send operation.

            Raises:
                Exception: If message sending fails.
            """
            try:
                # parse message to ACS notification content
                content = await self.parse_message(message)

                logging.info(f"Sending {content} via ACS")

                # send via Azure SDK
                resp = await self.client.send(content)

                if resp is not None:
                    logging.info(f"Message sent successfully: {resp}")
                    return resp
                else:
                    logging.error("Failed to send message: No response received")
                    return None

            except Exception as e:
                logging.error(f"Error sending message: {e}", exc_info=True)
                raise
