import asyncio
import logging
from dataclasses import dataclass, field
from io import BytesIO
from typing import Any, Optional

import flet as ft
import flet.canvas as fc

_MATPLOTLIB_IMPORT_ERROR: Optional[ImportError] = None

try:
    import matplotlib  # type: ignore[import]
    from matplotlib.figure import Figure  # type: ignore[import]
except ImportError as e:  # pragma: no cover - depends on optional dependency
    matplotlib = None  # type: ignore[assignment]
    Figure = Any  # type: ignore[assignment]
    _MATPLOTLIB_IMPORT_ERROR = e
else:
    matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg")

__all__ = [
    "MatplotlibChart",
    "MatplotlibChartMessageEvent",
    "MatplotlibChartToolbarButtonsUpdateEvent",
]

logger = logging.getLogger("flet-charts.matplotlib")

figure_cursors = {
    "default": None,
    "pointer": ft.MouseCursor.CLICK,
    "crosshair": ft.MouseCursor.PRECISE,
    "move": ft.MouseCursor.MOVE,
    "wait": ft.MouseCursor.WAIT,
    "ew-resize": ft.MouseCursor.RESIZE_LEFT_RIGHT,
    "ns-resize": ft.MouseCursor.RESIZE_UP_DOWN,
}


def _require_matplotlib() -> None:
    """
    Ensure optional matplotlib dependency is available.

    Raises:
        ModuleNotFoundError: If `matplotlib` is not installed.
    """

    if matplotlib is None:
        raise ModuleNotFoundError(
            'Install "matplotlib" Python package to use MatplotlibChart control.'
        ) from _MATPLOTLIB_IMPORT_ERROR


@dataclass
class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]):
    """
    Event carrying status text produced by the Matplotlib backend.
    """

    message: str
    """
    Message text.
    """


@dataclass
class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]):
    """
    Event describing enabled/disabled state changes for toolbar navigation buttons.
    """

    back_enabled: bool
    """
    Whether Back button is enabled or not.
    """
    forward_enabled: bool
    """
    Whether Forward button is enabled or not.
    """


@ft.control(kw_only=True, isolated=True)
class MatplotlibChart(ft.GestureDetector):
    """
    Displays a [Matplotlib](https://matplotlib.org/) chart.

    To display a Matplotlib figure with a built-in toolbar UI, use
    [`MatplotlibChartWithToolbar`][flet_charts.matplotlib_chart_with_toolbar.].

    Warning:
        This control requires the [`matplotlib`](https://matplotlib.org/)
        Python package to be installed.

        See this [installation guide](index.md#installation) for more information.
    """

    figure: Figure = field(metadata={"skip": True})
    """
    Matplotlib figure to draw - an instance of
    [`matplotlib.figure.Figure`](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure).
    """

    on_message: Optional[ft.EventHandler[MatplotlibChartMessageEvent]] = None
    """
    The event is triggered on figure message update.
    """

    on_toolbar_buttons_update: Optional[
        ft.EventHandler[MatplotlibChartToolbarButtonsUpdateEvent]
    ] = None
    """
    Triggers when toolbar buttons status is updated.
    """

    def init(self):
        _require_matplotlib()
        super().init()

    def build(self):
        self.mouse_cursor = ft.MouseCursor.WAIT
        self.__started = False
        self.__dpr = self.page.media.device_pixel_ratio
        logger.debug(f"DPR: {self.__dpr}")
        self.__image_mode = "full"

        self.canvas = fc.Canvas(
            # resize_interval=10,
            on_resize=self._on_canvas_resize,
            expand=True,
        )
        self.keyboard_listener = ft.KeyboardListener(
            self.canvas,
            autofocus=True,
            on_key_down=self._on_key_down,
            on_key_up=self._on_key_up,
        )
        self.content = self.keyboard_listener
        self.on_enter = self._on_enter
        self.on_hover = self._on_hover
        self.on_exit = self._on_exit
        self.on_pan_start = self._pan_start
        self.on_pan_update = self._pan_update
        self.on_pan_end = self._pan_end
        self.on_right_pan_start = self._right_pan_start
        self.on_right_pan_update = self._right_pan_update
        self.on_right_pan_end = self._right_pan_end
        self.img_count = 1
        self._receive_queue = asyncio.Queue()
        self._main_loop = asyncio.get_event_loop()
        self._width = 0
        self._height = 0
        self._waiting = False

    def _on_key_down(self, e):
        """
        Handle key-down notifications from keyboard listener.

        Args:
            e: Keyboard event payload.
        """

        logger.debug(f"ON KEY DOWN: {e}")

    def _on_key_up(self, e):
        """
        Handle key-up notifications from keyboard listener.

        Args:
            e: Keyboard event payload.
        """

        logger.debug(f"ON KEY UP: {e}")

    def _on_enter(self, e: ft.HoverEvent):
        """
        Notify backend that pointer entered the chart area.

        Args:
            e: Hover event containing local pointer coordinates.
        """

        logger.debug(f"_on_enter: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "figure_enter",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 0,
                "modifiers": [],
            }
        )

    def _on_hover(self, e: ft.HoverEvent):
        """
        Notify backend about pointer movement over chart area.

        Args:
            e: Hover event containing local pointer coordinates.
        """

        logger.debug(f"_on_hover: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "motion_notify",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 0,
                "modifiers": [],
            }
        )

    def _on_exit(self, e: ft.HoverEvent):
        """
        Notify backend that pointer left the chart area.

        Args:
            e: Hover event containing local pointer coordinates.
        """

        logger.debug(f"_on_exit: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "figure_leave",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 0,
                "modifiers": [],
            }
        )

    def _pan_start(self, e: ft.DragStartEvent):
        """
        Start primary-button drag interaction.

        Args:
            e: Drag start event containing local pointer coordinates.
        """

        logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}")
        asyncio.create_task(self.keyboard_listener.focus())
        self.send_message(
            {
                "type": "button_press",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 1,
                "modifiers": [],
            }
        )

    def _pan_update(self, e: ft.DragUpdateEvent):
        """
        Continue primary-button drag interaction.

        Args:
            e: Drag update event containing local pointer coordinates.
        """

        logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "motion_notify",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 1,
                "modifiers": [],
            }
        )

    def _pan_end(self, e: ft.DragEndEvent):
        """
        End primary-button drag interaction.

        Args:
            e: Drag end event containing local pointer coordinates.
        """

        logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "button_release",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 0,
                "modifiers": [],
            }
        )

    def _right_pan_start(self, e: ft.PointerEvent):
        """
        Start secondary-button drag interaction.

        Args:
            e: Pointer event containing local pointer coordinates.
        """

        logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "button_press",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 2,
                "buttons": 2,
                "modifiers": [],
            }
        )

    def _right_pan_update(self, e: ft.PointerEvent):
        """
        Continue secondary-button drag interaction.

        Args:
            e: Pointer event containing local pointer coordinates.
        """

        logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "motion_notify",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 0,
                "buttons": 2,
                "modifiers": [],
            }
        )

    def _right_pan_end(self, e: ft.PointerEvent):
        """
        End secondary-button drag interaction.

        Args:
            e: Pointer event containing local pointer coordinates.
        """

        logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}")
        self.send_message(
            {
                "type": "button_release",
                "x": e.local_position.x * self.__dpr,
                "y": e.local_position.y * self.__dpr,
                "button": 2,
                "buttons": 0,
                "modifiers": [],
            }
        )

    def will_unmount(self):
        """
        Called when the control is about to be removed from the page.
        """
        self.figure.canvas.manager.remove_web_socket(self)

    def home(self):
        """
        Resets the view to the original state.
        """
        logger.debug("home)")
        self.send_message({"type": "toolbar_button", "name": "home"})

    def back(self):
        """
        Goes back to the previous view.
        """
        logger.debug("back()")
        self.send_message({"type": "toolbar_button", "name": "back"})

    def forward(self):
        """
        Goes forward to the next view.
        """
        logger.debug("forward)")
        self.send_message({"type": "toolbar_button", "name": "forward"})

    def pan(self):
        """
        Activates the pan tool.
        """
        logger.debug("pan()")
        self.send_message({"type": "toolbar_button", "name": "pan"})

    def zoom(self):
        """
        Activates the zoom tool.
        """
        logger.debug("zoom()")
        self.send_message({"type": "toolbar_button", "name": "zoom"})

    def download(self, format) -> bytes:
        """
        Downloads the current figure in the specified format.
        Args:
            format (str): The format to download the figure in (e.g., 'png',
                'jpg', 'svg', etc.).
        Returns:
            bytes: The figure image in the specified format as a byte array.
        """
        logger.debug(f"Download in format: {format}")
        buff = BytesIO()
        self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr)
        return buff.getvalue()

    async def _receive_loop(self):
        """
        Consume backend messages and apply canvas/state updates.

        The loop handles both binary image frames and JSON control messages
        (cursor updates, draw requests, rubber-band overlays, status text, and
        toolbar history state).
        """

        while True:
            is_binary, content = await self._receive_queue.get()
            if is_binary:
                logger.debug(f"receive_binary({len(content)})")
                if self.__image_mode == "full":
                    await self.canvas.clear_capture()

                self.canvas.shapes = [
                    fc.Image(
                        src=content,
                        x=0,
                        y=0,
                        width=self.figure.bbox.size[0] / self.__dpr,
                        height=self.figure.bbox.size[1] / self.__dpr,
                    )
                ]
                ft.context.disable_auto_update()
                self.canvas.update()
                await self.canvas.capture()
                self.img_count += 1
                self._waiting = False
            else:
                logger.debug(f"receive_json({content})")
                if content["type"] == "image_mode":
                    self.__image_mode = content["mode"]
                elif content["type"] == "cursor":
                    self.mouse_cursor = figure_cursors[content["cursor"]]
                    self.update()
                elif content["type"] == "draw" and not self._waiting:
                    self._waiting = True
                    self.send_message({"type": "draw"})
                elif content["type"] == "rubberband":
                    if len(self.canvas.shapes) == 2:
                        self.canvas.shapes.pop()
                    if (
                        content["x0"] != -1
                        and content["y0"] != -1
                        and content["x1"] != -1
                        and content["y1"] != -1
                    ):
                        x0 = content["x0"] / self.__dpr
                        y0 = self._height - content["y0"] / self.__dpr
                        x1 = content["x1"] / self.__dpr
                        y1 = self._height - content["y1"] / self.__dpr
                        self.canvas.shapes.append(
                            fc.Rect(
                                x=x0,
                                y=y0,
                                width=x1 - x0,
                                height=y1 - y0,
                                paint=ft.Paint(
                                    stroke_width=1, style=ft.PaintingStyle.STROKE
                                ),
                            )
                        )
                    self.canvas.update()
                elif content["type"] == "resize":
                    self.send_message({"type": "refresh"})
                elif content["type"] == "message":
                    await self._trigger_event(
                        "message", {"message": content["message"]}
                    )
                elif content["type"] == "history_buttons":
                    await self._trigger_event(
                        "toolbar_buttons_update",
                        {
                            "back_enabled": content["Back"],
                            "forward_enabled": content["Forward"],
                        },
                    )

    def send_message(self, message):
        """Sends a message to the figure's canvas manager."""
        logger.debug(f"send_message({message})")
        manager = self.figure.canvas.manager
        if manager is not None:
            manager.handle_json(message)

    def send_json(self, content):
        """Sends a JSON message to the front end."""
        logger.debug(f"send_json: {content}")
        self._main_loop.call_soon_threadsafe(
            lambda: self._receive_queue.put_nowait((False, content))
        )

    def send_binary(self, blob):
        """Sends a binary message to the front end."""
        self._main_loop.call_soon_threadsafe(
            lambda: self._receive_queue.put_nowait((True, blob))
        )

    async def _on_canvas_resize(self, e: fc.CanvasResizeEvent):
        """
        Handle canvas resize and initialize backend session on first resize.

        On first call, starts receive loop, registers this control with figure
        manager, and requests initial image state. On every call, stores current
        dimensions and sends a resize message to backend.

        Args:
            e: Canvas resize event.
        """

        logger.debug(f"on_canvas_resize: {e.width}, {e.height}")

        if not self.__started:
            self.__started = True
            asyncio.create_task(self._receive_loop())
            self.figure.canvas.manager.add_web_socket(self)
            self.send_message({"type": "send_image_mode"})
            self.send_message(
                {"type": "set_device_pixel_ratio", "device_pixel_ratio": self.__dpr}
            )
            self.send_message({"type": "refresh"})
        self._width = e.width
        self._height = e.height
        self.send_message(
            {"type": "resize", "width": self._width, "height": self._height}
        )
