from __future__ import annotations

from dataclasses import is_dataclass
from enum import Enum
import importlib.util
from pathlib import Path
import re

import numpy as np
import pytest

import pyvista as pv
from pyvista import _vtk
from pyvista.core._vtk_utilities import DisableVtkSnakeCase
from pyvista.core._vtk_utilities import VTKObjectWrapperCheckSnakeCase
from pyvista.core._vtk_utilities import vtkPyVistaOverride
from pyvista.core.errors import PyVistaAttributeError
from pyvista.core.errors import VTKVersionError
from pyvista.core.utilities.misc import _NoNewAttrMixin
from pyvista.plotting.charts import _vtkWrapper


def test_vtk_namespace():
    # Test vtk class not defined in namespace
    match = (
        "'does_not_exist' is not defined in PyVista's vtk namespace.\n"
        'Developers should add a new `module:does_not_exist` mapping to the `_vtk` module.'
    )
    with pytest.raises(AttributeError, match=re.escape(match)):
        _ = _vtk.does_not_exist


@pytest.mark.needs_vtk_version((9, 3, 0), reason='Test hangs in CI on Linux')
def test_vtk_module_does_not_exist(monkeypatch):
    # Test module does not exist
    cls, module = 'foo', 'bar'
    monkeypatch.setitem(_vtk._VTK_CLASS_TO_MODULE, cls, module)
    assert cls in _vtk._VTK_CLASS_TO_MODULE
    match = (
        f"Cannot import name {cls!r} from 'vtkmodules.{module}'.\n"
        'The cause is likely attributable to VTK version or a custom VTK build.'
    )
    with pytest.raises(ImportError, match=match):
        _ = getattr(_vtk, cls)


@pytest.mark.needs_vtk_version((9, 5, 0), reason='Test hangs in CI on Linux')
def test_vtk_class_does_not_exist(monkeypatch):
    # Test module exists, but class does not
    cls, module = 'foo', 'vtkCommonCore'
    monkeypatch.setitem(_vtk._VTK_CLASS_TO_MODULE, cls, module)
    assert 'foo' in _vtk._VTK_CLASS_TO_MODULE
    match = (
        f"Cannot import name {cls!r} from 'vtkmodules.{module}'.\n"
        'The cause is likely attributable to VTK version or a custom VTK build.'
    )
    with pytest.raises(ImportError, match=match):
        _ = getattr(_vtk, cls)


def get_all_pyvista_classes() -> tuple[tuple[str, ...], tuple[type, ...]]:
    """Return all classes defined in the pyvista package."""
    class_types: set[type] = set()

    package_path = Path(pv.__path__[0])  # path to pyvista package

    def find_py_files(path: Path):
        return [p for p in path.rglob('*.py') if p.name != '__init__.py']

    core_py_files = find_py_files(package_path / 'core')
    plotting_py_files = find_py_files(package_path / 'plotting')

    for file_path in [*core_py_files, *plotting_py_files]:
        # Convert path to dotted module name
        rel_path = file_path.relative_to(package_path.parent)
        module_name = '.'.join(rel_path.with_suffix('').parts)

        module = importlib.import_module(module_name)

        for name in dir(module):
            obj = getattr(module, name)
            if isinstance(obj, type) and getattr(obj, '__module__', '') == module_name:
                class_types.add(obj)

    # Sort and return names and types as separate tuples
    sorted_classes = sorted(class_types, key=lambda cls: cls.__name__)
    return tuple(cls.__name__ for cls in sorted_classes), tuple(sorted_classes)


def pytest_generate_tests(metafunc):
    """Generate parametrized tests."""
    if 'vtk_subclass' in metafunc.fixturenames:
        # This test automatically collects all pyvista classes that _inherit_ from
        # vtk classes. For classes that wrap vtk classes through composition, we
        # manually add these here
        VTK_SUBCLASS_BY_COMPOSITION: set[type] = {pv.pyvista_ndarray}

        def get_vtk_subclasses_through_composition() -> dict[str, type]:
            return {cls.__name__: cls for cls in VTK_SUBCLASS_BY_COMPOSITION}

        def get_vtk_subclasses_through_inheritance() -> dict[str, type]:
            class_names, class_types = get_all_pyvista_classes()

            def inherits_from_vtk(klass):
                bases = klass.__mro__[1:]
                return any(base.__name__[:3].lower() == 'vtk' for base in bases)

            inherits_from_vtk = {
                name: cls
                for name, cls in zip(class_names, class_types, strict=True)
                if inherits_from_vtk(cls)
            }
            assert inherits_from_vtk
            return inherits_from_vtk

        inheritance = get_vtk_subclasses_through_inheritance()
        composition = get_vtk_subclasses_through_composition()
        inheritance.update(composition)

        names = inheritance.keys()
        types = inheritance.values()
        metafunc.parametrize('vtk_subclass', types, ids=names)

    if 'pyvista_class' in metafunc.fixturenames:
        class_names, class_types = get_all_pyvista_classes()

        SKIP_SUBCLASS = {Warning, Exception, tuple, Enum}

        class_map = {
            name: cls
            for name, cls in zip(class_names, class_types, strict=True)
            if not name.startswith('_')
            and not issubclass(cls, tuple(SKIP_SUBCLASS))
            and not getattr(cls, '_is_protocol', False)
        }
        metafunc.parametrize('pyvista_class', list(class_map.values()), ids=list(class_map.keys()))


def try_init_pyvista_object(class_):
    # Init object but skip if abstract
    kwargs = get_default_class_init_kwargs(class_)
    try:
        instance = class_(**kwargs)
    except (ImportError, VTKVersionError):
        pytest.skip('VTK Version not supported.')
    except TypeError as e:
        if 'abstract' in repr(e):
            pytest.skip('Class is abstract.')
        raise
    return instance


def get_default_class_init_kwargs(pyvista_class):
    # Define kwargs as required for initializing some classes
    kwargs = {}
    if pyvista_class is pv.CubeAxesActor:
        kwargs['camera'] = pv.Camera()
    elif pyvista_class is pv.Renderer:
        kwargs['parent'] = pv.Plotter()
    elif pyvista_class in [pv.ChartBox, pv.ChartPie]:
        kwargs['data'] = list(range(10))
    elif pyvista_class is pv.CompositeAttributes:
        kwargs['mapper'] = pv.CompositePolyDataMapper()
        kwargs['dataset'] = pv.MultiBlock()
    elif pyvista_class is pv.CornerAnnotation:
        kwargs['position'] = 'top'
        kwargs['text'] = 'text'
    elif pyvista_class is pv.plotting.utilities.algorithms.ActiveScalarsAlgorithm:
        kwargs['name'] = 'name'
    elif pyvista_class is pv.plotting.utilities.algorithms.CallbackFilterAlgorithm:
        kwargs['callback'] = lambda ds: ds
    elif pyvista_class is pv.plotting.utilities.algorithms.SourceAlgorithm:
        kwargs['generator'] = pv.Sphere
    elif pyvista_class is pv.charts.Charts:
        kwargs['renderer'] = pv.Renderer(pv.Plotter())
    elif pyvista_class is pv.charts.AreaPlot:
        kwargs['chart'] = pv.charts.Chart2D()
        kwargs['x'] = (0, 0, 0)
        kwargs['y1'] = (1, 0, 0)
    elif pyvista_class in [
        pv.charts.BarPlot,
        pv.charts.LinePlot2D,
        pv.charts.ScatterPlot2D,
    ]:
        kwargs['chart'] = pv.charts.Chart2D()
        kwargs['x'] = (0, 0, 0)
        kwargs['y'] = (1, 0, 0)
    elif pyvista_class is pv.charts.StackPlot:
        kwargs['chart'] = pv.charts.Chart2D()
        kwargs['x'] = (0, 0, 0)
        kwargs['ys'] = (1, 0, 0)
    elif pyvista_class in [pv.charts.BoxPlot, pv.charts.PiePlot]:
        kwargs['chart'] = pv.charts.Chart2D()
        kwargs['data'] = [0, 0, 0]
    elif pyvista_class is pv.charts._ChartBackground:
        kwargs['chart'] = pv.charts.Chart2D()
    elif pyvista_class is pv.plotting.background_renderer.BackgroundRenderer:
        kwargs['parent'] = pv.Plotter()
        kwargs['image_path'] = pv.examples.logofile
    elif pyvista_class is pv.pyvista_ndarray:
        kwargs['array'] = pv.vtk_points(np.eye(3)).GetData()
    elif pyvista_class is pv.ActorProperties:
        kwargs['properties'] = pv.Property()
    elif pyvista_class is pv.AffineWidget3D:
        kwargs['plotter'] = pv.Plotter()
        kwargs['actor'] = pv.Actor()
    elif pyvista_class in (pv.PickingComponent, pv.WidgetComponent):
        kwargs['plotter'] = pv.Plotter()
    elif pyvista_class is pv.BlockAttributes:
        dataset = pv.ImageData()
        kwargs['block'] = dataset
        kwargs['attr'] = pv.CompositeAttributes(
            pv.plotting.composite_mapper.CompositePolyDataMapper(), dataset
        )
    elif pyvista_class is pv.CameraPosition:
        kwargs['position'] = (1, 2, 3)
        kwargs['focal_point'] = (1, 2, 3)
        kwargs['viewup'] = (1, 2, 3)
    elif pyvista_class is pv.plotting.renderer.RenderPasses:
        kwargs['renderer'] = pv.Renderer(pv.Plotter())
    elif (
        pyvista_class is pv.plotting.plotter.Renderers
        or pyvista_class is pv.RenderWindowInteractor
        or pyvista_class is pv.plotting.plotter.ScalarBars
    ):
        kwargs['plotter'] = pv.Plotter()
    elif pyvista_class is pv.Timer:
        kwargs['max_steps'] = 0
        kwargs['callback'] = lambda x: x
    elif pyvista_class is pv.DataSetAttributes:
        dataset = pv.ImageData()
        kwargs['vtkobject'] = dataset.GetPointData()
        kwargs['dataset'] = dataset
        kwargs['association'] = pv.FieldAssociation.POINT
    elif pyvista_class is pv.ProgressMonitor:
        kwargs['algorithm'] = pv.plotting.utilities.algorithms.CrinkleAlgorithm
    elif pyvista_class is pv.plotting.lookup_table.lookup_table_ndarray:
        kwargs['array'] = pv.convert_array(np.array((1, 2, 3)))
    elif pyvista_class is pv.plotting.picking.RectangleSelection:
        kwargs['frustum'] = None
        kwargs['viewport'] = None
    elif issubclass(
        pyvista_class, pv.plotting.render_window_interactor.InteractorStyleCaptureMixin
    ):
        kwargs['render_window_interactor'] = pv.Plotter().iren
    elif issubclass(pyvista_class, pv.BaseReader):
        kwargs['path'] = __file__  # Dummy file to pass is_file() checks
    elif pyvista_class is pv.XMLPartitionedDataSetWriter:
        kwargs['path'] = ''
        kwargs['data_object'] = pv.PartitionedDataSet()
    elif issubclass(pyvista_class, pv.BaseWriter):
        kwargs['path'] = ''
        kwargs['data_object'] = pv.PolyData()
    return kwargs


def test_vtk_snake_case_api_is_disabled(vtk_subclass):
    if vtk_subclass is VTKObjectWrapperCheckSnakeCase:
        pytest.skip('Class is effectively abstract.')

    assert pv.vtk_snake_case() == 'error'

    # Default test values for classes
    vtk_attr_camel_case = 'GetGlobalWarningDisplay'
    vtk_attr_snake_case = 'global_warning_display'

    if vtk_subclass is pv.pyvista_ndarray:
        vtk_attr_camel_case = 'GetName'
        vtk_attr_snake_case = 'name'

    instance = try_init_pyvista_object(vtk_subclass)

    # Make sure the CamelCase attribute exists and can be accessed
    assert hasattr(instance, vtk_attr_camel_case)

    if pv.vtk_version_info >= (9, 4):
        # Test getting the snake_case equivalent raises error

        try:
            getattr(instance, vtk_attr_snake_case)
        except PyVistaAttributeError as e:
            # Test passes, we want an error to be raised
            # Confirm error message is correct
            match = (
                f"The attribute '{vtk_attr_snake_case}' is defined by VTK and is not part of "
                f'the PyVista API'
            )
            assert match in repr(e)  # noqa: PT017
        else:
            if DisableVtkSnakeCase not in vtk_subclass.__mro__:
                msg = (
                    f'The class {vtk_subclass.__name__!r} in {vtk_subclass.__module__!r}\n'
                    f'must inherit from {DisableVtkSnakeCase.__name__!r} in '
                    f'{DisableVtkSnakeCase.__module__!r}'
                )
            else:
                msg = f'{PyVistaAttributeError.__name__} was NOT raised (but was expected).'
            pytest.fail(msg)
    else:
        assert not hasattr(instance, vtk_attr_snake_case)


def test_dir_hides_vtk_inherited_attributes(vtk_subclass):
    """``__dir__`` omits VTK-inherited attributes but they remain callable."""
    if vtk_subclass is VTKObjectWrapperCheckSnakeCase:
        pytest.skip('Class is effectively abstract.')
    if DisableVtkSnakeCase not in vtk_subclass.__mro__:
        pytest.skip(f'{vtk_subclass.__name__!r} does not use {DisableVtkSnakeCase.__name__!r}.')

    assert pv.global_config.show_vtk_api is False  # default

    instance = try_init_pyvista_object(vtk_subclass)
    listing = dir(instance)

    # VTK attributes inherited from ``vtkObjectBase`` / ``vtkObject`` exist on
    # every VTK subclass; they should be hidden from ``dir`` but remain
    # accessible on the instance.
    for vtk_name in ('AddObserver', 'GetClassName', 'GetMTime'):
        assert vtk_name not in listing
        assert callable(getattr(instance, vtk_name))

    # The listing should be sorted and unique.
    assert listing == sorted(set(listing))


def test_dir_exposes_pyvista_api(sphere):
    """Curated PyVista API members show up in ``dir``."""
    listing = dir(sphere)
    for expected in (
        'points',
        'bounds',
        'point_data',
        'cell_data',
        'field_data',
        'n_points',
        'n_cells',
        'save',
        'copy',
        'deep_copy',
        'shallow_copy',
    ):
        assert expected in listing


def test_dir_show_vtk_api_opt_in(sphere):
    """Setting ``pv.global_config.show_vtk_api`` re-enables the full VTK surface."""
    assert 'GetBounds' not in dir(sphere)
    try:
        pv.global_config.show_vtk_api = True
        listing = dir(sphere)
        for vtk_name in ('GetBounds', 'DeepCopy', 'AddObserver', 'GetClassName'):
            assert vtk_name in listing
        # PyVista API still visible.
        for expected in ('points', 'bounds', 'point_data', 'n_points'):
            assert expected in listing
    finally:
        pv.global_config.show_vtk_api = False
    assert 'GetBounds' not in dir(sphere)


def test_dir_snake_case_hidden_when_disallowed(sphere):
    """Snake_case VTK aliases stay hidden while ``vtk_snake_case`` is not ``'allow'``.

    They would raise ``PyVistaAttributeError`` on access, so surfacing them in
    ``dir`` would only be misleading.
    """
    # Even with the CamelCase API toggled on, snake_case VTK aliases stay
    # hidden unless snake_case is allowed.
    try:
        pv.global_config.show_vtk_api = True
        listing = dir(sphere)
        # ``information`` is a VTK-defined snake_case alias on vtkDataObject
        # (see pv.vtk_snake_case docstring).
        assert 'information' not in listing
    finally:
        pv.global_config.show_vtk_api = False


@pytest.mark.skipif(pv.vtk_version_info < (9, 4), reason='Requires VTK >= 9.4')
def test_dir_snake_case_visible_when_allowed(sphere):
    """Snake_case VTK aliases appear in ``dir`` when snake_case is allowed."""
    with pv.vtk_snake_case('allow'):
        listing = dir(sphere)
        assert 'information' in listing


def test_pyvista_class_no_new_attributes(pyvista_class):
    def skip_test_for_some_classes():
        if pyvista_class in (
            pv.MFIXReader,
            pv.Nek5000Reader,
            pv.XdmfReader,
            pv.PVDReader,
            pv.SeriesReader,
            pv.CGNSReader,
            pv.ExodusIIBlockSet,
            pv.DEMReader,
        ):
            assert issubclass(pyvista_class, _NoNewAttrMixin)
            pytest.skip('Test fails without proper dataset files.')
        elif is_dataclass(pyvista_class):
            assert issubclass(pyvista_class, _NoNewAttrMixin)
            pytest.skip('Dataclass, no test required.')
        elif pyvista_class in (
            pv.core.utilities.misc.conditional_decorator,
            pv.plotting.utilities.sphinx_gallery.Scraper,
            pv.plotting.utilities.sphinx_gallery.DynamicScraper,
            DisableVtkSnakeCase,
            vtkPyVistaOverride,
            VTKObjectWrapperCheckSnakeCase,
            pv.VtkErrorCatcher,
            pv.DataSetAccessor,
        ):
            assert not issubclass(pyvista_class, _NoNewAttrMixin)
            pytest.skip('Specialized class with no real risk of new attributes being added.')
        elif pyvista_class in [pv.charts.PiePlot, pv.charts.Pen, pv.charts.Brush, pv.charts.Axis]:
            assert not issubclass(pyvista_class, _NoNewAttrMixin)
            assert issubclass(pyvista_class, _vtkWrapper)
            pytest.skip(
                f'Parent {_vtkWrapper.__name__} is not compatible with {_NoNewAttrMixin.__name__}.'
            )
        elif pyvista_class is pv.HDFWriter and pv.vtk_version_info < (9, 4, 0):
            pytest.skip('Requires vtk 9.4')
        elif pyvista_class is pv.FluentReader and pv.vtk_version_info < (9, 4, 0):
            # vtkFLUENTReader hangs indefinitely on older VTK when given a
            # non-Fluent file. The other readers in `try_init_pyvista_object`
            # tolerate the dummy `__file__` path without trying to parse it.
            pytest.skip('vtkFLUENTReader hangs on bad input on vtk<9.4')

    skip_test_for_some_classes()
    instance = try_init_pyvista_object(pyvista_class)
    try:
        instance.new_attribute = 'foo'
    except PyVistaAttributeError as e:
        # Test passes, we want an error to be raised
        match = 'does not exist and cannot be added to class'
        assert match in repr(e)  # noqa: PT017
    except AttributeError as e:
        if 'dict' in repr(e):
            pytest.skip('Skip dict classes')
    else:
        if not issubclass(pyvista_class, _NoNewAttrMixin):
            msg = (
                f'The class {pyvista_class.__name__!r} in {pyvista_class.__module__!r}'
                f'must inherit from \n{_NoNewAttrMixin.__name__!r} '
                f'in {_NoNewAttrMixin.__module__!r}'
            )
        else:
            msg = f'{PyVistaAttributeError.__name__} was NOT raised (but was expected).'
        pytest.fail(msg)
