from collections.abc import Iterator
from pathlib import Path
import shutil
from types import SimpleNamespace

from PyQt6.QtCore import (
    QCoreApplication,
    QEvent,
    QSettings,
)
from PyQt6.QtWidgets import (
    QApplication,
    QWidget,
)
import pyqtgraph as pg
from pyqtgraph import ViewBox
import pytest
from pytestqt.qtbot import QtBot

from piker import config


_PREEXISTING_SETTING: str = 'pytest/preexisting'
_OWNED_SETTING: str = 'pytest/owned'


@pytest.fixture(scope='session', autouse=True)
def ui_process_sentinel(
    isolated_xdg_config_home: Path,

) -> Iterator[Path]:
    '''
    Keep known process-owned state across all UI tests.

    '''
    # TODO: expose public config-dir get/set APIs like
    # `modden.config.dirs.get_conf_dir()` and `set_conf_dir()` so
    # tests do not reach into Piker's import-cached path globals.
    config_dir: Path = config._click_config_dir
    config_dir.mkdir(parents=True, exist_ok=True)
    sentinel_path: Path = config_dir / 'pytest-preexisting'
    sentinel_existed: bool = sentinel_path.exists()
    sentinel_bytes: bytes|None = None
    if sentinel_existed:
        sentinel_bytes = sentinel_path.read_bytes()
    sentinel_path.write_text('keep-me', encoding='utf-8')

    settings: QSettings = QSettings('pikers', 'piker')
    settings.setFallbacksEnabled(False)
    setting_existed: bool = settings.contains(
        _PREEXISTING_SETTING,
    )
    setting_value: object = settings.value(
        _PREEXISTING_SETTING,
    )
    settings.setValue(_PREEXISTING_SETTING, 'keep-me')
    settings.sync()

    try:
        assert config_dir.is_relative_to(isolated_xdg_config_home)
        yield sentinel_path
    finally:
        if setting_existed:
            settings.setValue(
                _PREEXISTING_SETTING,
                setting_value,
            )
        else:
            settings.remove(_PREEXISTING_SETTING)
        settings.sync()

        if sentinel_existed:
            assert sentinel_bytes is not None
            sentinel_path.write_bytes(sentinel_bytes)
        else:
            sentinel_path.unlink(missing_ok=True)


@pytest.fixture(autouse=True)
def isolated_ui_state(
    qapp: QApplication,
    qtbot: QtBot,
    tmp_path: Path,
    isolated_xdg_config_home: Path,
    ui_process_sentinel: Path,

) -> Iterator[SimpleNamespace]:
    '''
    Restore mutable process state after one real Qt test.

    Pytest-qt owns the shared `QApplication` and closes widgets
    before this fixture unwinds. This guard snapshots the remaining
    process globals, removes exact test-owned config, and reports any
    widget or PyQtGraph object that survived normal pytest-qt
    cleanup.

    '''
    top_levels: set[QWidget] = set(
        qapp.topLevelWidgets()
    )
    all_views: set[ViewBox] = set(ViewBox.AllViews)
    named_views: dict[str, ViewBox] = dict(ViewBox.NamedViews)
    pg_options: dict[str, object] = dict(pg.CONFIG_OPTIONS)
    quit_on_last_window: bool = (
        qapp.quitOnLastWindowClosed()
    )
    config_paths: dict[str, Path] = {
        '_click_config_dir': config._click_config_dir,
        '_config_dir': config._config_dir,
        '_watchlists_data_path': config._watchlists_data_path,
    }
    settings: QSettings = QSettings('pikers', 'piker')
    settings.setFallbacksEnabled(False)
    settings_state: dict[str, object] = {
        key: settings.value(key)
        for key in settings.allKeys()
    }
    test_config_dir: Path = tmp_path / 'piker-config'
    test_config_dir.mkdir()
    state: SimpleNamespace = SimpleNamespace(
        qapp=qapp,
        process_config_home=isolated_xdg_config_home,
        preexisting_config=ui_process_sentinel,
        test_config_dir=test_config_dir,
        preexisting_setting=_PREEXISTING_SETTING,
        owned_setting=_OWNED_SETTING,
    )

    try:
        yield state
    finally:
        # Collect every isolation failure so cleanup of one process
        # global cannot prevent restoration of the remaining globals.
        errors: list[Exception] = []

        # Pytest-qt closes registered widgets before fixture
        # teardown, but Qt completes `QWidget.deleteLater()`
        # asynchronously.
        QCoreApplication.sendPostedEvents(
            None,
            QEvent.Type.DeferredDelete,
        )
        qapp.processEvents()

        # Any addition to `QApplication.topLevelWidgets()` after the
        # deferred-delete drain is a widget pytest-qt failed to own.
        leaked_widgets: set[QWidget] = (
            set(qapp.topLevelWidgets()) - top_levels
        )
        for widget in leaked_widgets:
            widget.close()
            widget.deleteLater()
        QCoreApplication.sendPostedEvents(
            None,
            QEvent.Type.DeferredDelete,
        )
        qapp.processEvents()
        if leaked_widgets:
            errors.append(AssertionError(
                'pytest-qt left test-owned top-level widgets alive: '
                f'{leaked_widgets!r}'
            ))

        # `ViewBox.AllViews` and `ViewBox.NamedViews` are
        # process-wide weak registries used for linked-axis
        # discovery.
        # Close leaked views, report either registry delta, then
        # restore the exact baseline so a failure cannot contaminate
        # the next test.
        leaked_views: set[ViewBox] = (
            set(ViewBox.AllViews) - all_views
        )
        for view in leaked_views:
            view.close()
        if leaked_views:
            errors.append(AssertionError(
                'test-owned `ViewBox.AllViews` entries survived: '
                f'{leaked_views!r}'
            ))
        named_views_changed: bool = (
            dict(ViewBox.NamedViews) != named_views
        )
        if named_views_changed:
            errors.append(AssertionError(
                'test mutated `ViewBox.NamedViews` without cleanup'
            ))
        ViewBox.AllViews.clear()
        ViewBox.AllViews.update({
            view: None
            for view in all_views
        })
        ViewBox.NamedViews.clear()
        ViewBox.NamedViews.update(named_views)
        ViewBox.updateAllViewLists()

        # PyQtGraph options and Qt's last-window policy are mutable
        # process globals, not properties owned by one test widget.
        pg.CONFIG_OPTIONS.clear()
        pg.CONFIG_OPTIONS.update(pg_options)
        qapp.setQuitOnLastWindowClosed(quit_on_last_window)

        # Rebuild this test application's `QSettings` keys exactly:
        # test-owned keys disappear while pre-existing values
        # survive.
        for key in settings.allKeys():
            settings.remove(key)
        for key, value in settings_state.items():
            settings.setValue(key, value)
        settings.sync()

        # Restore Piker's import-cached path globals before deleting
        # the function-owned config tree to prevent a dangling path.
        for name, path in config_paths.items():
            setattr(config, name, path)
        shutil.rmtree(test_config_dir, ignore_errors=True)

        # Re-read every restored process value instead of trusting
        # the cleanup assignments; each mismatch joins one report.
        if dict(pg.CONFIG_OPTIONS) != pg_options:
            errors.append(AssertionError(
                '`pyqtgraph.CONFIG_OPTIONS` was not restored'
            ))
        if qapp.quitOnLastWindowClosed() != quit_on_last_window:
            errors.append(AssertionError(
                '`QApplication.quitOnLastWindowClosed()` changed'
            ))
        if {
            name: getattr(config, name)
            for name in config_paths
        } != config_paths:
            errors.append(AssertionError(
                'Piker config path globals were not restored'
            ))
        if {
            key: settings.value(key)
            for key in settings.allKeys()
        } != settings_state:
            errors.append(AssertionError(
                '`QSettings` keys were not restored exactly'
            ))
        if test_config_dir.exists():
            errors.append(AssertionError(
                'test-owned config directory survived teardown'
            ))

        # Raise only after all restoration and verification
        # completes, preserving each leak signal for diagnosis.
        if errors:
            raise ExceptionGroup(
                'UI isolation teardown failed',
                errors,
            )
