from __future__ import annotations

from collections.abc import Callable
from itertools import zip_longest
from logging import getLogger
from pathlib import Path
from types import get_original_bases
from typing import TYPE_CHECKING, Any, get_args, get_origin
from weakref import WeakKeyDictionary

import vapoursynth as vs
from pydantic import BaseModel
from PySide6.QtCore import QMetaObject, QObject, QRect, Signal
from PySide6.QtGui import QContextMenuEvent, QKeyEvent, QMouseEvent
from PySide6.QtWidgets import QDockWidget, QSplitter, QTabWidget, QWidget

from vsview.app.outputs import VideoOutput
from vsview.app.settings import SecretsManager, SettingsManager
from vsview.app.utils import ObjectType
from vsview.app.views.timeline import Timeline
from vsview.app.views.video import GraphicsView
from vsview.vsenv.loop import run_in_loop

if TYPE_CHECKING:
    from vsview.app.workspace.loader import LoaderWorkspace
    from vsview.app.workspace.playback import PlaybackManager

    from .api import PluginGraphicsView, VideoOutputProxy, WidgetPluginBase, _PluginBase

logger = getLogger(__name__)


class _SettingsProxy[T: BaseModel]:
    """
    Proxy that intercepts `__setattr__` on a pydantic BaseModel and auto-persists the change via the supplied callback.
    Reads are delegated transparently to the underlying model.
    """

    __slots__ = ("_model", "_on_update")

    def __init__(self, model: T, on_update: Callable[[str, Any], None]) -> None:
        object.__setattr__(self, "_model", model)
        object.__setattr__(self, "_on_update", on_update)

    def __getattr__(self, name: str) -> Any:
        attr = getattr(self._model, name)
        return _SettingsProxy(attr, lambda _, __: self._on_update(name, attr)) if isinstance(attr, BaseModel) else attr

    def __setattr__(self, name: str, value: Any) -> None:
        if name in self.__slots__:
            object.__setattr__(self, name, value)
        elif hasattr(self._model, name):
            if isinstance(value, _SettingsProxy):
                value = value._model

            setattr(self._model, name, value)
            self._on_update(name, value)
        else:
            raise AttributeError(f"{type(self._model).__name__!r} object has no attribute {name!r}")

    def __repr__(self) -> str:
        return repr(self._model)

    def __eq__(self, other: object) -> bool:
        return self._model == other._model if isinstance(other, _SettingsProxy) else self._model == other


class _PluginSettingsStore:
    def __init__(self, workspace: LoaderWorkspace[Any]) -> None:
        self._workspace = workspace
        self._caches: dict[str, WeakKeyDictionary[_PluginBase[Any, Any], BaseModel]] = {
            "global": WeakKeyDictionary(),
            "local": WeakKeyDictionary(),
        }

    @property
    def file_path(self) -> Path | None:
        from vsview.app.workspace.file import GenericFileWorkspace

        return self._workspace.content if isinstance(self._workspace, GenericFileWorkspace) else None

    def get(self, plugin: _PluginBase[Any, Any], scope: str) -> BaseModel | None:
        cache = self._caches[scope]

        if plugin in cache:
            return cache[plugin]

        model_cls: type[BaseModel] | None = getattr(plugin, f"{scope}_settings_model")
        if model_cls is None:
            return None

        # Fetch and validate
        settings = model_cls.model_validate(self._get_raw_settings(plugin.identifier, scope))

        # Resolve local settings with global fallbacks
        if scope == "local":
            from .api import LocalSettingsModel

            if isinstance(settings, LocalSettingsModel) and (global_settings := self.get(plugin, "global")):
                settings = settings.resolve(global_settings)

        cache[plugin] = settings
        return settings

    def update(self, plugin: _PluginBase[Any, Any], scope: str, **updates: Any) -> None:
        model_cls: type[BaseModel] | None = getattr(plugin, f"{scope}_settings_model")

        if model_cls is None:
            logger.warning("No model for plugin %r scope %r", plugin.identifier, scope)
            return

        # Always update against the raw (unresolved) state
        raw_data = self._get_raw_settings(plugin.identifier, scope)
        settings = model_cls.model_validate(raw_data)

        for key, value in updates.items():
            setattr(settings, key, value)

        self._set_raw_settings(plugin.identifier, scope, settings)
        self._caches[scope].pop(plugin, None)

    def invalidate(self, scope: str) -> None:
        self._caches[scope].clear()

    def _get_raw_settings(self, plugin_id: str, scope: str) -> dict[str, Any]:
        if scope == "global":
            container = SettingsManager.global_settings
        elif self.file_path is not None:
            container = SettingsManager.get_local_settings(self.file_path)
        else:
            return {}

        raw = container.plugins.get(plugin_id, {})
        return raw if isinstance(raw, dict) else raw.model_dump()

    def _set_raw_settings(self, plugin_id: str, scope: str, settings: BaseModel) -> None:
        if scope == "global":
            SettingsManager.global_settings.plugins[plugin_id] = settings
        elif self.file_path is not None:
            SettingsManager.get_local_settings(self.file_path).plugins[plugin_id] = settings


def _make_voutput_proxy(voutput: VideoOutput) -> VideoOutputProxy:
    from .api import VideoOutputProxy

    return VideoOutputProxy(
        voutput.vs_index,
        voutput.vs_name,
        voutput.vs_output,
        voutput.props,
        tuple(voutput.framedurs) if voutput.framedurs is not None else None,
        tuple(voutput.cum_durations) if voutput.cum_durations is not None else None,
        voutput.info,
    )


class _PluginAPI(QObject):
    statusMessage = Signal(str)
    globalSettingsChanged = Signal()
    localSettingsChanged = Signal(str)
    aboutToSaveGlobal = Signal()
    aboutToSaveLocal = Signal(str)

    def __init__(self, workspace: LoaderWorkspace[Any]) -> None:
        super().__init__()
        self.__workspace = workspace
        self.__settings_store: _PluginSettingsStore | None = None

        SettingsManager.signals.globalChanged.connect(self._on_global_settings_changed)
        SettingsManager.signals.localChanged.connect(self._on_local_settings_changed)
        SettingsManager.signals.aboutToSaveGlobal.connect(self.aboutToSaveGlobal.emit)
        SettingsManager.signals.aboutToSaveLocal.connect(self.aboutToSaveLocal.emit)

    @property
    def voutputs(self) -> list[VideoOutputProxy]:
        """Return a dictionary of VideoOutputProxy objects for all tabs."""
        return [_make_voutput_proxy(voutput) for voutput in self.__workspace.outputs_manager.voutputs]

    @property
    def current_voutput(self) -> VideoOutputProxy:
        """Return the VideoOutput for the currently selected tab."""
        if voutput := self.__workspace.outputs_manager.current_voutput:
            return _make_voutput_proxy(voutput)

        # This shouldn't happen
        raise NotImplementedError

    # PRIVATE API
    @property
    def _settings_store(self) -> _PluginSettingsStore:
        if self.__settings_store is None:
            self.__settings_store = _PluginSettingsStore(self.__workspace)
        return self.__settings_store

    @run_in_loop(return_future=False)
    def _is_truly_visible(self, plugin: WidgetPluginBase[Any, Any]) -> bool:
        # Check if this plugin is truly visible to the user.

        # This accounts for:
        # - Widgets in a QTabWidget that are not the current tab
        # - Widgets in a QDockWidget that is tabified and not visible
        # - Widgets in a QSplitter panel that is collapsed (size=0)
        if not plugin.isVisible():
            return False

        widget: QObject | None = plugin

        while widget:
            parent = widget.parent()

            # Plugin is not the current tab (compare against self, not intermediate widget)
            if isinstance(parent, QTabWidget) and parent.currentWidget() is not plugin:
                return False

            # Dock widget is tabified and not visible
            if isinstance(parent, QDockWidget) and parent.visibleRegion().isEmpty():
                return False

            # Check if our panel in the splitter is collapsed
            if (
                isinstance(parent, QSplitter)
                and isinstance(widget, QWidget)
                and (idx := parent.indexOf(widget)) >= 0
                and parent.sizes()[idx] == 0
            ):
                return False

            widget = parent

        return True

    def _register_plugin_nodes_to_buffer(self) -> None:
        # Register visible plugin nodes with the buffer for pre-fetching during playback.
        from .api import PluginGraphicsView

        for plugin in self.__workspace.plugins:
            if not self._is_truly_visible(plugin):
                continue

            for view in self.__workspace.loop.from_thread(plugin.findChildren, PluginGraphicsView).result():
                if view.current_tab in view.outputs and self.__workspace.playback.state.buffer:
                    self.__workspace.playback.state.buffer.register_plugin_node(
                        plugin.identifier, view.outputs[view.current_tab]
                    )

    def _on_current_voutput_changed(self, refresh: bool = False) -> None:
        # Notify all visible plugin views of output change.
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                self._init_plugin(plugin, refresh)

    def _init_plugin(self, plugin: WidgetPluginBase[Any, Any], refresh: bool = False) -> None:
        # Initialize plugin for the current output and render initial frame if needed.
        from .api import PluginGraphicsView

        if not self._is_truly_visible(plugin):
            return

        try:
            plugin.on_current_voutput_changed(
                self.current_voutput,
                self.__workspace.outputs_manager.current_video_index,
            )
        except Exception:
            logger.exception("on_current_voutput_changed: Failed to initialize plugin %r", plugin)
            return

        try:
            plugin.on_current_frame_changed(self.__workspace.playback.state.current_frame)
        except Exception:
            logger.exception("on_current_frame_changed: Failed to initialize plugin %r", plugin)
            return

        for view in self.__workspace.loop.from_thread(plugin.findChildren, PluginGraphicsView).result():
            try:
                self._init_view(view, plugin, refresh)
            except Exception:
                logger.exception("Failed to initialize view %r", view)

    @run_in_loop(return_future=False)
    def _find_plugin_for_widget(self, widget: QWidget) -> WidgetPluginBase[Any, Any] | None:
        from .api import WidgetPluginBase

        current: QObject | None = widget.parent()

        while current is not None:
            if isinstance(current, WidgetPluginBase):
                return current
            current = current.parent()
        logger.warning("Could not find plugin for widget %r", widget)
        return None

    def _init_view(
        self, view: PluginGraphicsView, plugin: WidgetPluginBase[Any, Any] | None = None, refresh: bool = False
    ) -> None:
        if plugin is None and (plugin := self._find_plugin_for_widget(view)) is None:
            return

        if not self._is_truly_visible(plugin):
            return

        tab_index = self.__workspace.outputs_manager.current_video_index
        current_frame = self.__workspace.playback.state.current_frame

        # Detect if we are actually changing tabs or forcing a refresh
        image_changed = view.current_tab != tab_index or view.last_frame != current_frame
        view.current_tab = tab_index
        view.last_frame = current_frame

        logger.debug("Initializing view: %s, tab=%d (changed=%s), refresh=%s", view, tab_index, image_changed, refresh)

        if refresh:
            view.outputs.clear()

        if tab_index not in view.outputs:
            with self.__workspace.env.use():
                node = view.get_node(self.current_voutput.vs_output.clip)
                packed = self.__workspace.outputs_manager.packer.pack_clip(node)
                view.outputs[tab_index] = packed
                logger.debug("Created output node for tab %d", tab_index)

        with self.__workspace.env.use():
            view.on_current_voutput_changed(self.current_voutput, tab_index)

        if view.pixmap_item.pixmap().isNull() or image_changed or refresh:
            with self.__workspace.env.use(), view.outputs[tab_index].get_frame(current_frame) as frame:
                logger.debug("Rendering initial frame %d for view", current_frame)
                view.on_current_frame_changed(current_frame, frame)

        view.update_scene_rect()
        view.set_autofit(view.autofit)

    def _on_current_frame_changed(self, n: int, plugin_frames: dict[str, vs.VideoFrame] | None = None) -> None:
        # Notify plugins of frame change.
        # If plugin_frames is provided, uses pre-fetched frames.
        # Otherwise, fetches frames synchronously for each plugin view.
        from .api import PluginGraphicsView

        for plugin in self.__workspace.plugins:
            if not self._is_truly_visible(plugin):
                continue

            plugin.on_current_frame_changed(n)

            for view in self.__workspace.loop.from_thread(plugin.findChildren, PluginGraphicsView).result():
                if view.current_tab == -1 or view.current_tab not in view.outputs:
                    continue

                # Get pre-fetched frame or fall back to sync request
                if plugin_frames and plugin.identifier in plugin_frames:
                    view.on_current_frame_changed(n, plugin_frames[plugin.identifier])
                else:
                    with self.__workspace.env.use(), view.outputs[view.current_tab].get_frame(n) as frame:
                        view.on_current_frame_changed(n, frame)

    def _get_cached_proxy_settings(self, plugin: _PluginBase[Any, Any], scope: str) -> Any:
        model = self._settings_store.get(plugin, scope)

        if model is None:
            return model

        return _SettingsProxy(model, lambda k, v: self._settings_store.update(plugin, scope, **{k: v}))

    def _update_settings(self, plugin: _PluginBase[Any, Any], scope: str, **updates: Any) -> None:
        self._settings_store.update(plugin, scope, **updates)

    @run_in_loop(return_future=False)
    def _on_playback_started(self) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_playback_started()

    # Already run in loop from caller
    def _on_playback_stopped(self) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_playback_stopped()

    def _on_view_context_menu(self, event: QContextMenuEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_context_menu(event)

    def _on_view_mouse_moved(self, event: QMouseEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_mouse_moved(event)

    def _on_view_mouse_pressed(self, event: QMouseEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_mouse_pressed(event)

    def _on_view_mouse_released(self, event: QMouseEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_mouse_released(event)

    def _on_view_rect_selection_changed(self, rect: QRect) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_rect_selection_changed(rect)

    def _on_view_rect_selection_finished(self, rect: QRect) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_rect_selection_finished(rect)

    def _on_view_key_press(self, event: QKeyEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_key_press(event)

    def _on_view_key_release(self, event: QKeyEvent) -> None:
        for plugin in self.__workspace.plugins:
            if self._is_truly_visible(plugin):
                plugin.on_view_key_release(event)

    def _on_global_settings_changed(self) -> None:
        self._settings_store.invalidate("global")
        self._settings_store.invalidate("local")
        self.globalSettingsChanged.emit()

    def _on_local_settings_changed(self, path: str) -> None:
        self._settings_store.invalidate("local")
        self.localSettingsChanged.emit(path)


class _PluginBaseMeta(ObjectType):
    global_settings_model: type[BaseModel] | None
    local_settings_model: type[BaseModel] | None

    def __new__[MetaSelf: _PluginBaseMeta](  # noqa: PYI019
        mcls: type[MetaSelf],
        name: str,
        bases: tuple[type, ...],
        namespace: dict[str, Any],
        /,
        **kwargs: Any,
    ) -> MetaSelf:
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)

        # Skip processing for base classes that set __plugin_base__ = True
        if namespace.get("__plugin_base__"):
            return cls

        # WidgetPluginBase and NodeProcessor are now defined so it's safe to import them
        from .api import NodeProcessor, WidgetPluginBase

        for base in get_original_bases(cls):
            if not (origin := get_origin(base)):
                continue

            args = get_args(base)

            if issubclass(origin, WidgetPluginBase):
                scope = ["global", "local"]
            elif issubclass(origin, NodeProcessor):
                scope = [None, "global", "local"]
            else:
                continue

            for n, arg in zip_longest(scope, args, fillvalue=None):
                if arg is None or not isinstance(arg, type):
                    setattr(cls, f"{n}_settings_model", None)
                    continue

                origin = get_origin(arg)
                is_basemodel = (origin is None and issubclass(arg, BaseModel)) or (
                    origin is not None and issubclass(origin, BaseModel)
                )
                setattr(cls, f"{n}_settings_model", arg if is_basemodel else None)
            break
        else:
            cls.global_settings_model, cls.local_settings_model = None, None

        return cls


class _GraphicsViewProxy(QObject):
    def __init__(self, workspace: LoaderWorkspace[Any], view: GraphicsView) -> None:
        super().__init__()
        self.__workspace = workspace
        self.__view = view


class _ViewportProxy(QObject):
    def __init__(self, workspace: LoaderWorkspace[Any], viewport: QWidget) -> None:
        super().__init__()
        self.__workspace = workspace
        self.__viewport = viewport
        self.__cursor_reset_conn: QMetaObject.Connection | None = None


class _TimelineProxy(QObject):
    def __init__(self, workspace: LoaderWorkspace[Any], timeline: Timeline) -> None:
        super().__init__()
        self.__workspace = workspace
        self.__timeline = timeline


class _PlaybackProxy(QObject):
    def __init__(self, workspace: LoaderWorkspace[Any], playback_manager: PlaybackManager) -> None:
        super().__init__()
        self.__workspace = workspace
        self.__playback_manager = playback_manager


class _PluginSecrets:
    def __init__(self, plugin: _PluginBase[Any, Any]) -> None:
        self.__namespace = plugin.identifier

    def __getattr__(self, name: str) -> Any:
        if name in dir(SecretsManager) and not name.startswith("_"):
            return lambda *args, **kwargs: getattr(SecretsManager, name)(self.__namespace, *args, **kwargs)

        raise AttributeError
