from __future__ import annotations

from collections.abc import Sized
import itertools
import re
import sys
from typing import Literal
from typing import get_args

from hypothesis import HealthCheck
from hypothesis import assume
from hypothesis import given
from hypothesis import settings
from hypothesis.extra._array_helpers import array_shapes
from hypothesis.extra.numpy import arrays
from hypothesis.strategies import floats
from hypothesis.strategies import integers
from hypothesis.strategies import one_of
import numpy as np
import pytest

import pyvista as pv
from pyvista import PyVistaDeprecationWarning
from pyvista import VTKVersionError
from pyvista import _vtk
from pyvista import examples
from pyvista.core.errors import DeprecationError
from pyvista.core.filters.data_object import _PYVISTA_CELL_STATUS_INFO
from pyvista.core.filters.data_object import _SENTINEL
from pyvista.core.filters.data_object import _get_cell_quality_measures
from pyvista.core.utilities.cell_quality import _CellQualityLiteral
from tests.core.test_dataset_filters import HYPOTHESIS_MAX_EXAMPLES
from tests.core.test_dataset_filters import n_numbers
from tests.core.test_dataset_filters import normals

# CellStatus sorted by lower-case names, excluding VALID state
CELL_STATUS_ARRAY_NAMES = [
    val.name.lower()
    for val in sorted(
        pv.CellStatus,
        key=lambda v: v.name.lower(),
    )
    if val != pv.CellStatus.VALID
]


@pytest.mark.parametrize('return_clipped', [True, False])
@pytest.mark.parametrize('crinkle', [True, False])
def test_clip_filter(multiblock_all_with_nested_and_none, return_clipped, crinkle):
    """This tests the clip filter on all datatypes available filters"""
    # Remove None blocks in the root block but keep the none block in the nested MultiBlock
    multi = multiblock_all_with_nested_and_none
    for i, block in enumerate(multi):
        if block is None:
            del multi[i]
    assert None not in multi
    assert None in multi.recursive_iterator()

    # Center datasets at origin so that clip actually removes part of the mesh
    for block in multi.recursive_iterator(skip_none=True):
        center = np.array(block.center)
        block.translate(-center, inplace=True)

    for dataset in multi:
        bounds_before_clip = dataset.bounds
        clips = dataset.clip(
            normal='x', invert=True, return_clipped=return_clipped, crinkle=crinkle
        )
        assert clips is not None

        if return_clipped:
            assert isinstance(clips, tuple)
            assert len(clips) == 2
        else:
            assert isinstance(clips, pv.DataObject)
            # Make dataset iterable
            clips = [clips]

        for clip in clips:
            if isinstance(dataset, pv.PolyData):
                assert isinstance(clip, pv.PolyData)
            elif isinstance(dataset, pv.MultiBlock):
                assert isinstance(clip, pv.MultiBlock)
                assert clip.n_blocks == dataset.n_blocks
            else:
                assert isinstance(clip, pv.UnstructuredGrid)

            bounds_after_clip = clip.bounds
            assert not np.allclose(bounds_before_clip, bounds_after_clip)


@pytest.mark.parametrize('as_composite', [True, False])
def test_clip_filter_pointset_no_points_removed(pointset, as_composite):
    n_points_in = pointset.n_points
    mesh = pv.MultiBlock([pointset]) if as_composite else pointset
    # Make sure we clip such that none of the points are removed
    # This ensures output bounds == input bounds which hits a branch where
    # remove_unused_points may be called
    bounds = pointset.bounds
    clipped = mesh.clip(origin=(bounds.x_max + 1, bounds.y_max, bounds.z_max))
    pointset_out = clipped[0] if as_composite else clipped

    if as_composite and pv.vtk_version_info >= (9, 4) and pv.vtk_version_info < (9, 5):
        assert pointset_out.is_empty
        pytest.xfail("VTK 9.4 bug where clipping PointSet doesn't work")
    assert np.allclose(clipped.bounds, bounds)

    assert isinstance(pointset_out, pv.PointSet)
    n_points_out = pointset_out.n_points
    assert n_points_in == n_points_out


def test_clip_filter_normal(datasets):
    # Test no errors are raised
    for i, dataset in enumerate(datasets):
        dataset.clip(normal=normals[i], invert=True)


@pytest.mark.parametrize('dataset', [pv.PolyData(), pv.MultiBlock()])
def test_clip_filter_empty_inputs(dataset):
    dataset.clip('x')


def test_clip_filter_crinkle_disjoint(uniform):
    def assert_array_names(clipped):
        assert cell_ids in clipped.array_names
        assert 'vtkOriginalPointIds' not in clipped.array_names
        assert 'vtkOriginalCellIds' not in clipped.array_names

    # crinkle clip
    cell_ids = 'cell_ids'
    clp = uniform.clip(normal=(1, 1, 1), crinkle=True)
    assert_array_names(clp)

    assert clp is not None
    clp1, clp2 = uniform.clip(normal=(1, 1, 1), return_clipped=True, crinkle=True)
    assert clp1 is not None
    assert clp2 is not None
    assert_array_names(clp1)
    assert_array_names(clp2)
    set_a = set(clp1.cell_data[cell_ids])
    set_b = set(clp2.cell_data[cell_ids])
    assert set_a.isdisjoint(set_b)
    assert set_a.union(set_b) == set(range(uniform.n_cells))


@pytest.mark.parametrize('has_active_scalars', [True, False])
def test_clip_filter_crinkle_active_scalars(uniform, has_active_scalars):
    if not has_active_scalars:
        uniform.set_active_scalars(None)
        assert uniform.active_scalars is None
    else:
        assert uniform.active_scalars is not None

    scalars_before = uniform.active_scalars_name
    uniform.clip('x', crinkle=True)
    scalars_after = uniform.active_scalars_name
    assert scalars_before == scalars_after


def test_clip_filter_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.clip(normal=normals[0], invert=False)
    assert output.n_blocks == multiblock_all.n_blocks


@pytest.mark.parametrize(
    'filt',
    [
        pv.DataObjectFilters.clip,
        pv.DataObjectFilters.slice,
        pv.PolyDataFilters.clip_closed_surface,
        pv.PolyDataFilters.project_points_to_plane,
    ],
)
def test_filters_with_plane_keyword(filt, ant):
    origin = (1, 2, 3)
    normal = (4, 5, 6)
    plane = pv.Plane(center=origin, direction=normal)
    output_no_plane = filt(ant, origin=origin, normal=normal)
    output_with_plane = filt(ant, plane=plane)
    assert np.allclose(output_no_plane.bounds, output_with_plane.bounds)

    match = 'The plane mesh must be planar. Got a non-planar mesh with dimensionality of 3.'
    with pytest.raises(ValueError, match=match):
        filt(ant, plane=pv.Box())

    match = 'The `normal` and `origin` parameters cannot be set when `plane` is specified.'
    with pytest.raises(ValueError, match=match):
        filt(ant, plane=plane, normal='x')
    with pytest.raises(ValueError, match=match):
        filt(ant, plane=plane, origin=(0, 0, 0))


def test_transform_raises(sphere):
    matrix = np.diag((1, 1, 1, 0))
    match = re.escape('Transform element (3,3), the inverse scale term, is zero')
    with pytest.raises(ValueError, match=match):
        sphere.transform(matrix, inplace=False)


@pytest.mark.parametrize('crinkle', [True, False])
def test_clip_box_output_type(multiblock_all_with_nested_and_none, crinkle):
    multiblock_all_with_nested_and_none.clean()
    for dataset in multiblock_all_with_nested_and_none:
        clp = dataset.clip_box(invert=True, progress_bar=True, crinkle=crinkle)
        assert clp is not None
        assert isinstance(clp, (pv.UnstructuredGrid, pv.MultiBlock))
        if isinstance(clp, pv.MultiBlock):
            assert all(
                isinstance(block, pv.UnstructuredGrid)
                for block in clp.recursive_iterator(skip_none=True)
            )
        clp2 = dataset.clip_box(merge_points=False)
        assert clp2 is not None


def test_clip_box():
    dataset = examples.load_airplane()
    # test length 3 bounds
    result = dataset.clip_box(bounds=(900, 900, 200), invert=False, progress_bar=True)
    dataset = examples.load_uniform()
    result = dataset.clip_box(bounds=0.5, progress_bar=True)
    assert result.n_cells
    with pytest.raises(ValueError):  # noqa: PT011
        dataset.clip_box(bounds=(5, 6), progress_bar=True)
    # allow Sequence but not Iterable bounds
    with pytest.raises(TypeError):
        dataset.clip_box(bounds={5, 6, 7}, progress_bar=True)
    # Test with a poly data box
    mesh = examples.load_airplane()
    box = pv.Cube(center=(0.9e3, 0.2e3, mesh.center[2]), x_length=500, y_length=500, z_length=500)
    box.rotate_z(33, inplace=True)
    result = mesh.clip_box(box, invert=False, progress_bar=True)
    assert result.n_cells
    result = mesh.clip_box(box, invert=True, progress_bar=True)
    assert result.n_cells

    with pytest.raises(ValueError):  # noqa: PT011
        dataset.clip_box(bounds=pv.Sphere(), progress_bar=True)

    # crinkle clip
    surf = pv.Sphere(radius=3)
    vol = surf.voxelize()
    cube = pv.Cube().rotate_x(33, inplace=False)
    clp = vol.clip_box(bounds=cube, invert=False, crinkle=True)
    assert clp is not None


@pytest.mark.parametrize('crinkle', [True, False])
def test_clip_empty(crinkle):
    out = pv.PolyData().clip(crinkle=crinkle, return_clipped=False)
    assert out.is_empty

    out1, _out2 = pv.PolyData().clip(crinkle=crinkle, return_clipped=True)
    assert out1.is_empty

    out = pv.PolyData().clip_box(crinkle=crinkle)
    assert out.is_empty


@pytest.mark.parametrize('as_composite', [True, False])
def test_clip_box_no_unused_points(as_composite):
    mesh = pv.Cube()
    mesh = pv.MultiBlock([mesh]) if as_composite else mesh
    new_bounds = (
        mesh.bounds.x_min,
        mesh.bounds.x_max,
        mesh.bounds.y_min,
        mesh.bounds.y_max,
        mesh.bounds.z_min + (mesh.bounds.z_max - mesh.bounds.z_min) * 7 / 10,
        mesh.bounds.z_min + (mesh.bounds.z_max - mesh.bounds.z_min) * 8 / 10,
    )
    clipped = mesh.clip_box(bounds=new_bounds, invert=False)
    assert np.allclose(clipped.bounds, new_bounds)


def test_clip_box_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.clip_box(invert=False, progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_clip_slab_axis_aligned():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    slab = mesh.clip_slab(thickness=0.4, normal='z', origin=(0, 0, 0))
    assert slab.n_cells > 0
    assert slab.bounds.z_min == pytest.approx(-0.2, abs=1e-6)
    assert slab.bounds.z_max == pytest.approx(0.2, abs=1e-6)
    # Untouched axes should span the original bounds
    assert slab.bounds.x_min == pytest.approx(mesh.bounds.x_min)
    assert slab.bounds.x_max == pytest.approx(mesh.bounds.x_max)


def test_clip_slab_default_origin_is_mesh_center():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    slab = mesh.clip_slab(thickness=0.4, normal='x')
    cx = mesh.center[0]
    assert slab.bounds.x_min == pytest.approx(cx - 0.2, abs=1e-6)
    assert slab.bounds.x_max == pytest.approx(cx + 0.2, abs=1e-6)


def test_clip_slab_string_normal():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    pos = mesh.clip_slab(thickness=0.4, normal='y', origin=(0, 0, 0))
    neg = mesh.clip_slab(thickness=0.4, normal='-y', origin=(0, 0, 0))
    assert pos.n_cells == neg.n_cells
    assert np.allclose(pos.bounds, neg.bounds)


def test_clip_slab_invert():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    slab = mesh.clip_slab(thickness=0.4, normal='z', origin=(0, 0, 0))
    outside = mesh.clip_slab(thickness=0.4, normal='z', origin=(0, 0, 0), invert=True)
    assert slab.n_cells > 0
    assert outside.n_cells > 0
    # Tolerance is loose enough to absorb VTK 9.2 floating-point ordering
    # differences in cell-center computation.
    tol = 1e-6
    slab_z = slab.cell_centers().points[:, 2]
    assert slab_z.min() >= -0.2 - tol
    assert slab_z.max() <= 0.2 + tol
    out_z = outside.cell_centers().points[:, 2]
    assert not np.any((out_z > -0.2 + tol) & (out_z < 0.2 - tol))


def test_clip_slab_oblique_normal():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    normal = np.array([1.0, 1.0, 1.0])
    unit = normal / np.linalg.norm(normal)
    slab = mesh.clip_slab(thickness=0.3, normal=normal, origin=(0, 0, 0))
    assert slab.n_cells > 0
    # Compute signed distance to the reference plane without matmul to avoid
    # spurious `divide by zero` warnings in certain BLAS builds when operating
    # on pyvista_ndarray views.
    points = np.asarray(slab.points)
    projections = (points * unit).sum(axis=1)
    assert projections.min() >= -0.15 - 1e-6
    assert projections.max() <= 0.15 + 1e-6


def test_clip_slab_plane_argument():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    plane = pv.Plane(center=(0, 0, 0), direction=(0, 0, 1))
    slab = mesh.clip_slab(thickness=0.4, plane=plane)
    assert slab.bounds.z_min == pytest.approx(-0.2, abs=1e-6)
    assert slab.bounds.z_max == pytest.approx(0.2, abs=1e-6)


def test_clip_slab_polydata_preserves_type():
    sphere = pv.Sphere()
    slab = sphere.clip_slab(thickness=0.2, normal='y')
    assert isinstance(slab, pv.PolyData)
    assert slab.n_cells > 0


def test_clip_slab_composite(multiblock_all):
    output = multiblock_all.clip_slab(thickness=5.0, normal='x', progress_bar=True)
    assert isinstance(output, pv.MultiBlock)
    assert output.n_blocks == multiblock_all.n_blocks


def test_clip_slab_crinkle():
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    slab = mesh.clip_slab(thickness=0.4, normal='z', origin=(0, 0, 0), crinkle=True)
    assert 'cell_ids' in slab.cell_data


@pytest.mark.parametrize('thickness', [0.0, -1.0])
def test_clip_slab_invalid_thickness(thickness):
    mesh = pv.Sphere()
    with pytest.raises(ValueError, match='strictly positive'):
        mesh.clip_slab(thickness=thickness, normal='x')


def test_clip_slab_zero_normal():
    mesh = pv.Sphere()
    with pytest.raises(ValueError, match='non-zero'):
        mesh.clip_slab(thickness=0.2, normal=(0.0, 0.0, 0.0))


def _two_clip_reference(mesh, normal, origin, thickness):
    """Reference implementation: two chained clips with opposing normals."""
    unit = np.asarray(normal, dtype=float)
    unit = unit / np.linalg.norm(unit)
    origin = np.asarray(origin, dtype=float)
    half = thickness / 2.0
    return mesh.clip(normal=unit, origin=origin + unit * half).clip(
        normal=-unit, origin=origin - unit * half
    )


@pytest.mark.parametrize(
    ('normal', 'thickness'),
    [
        ((0.0, 0.0, 1.0), 0.4),
        ((1.0, 1.0, 1.0), 0.3),
        ((2.0, -1.0, 0.5), 0.25),
    ],
)
def test_clip_slab_matches_two_clip_workaround(normal, thickness):
    """``clip_slab`` must match the historical two-clip chain geometrically.

    Only geometric equivalence (same region, same volume, same bounds) is
    asserted — not topological equivalence. The single-pass
    ``vtkImplicitBoolean`` clipper and the two-pass chain produce different
    cell decompositions on some VTK versions (notably 9.2), but both are valid
    discretizations of the same slab region.
    """
    mesh = pv.ImageData(dimensions=(21, 21, 21), spacing=(0.1, 0.1, 0.1), origin=(-1, -1, -1))
    origin = (0.0, 0.0, 0.0)
    new = mesh.clip_slab(thickness=thickness, normal=normal, origin=origin)
    old = _two_clip_reference(mesh, normal, origin, thickness)
    assert new.n_cells > 0
    assert old.n_cells > 0
    assert np.allclose(new.bounds, old.bounds, atol=1e-6)
    assert new.volume == pytest.approx(old.volume, rel=1e-6)


def test_clip_slab_matches_two_clip_workaround_polydata():
    """``clip_slab`` on surface data must match the two-clip chain geometrically."""
    mesh = pv.Sphere()
    new = mesh.clip_slab(thickness=0.2, normal='y', origin=(0, 0, 0))
    old = _two_clip_reference(mesh, (0, 1, 0), (0, 0, 0), 0.2)
    assert new.n_cells > 0
    assert old.n_cells > 0
    assert np.allclose(new.bounds, old.bounds, atol=1e-6)
    assert new.area == pytest.approx(old.area, rel=1e-6)


def test_slice_filter(datasets):
    """This tests the slice filter on all datatypes available filters"""
    for i, dataset in enumerate(datasets):
        slc = dataset.slice(normal=normals[i], progress_bar=True)
        assert slc is not None
        assert isinstance(slc, pv.PolyData)
    dataset = examples.load_uniform()
    slc = dataset.slice(contour=True, progress_bar=True)
    assert slc is not None
    assert isinstance(slc, pv.PolyData)
    result = dataset.slice(origin=(10, 15, 15), progress_bar=True)
    assert result.n_points < 1


def test_slice_filter_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.slice(normal=normals[0], progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_slice_orthogonal_filter(datasets):
    """This tests the slice filter on all datatypes available filters"""
    for dataset in datasets:
        slices = dataset.slice_orthogonal(progress_bar=True)
        assert slices is not None
        assert isinstance(slices, pv.MultiBlock)
        assert slices.n_blocks == 3
        for slc in slices:
            assert isinstance(slc, pv.PolyData)


def test_slice_orthogonal_filter_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.slice_orthogonal(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_slice_along_axis(datasets):
    """Test the many slices along axis filter"""
    axii = ['x', 'y', 'z', 'y', 0]
    ns = [2, 3, 4, 10, 20, 13]
    for i, dataset in enumerate(datasets):
        slices = dataset.slice_along_axis(n=ns[i], axis=axii[i], progress_bar=True)
        assert slices is not None
        assert isinstance(slices, pv.MultiBlock)
        assert slices.n_blocks == ns[i]
        for slc in slices:
            assert isinstance(slc, pv.PolyData)
    dataset = examples.load_uniform()
    with pytest.raises(ValueError):  # noqa: PT011
        dataset.slice_along_axis(axis='u')


def test_slice_along_axis_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.slice_along_axis(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_extract_all_edges(datasets):
    for dataset in datasets:
        edges = dataset.extract_all_edges()
        assert edges is not None
        assert isinstance(edges, pv.PolyData)

    # Test that use_all_points parameter raises a deprecation warning
    with pytest.warns(PyVistaDeprecationWarning, match='use_all_points.*deprecated'):
        edges = datasets[0].extract_all_edges(use_all_points=True)
    assert edges.n_lines

    # Test that use_all_points=False also raises a deprecation warning
    with pytest.warns(PyVistaDeprecationWarning, match='use_all_points.*deprecated'):
        edges = datasets[0].extract_all_edges(use_all_points=False)
    assert edges.n_lines


def test_extract_all_edges_no_data():
    mesh = pv.Wavelet()
    edges = mesh.extract_all_edges(clear_data=True)
    assert edges is not None
    assert isinstance(edges, pv.PolyData)
    assert edges.n_arrays == 0


def test_extract_all_edges_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.extract_all_edges(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_elevation(uniform):
    dataset = uniform
    # Test default params
    elev = dataset.elevation(progress_bar=True)
    assert 'Elevation' in elev.array_names
    assert elev.active_scalars_name == 'Elevation'
    assert elev.get_data_range() == (dataset.bounds.z_min, dataset.bounds.z_max)
    # test vector args
    c = list(dataset.center)
    t = list(c)  # cast so it does not point to `c`
    t[2] = dataset.bounds[-1]
    elev = dataset.elevation(low_point=c, high_point=t, progress_bar=True)
    assert 'Elevation' in elev.array_names
    assert elev.active_scalars_name == 'Elevation'
    assert elev.get_data_range() == (dataset.center[2], dataset.bounds.z_max)
    # Test not setting active
    elev = dataset.elevation(set_active=False, progress_bar=True)
    assert 'Elevation' in elev.array_names
    assert elev.active_scalars_name != 'Elevation'
    # Set use a range by scalar name
    elev = dataset.elevation(scalar_range='Spatial Point Data', progress_bar=True)
    assert 'Elevation' in elev.array_names
    assert elev.active_scalars_name == 'Elevation'
    assert dataset.get_data_range('Spatial Point Data') == (elev.get_data_range('Elevation'))
    # Set use a user defined range
    elev = dataset.elevation(scalar_range=[1.0, 100.0], progress_bar=True)
    assert 'Elevation' in elev.array_names
    assert elev.active_scalars_name == 'Elevation'
    assert elev.get_data_range('Elevation') == (1.0, 100.0)
    # test errors
    match = 'Data Range has shape () which is not allowed. Shape must be 2.'
    with pytest.raises(ValueError, match=re.escape(match)):
        elev = dataset.elevation(scalar_range=0.5, progress_bar=True)
    with pytest.raises(ValueError):  # noqa: PT011
        elev = dataset.elevation(scalar_range=[1, 2, 3], progress_bar=True)
    with pytest.raises(TypeError):
        elev = dataset.elevation(scalar_range={1, 2}, progress_bar=True)


def test_elevation_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.elevation(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_compute_cell_sizes(datasets):
    for dataset in datasets:
        result = dataset.compute_cell_sizes(progress_bar=True, vertex_count=True)
        assert result is not None
        assert isinstance(result, type(dataset))
        assert 'Length' in result.array_names
        assert 'Area' in result.array_names
        assert 'Volume' in result.array_names
        assert 'VertexCount' in result.array_names
    # Test the volume property
    grid = pv.ImageData(dimensions=(10, 10, 10))
    volume = float(np.prod(np.array(grid.dimensions) - 1))
    assert np.allclose(grid.volume, volume)


def test_compute_cell_sizes_multiblock_vertex_count():
    multi = pv.MultiBlock([pv.PolyData()])
    result = multi.compute_cell_sizes(vertex_count=True)[0]
    assert 'Length' in result.array_names
    assert 'Area' in result.array_names
    assert 'Volume' in result.array_names
    assert 'VertexCount' in result.array_names


def test_compute_cell_sizes_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.compute_cell_sizes(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_cell_centers(datasets):
    for dataset in datasets:
        result = dataset.cell_centers(progress_bar=True)
        assert result is not None
        assert isinstance(result, pv.PolyData)


def test_cell_centers_no_cell_data(cube):
    # test passing cell data kwarg works
    assert cube.cell_centers(pass_cell_data=True).cell_data
    assert not cube.cell_centers(pass_cell_data=False).cell_data


def test_cell_center_pointset(airplane):
    pointset = airplane.cast_to_pointset()
    result = pointset.cell_centers(progress_bar=True)
    assert result is not None
    assert isinstance(result, pv.PolyData)


def test_cell_centers_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.cell_centers(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_cell_data_to_point_data():
    data = examples.load_uniform()
    foo = data.cell_data_to_point_data(progress_bar=True)
    assert foo.n_arrays == 2
    assert len(foo.cell_data.keys()) == 0
    _ = data.ctp()


def test_cell_data_to_point_data_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.cell_data_to_point_data(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_point_data_to_cell_data():
    data = examples.load_uniform()
    foo = data.point_data_to_cell_data(progress_bar=True)
    assert foo.n_arrays == 2
    assert len(foo.point_data.keys()) == 0
    _ = data.ptc()


def test_point_data_to_cell_data_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.point_data_to_cell_data(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_triangulate():
    data = examples.load_uniform()
    tri = data.triangulate(progress_bar=True)
    assert isinstance(tri, pv.UnstructuredGrid)
    assert np.any(tri.cells)


def test_triangulate_composite(multiblock_all):
    # Now test composite data structures
    output = multiblock_all.triangulate(progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_sample():
    mesh = pv.Sphere(center=(4.5, 4.5, 4.5), radius=4.5)
    data_to_probe = examples.load_uniform()

    def sample_test(**kwargs):
        """Test `sample` with kwargs."""
        result = mesh.sample(data_to_probe, **kwargs)
        name = 'Spatial Point Data'
        assert name in result.array_names
        assert isinstance(result, type(mesh))

    sample_test()
    sample_test(tolerance=1.0)
    sample_test(progress_bar=True)
    sample_test(categorical=True)
    sample_test(locator=_vtk.vtkStaticCellLocator())
    for locator in ['cell', 'cell_tree', 'obb_tree', 'static_cell']:
        sample_test(locator=locator)
    with pytest.raises(ValueError):  # noqa: PT011
        sample_test(locator='invalid')
    sample_test(pass_cell_data=False)
    sample_test(pass_point_data=False)
    sample_test(pass_field_data=False)
    if pv.vtk_version_info >= (9, 3):
        sample_test(snap_to_closest_point=True)
    else:
        with pytest.raises(VTKVersionError, match='snap_to_closest_point'):
            sample_test(snap_to_closest_point=True)


def test_sample_composite():
    mesh0 = pv.ImageData(dimensions=(11, 11, 1), origin=(0.0, 0.0, 0.0), spacing=(1.0, 1.0, 1.0))
    mesh1 = pv.ImageData(dimensions=(11, 11, 1), origin=(10.0, 0.0, 0.0), spacing=(1.0, 1.0, 1.0))
    mesh0['common_data'] = np.zeros(mesh0.n_points)
    mesh1['common_data'] = np.ones(mesh1.n_points)
    mesh0['partial_data'] = np.zeros(mesh0.n_points)

    composite = pv.MultiBlock([mesh0, mesh1])

    probe_points = pv.PolyData(
        [
            [5.0, 5.0, 0.0],
            [15.0, 5.0, 0.0],
            [25.0, 5.0, 0.0],  # outside domain
        ],
    )

    result = probe_points.sample(composite)
    assert 'common_data' in result.point_data
    # Need pass partial arrays?
    assert 'partial_data' not in result.point_data
    assert 'vtkValidPointMask' in result.point_data
    assert 'vtkGhostType' in result.point_data
    # data outside domain is 0
    assert np.array_equal(result['common_data'], [0.0, 1.0, 0.0])
    assert np.array_equal(result['vtkValidPointMask'], [1, 1, 0])

    result = probe_points.sample(composite, mark_blank=False)
    assert 'vtkGhostType' not in result.point_data

    small_mesh_0 = pv.ImageData(
        dimensions=(6, 6, 1),
        origin=(0.0, 0.0, 0.0),
        spacing=(1.0, 1.0, 1.0),
    )
    small_mesh_1 = pv.ImageData(
        dimensions=(6, 6, 1),
        origin=(10.0, 0.0, 0.0),
        spacing=(1.0, 1.0, 1.0),
    )

    probe_composite = pv.MultiBlock([small_mesh_0, small_mesh_1])
    result = probe_composite.sample(composite)
    assert 'common_data' in result[0].point_data
    # Need pass partial arrays?
    assert 'partial_data' not in result[0].point_data
    assert 'vtkValidPointMask' in result[0].point_data
    assert 'vtkGhostType' in result[0].point_data


def test_slice_along_line():
    model = examples.load_uniform()
    n = 5
    x = y = z = np.linspace(model.bounds.x_min, model.bounds.x_max, num=n)
    points = np.c_[x, y, z]
    spline = pv.Spline(points, n)
    slc = model.slice_along_line(spline, progress_bar=True)
    assert slc.n_points > 0
    slc = model.slice_along_line(spline, contour=True, progress_bar=True)
    assert slc.n_points > 0
    # Now check a simple line
    a = [model.bounds.x_min, model.bounds.y_min, model.bounds.z_min]
    b = [model.bounds.x_max, model.bounds.y_max, model.bounds.z_max]
    line = pv.Line(a, b, resolution=10)
    slc = model.slice_along_line(line, progress_bar=True)
    assert slc.n_points > 0
    # Now check a bad input
    a = [model.bounds.x_min, model.bounds.y_min, model.bounds.z_min]
    b = [model.bounds.x_max, model.bounds.y_min, model.bounds.z_max]
    line2 = pv.Line(a, b, resolution=10)
    line = line2.cast_to_unstructured_grid().merge(line.cast_to_unstructured_grid())
    with pytest.raises(ValueError):  # noqa: PT011
        slc = model.slice_along_line(line, progress_bar=True)

    one_cell = model.extract_cells(0, progress_bar=True)
    with pytest.raises(TypeError):
        model.slice_along_line(one_cell, progress_bar=True)


def test_slice_along_line_composite(multiblock_all):
    # Now test composite data structures
    a = [multiblock_all.bounds.x_min, multiblock_all.bounds.y_min, multiblock_all.bounds.z_min]
    b = [multiblock_all.bounds.x_max, multiblock_all.bounds.y_max, multiblock_all.bounds.z_max]
    line = pv.Line(a, b, resolution=10)
    output = multiblock_all.slice_along_line(line, progress_bar=True)
    assert output.n_blocks == multiblock_all.n_blocks


def test_slice_generate_triangles_true_emits_only_triangles():
    grid = examples.load_uniform().cast_to_unstructured_grid()
    out = grid.slice(normal=(1, 1, 1), generate_triangles=True)
    ug = out.cast_to_unstructured_grid()
    assert set(ug.celltypes.tolist()) <= {pv.CellType.TRIANGLE}


def test_slice_generate_triangles_false_default_preserves_polygons():
    """The ``False`` default keeps the historical polygon output for
    plane-cut UnstructuredGrids (no triangulation forced).
    """
    grid = examples.load_uniform().cast_to_unstructured_grid()
    out = grid.slice(normal=(1, 1, 1))  # default generate_triangles=False
    ug = out.cast_to_unstructured_grid()
    # Polygon path produces quads (and maybe other polygons) for hex
    # cell intersections, not just triangles.
    types = set(ug.celltypes.tolist())
    assert pv.CellType.QUAD in types or pv.CellType.POLYGON in types


def test_compute_cell_quality_removed():
    mesh = pv.ParametricEllipsoid().triangulate().decimate(0.8)
    with pytest.raises(AttributeError):
        _ = mesh.compute_cell_quality(progress_bar=True)


SHAPE = 'shape'
CELL_QUALITY = 'CellQuality'
AREA = 'area'
VOLUME = 'volume'


def test_cell_quality():
    mesh = pv.ParametricEllipsoid().triangulate().decimate(0.8)
    qual = mesh.cell_quality(SHAPE, progress_bar=True)
    assert SHAPE in qual.array_names

    expected_names = [SHAPE, AREA]
    qual = mesh.cell_quality(expected_names, progress_bar=True)
    assert qual.array_names == expected_names

    with pytest.raises(ValueError, match="quality_measure 'foo' is not valid"):
        mesh.cell_quality(quality_measure='foo', progress_bar=True)


def test_cell_quality_measures(ant):
    # Get quality measures from type hints
    hinted_measures = list(get_args(_CellQualityLiteral))

    # Get quality measures from the VTK class
    actual_measures = list(_get_cell_quality_measures().keys())
    msg = 'VTK API has changed. Update type hints and docstring for `cell_quality`.'
    assert actual_measures == hinted_measures, msg

    # Test 'all' measure keys
    qual = ant.cell_quality('all')
    assert qual.array_names == actual_measures


@pytest.mark.parametrize(
    'cell_mesh',
    [
        examples.cells.Triangle(),
        examples.cells.Quadrilateral(),
        examples.cells.Hexahedron(),
        examples.cells.Tetrahedron(),
    ],
)
@pytest.mark.parametrize('measure', ['relative_size_squared', 'shape_and_size'])
def test_cell_quality_size_measures(cell_mesh, measure):
    quality = cell_mesh.cell_quality(measure)
    assert np.isclose(quality[measure][0], 1.0)


def test_cell_quality_all_valid(ant):
    qual = ant.cell_quality('all_valid')
    assert AREA in qual.array_names
    assert SHAPE in qual.array_names
    assert VOLUME not in qual.array_names


def test_cell_quality_composite(multiblock_all_with_nested_and_none):
    qual = multiblock_all_with_nested_and_none.cell_quality([SHAPE])
    for block in qual.recursive_iterator(skip_none=True):
        assert SHAPE in block.array_names


def test_cell_quality_return_type(multiblock_all_with_nested_and_none):
    iter_in = multiblock_all_with_nested_and_none.recursive_iterator()
    qual = multiblock_all_with_nested_and_none.cell_quality([SHAPE])
    iter_out = qual.recursive_iterator()
    for block_in, block_out in zip(iter_in, iter_out, strict=True):
        assert type(block_in) is type(block_out)


@pytest.mark.parametrize(
    ('num_cell_arrays', 'num_point_data'),
    itertools.product([0, 1, 2], [0, 1, 2]),
)
def test_transform_mesh(datasets, num_cell_arrays, num_point_data):
    dx, dy, dz = -1.0, 2.0, 3.0
    tf = pv.Transform().translate((dx, dy, dz))
    for dataset in datasets:
        for i in range(num_cell_arrays):
            dataset.cell_data[f'C{i}'] = np.random.default_rng().random((dataset.n_cells, 3))

        for i in range(num_point_data):
            dataset.point_data[f'P{i}'] = np.random.default_rng().random((dataset.n_points, 3))

        # deactivate any active vectors!
        # even if transform_all_input_vectors is False, vtkTransformfilter will
        # transform active vectors
        dataset.set_active_vectors(None)

        transformed = dataset.transform(tf, transform_all_input_vectors=False, inplace=False)

        assert np.allclose(dataset.points[:, 0] + dx, transformed.points[:, 0])
        assert np.allclose(dataset.points[:, 1] + dy, transformed.points[:, 1])
        assert np.allclose(dataset.points[:, 2] + dz, transformed.points[:, 2])

        # ensure that none of the vector data is changed
        for name, array in dataset.point_data.items():
            assert transformed.point_data[name] == pytest.approx(array)

        for name, array in dataset.cell_data.items():
            assert transformed.cell_data[name] == pytest.approx(array)

        # verify that the cell connectivity is a deep copy
        if hasattr(dataset, '_connectivity_array'):
            transformed._connectivity_array[0] += 1
            assert not np.array_equal(dataset._connectivity_array, transformed._connectivity_array)
        if hasattr(dataset, 'cell_connectivity'):
            transformed.cell_connectivity[0] += 1
            assert not np.array_equal(dataset.cell_connectivity, transformed.cell_connectivity)


@pytest.mark.parametrize(
    ('num_cell_arrays', 'num_point_data'),
    itertools.product([0, 1, 2], [0, 1, 2]),
)
def test_transform_mesh_and_vectors(datasets, num_cell_arrays, num_point_data):
    sx, sy, sz = -1.1, 2.2, 3.3
    tf = pv.Transform().scale((sx, sy, sz))
    for dataset in datasets:
        for i in range(num_cell_arrays):
            dataset.cell_data[f'C{i}'] = np.random.default_rng().random((dataset.n_cells, 3))

        for i in range(num_point_data):
            dataset.point_data[f'P{i}'] = np.random.default_rng().random((dataset.n_points, 3))

        # track original untransformed dataset
        orig_dataset = dataset.copy(deep=True)

        transformed = dataset.transform(tf, transform_all_input_vectors=True, inplace=False)

        # verify that the dataset has not modified
        if num_cell_arrays:
            assert dataset.cell_data == orig_dataset.cell_data
        if num_point_data:
            assert dataset.point_data == orig_dataset.point_data

        assert np.allclose(dataset.points[:, 0] * sx, transformed.points[:, 0])
        assert np.allclose(dataset.points[:, 1] * sy, transformed.points[:, 1])
        assert np.allclose(dataset.points[:, 2] * sz, transformed.points[:, 2])

        for i in range(num_cell_arrays):
            assert np.allclose(
                dataset.cell_data[f'C{i}'][:, 0] * sx,
                transformed.cell_data[f'C{i}'][:, 0],
            )
            assert np.allclose(
                dataset.cell_data[f'C{i}'][:, 1] * sy,
                transformed.cell_data[f'C{i}'][:, 1],
            )
            assert np.allclose(
                dataset.cell_data[f'C{i}'][:, 2] * sz,
                transformed.cell_data[f'C{i}'][:, 2],
            )

        for i in range(num_point_data):
            assert np.allclose(
                dataset.point_data[f'P{i}'][:, 0] * sx,
                transformed.point_data[f'P{i}'][:, 0],
            )
            assert np.allclose(
                dataset.point_data[f'P{i}'][:, 1] * sy,
                transformed.point_data[f'P{i}'][:, 1],
            )
            assert np.allclose(
                dataset.point_data[f'P{i}'][:, 2] * sz,
                transformed.point_data[f'P{i}'][:, 2],
            )

        # Verify active scalars are not changed
        expected_point_scalars_name = orig_dataset.point_data.active_scalars_name
        actual_point_scalars_name = transformed.point_data.active_scalars_name
        assert actual_point_scalars_name == expected_point_scalars_name

        expected_cell_scalars_name = orig_dataset.cell_data.active_scalars_name
        actual_cell_scalars_name = transformed.cell_data.active_scalars_name
        assert actual_cell_scalars_name == expected_cell_scalars_name


@pytest.mark.parametrize(
    ('num_cell_arrays', 'num_point_data'),
    itertools.product([0, 1, 2], [0, 1, 2]),
)
def test_transform_int_vectors_warning(datasets, num_cell_arrays, num_point_data):
    tf = pv.Transform().scale((1, 2, 3))
    for dataset in datasets:
        dataset.clear_data()
        for i in range(num_cell_arrays):
            dataset.cell_data[f'C{i}'] = np.random.default_rng().integers(
                np.iinfo(int).max,
                size=(dataset.n_cells, 3),
            )
        for i in range(num_point_data):
            dataset.point_data[f'P{i}'] = np.random.default_rng().integers(
                np.iinfo(int).max,
                size=(dataset.n_points, 3),
            )
        if not (num_cell_arrays == 0 and num_point_data == 0):
            with pytest.warns(UserWarning, match='Integer'):
                _ = dataset.transform(tf, transform_all_input_vectors=True, inplace=False)


def test_transform_inplace(datasets):
    tf = pv.Transform().scale(1, 2, 3)
    for dataset in datasets:
        dataset.clear_data()
        pdata_array = np.arange(dataset.n_points)
        cdata_array = np.arange(dataset.n_cells)
        pdata_name = 'pdata'
        cdata_name = 'cdata'
        dataset[pdata_name] = pdata_array
        dataset[cdata_name] = cdata_array

        copied = dataset.copy()
        inplace = copied.transform(tf, inplace=True)
        assert inplace is copied
        assert np.shares_memory(inplace[pdata_name], copied[pdata_name])
        assert np.shares_memory(inplace[cdata_name], copied[cdata_name])

        not_inplace = dataset.transform(tf, inplace=False)
        assert inplace == not_inplace
        assert not np.shares_memory(not_inplace[pdata_name], copied[pdata_name])
        assert not np.shares_memory(not_inplace[cdata_name], copied[cdata_name])


def test_transform_rectilinear_raises(rectilinear):
    tf = pv.Transform().rotate_x(30)
    match = (
        'The transformation has a non-diagonal rotation component which is not supported by\n'
        'RectilinearGrid. Cast to StructuredGrid first to fully support rotations, or use\n'
        '`Transform.decompose()` to remove this component.'
    )

    with pytest.raises(ValueError, match=re.escape(match)):
        rectilinear.transform(tf, inplace=False)

    matrix = np.eye(4)
    matrix[0, 1] = 0.1
    matrix[1, 0] = 0.1
    match = (
        'The transformation has a shear component which is not supported by RectilinearGrid.\n'
        'Cast to StructuredGrid first to support shear transformations.'
    )

    with pytest.raises(ValueError, match=match):
        rectilinear.transform(matrix, inplace=False)


def test_transform_rectilinear(rectilinear):
    # Test that various transformations applied sequentially work

    def transform(mesh):
        return (
            mesh.flip_x()
            .flip_y()
            .flip_z()
            .rotate_x(360)
            .rotate(np.diag((-1, -1, -1)))
            .scale((1, 2, 3))
            .translate((4, 5, 6))
        )

    transform_then_cast = transform(rectilinear).cast_to_unstructured_grid()
    cast_then_transform = transform(rectilinear.cast_to_unstructured_grid())

    assert transform_then_cast == cast_then_transform


@pytest.mark.parametrize('spacing', [(1, 1, 1), (0.5, 0.6, 0.7)])
def test_transform_imagedata(uniform, spacing):
    # Transformations affect origin, spacing, and direction, so test these here
    uniform.spacing = spacing

    # Test scaling
    vector123 = np.array((1, 2, 3))
    uniform.scale(vector123, inplace=True)
    expected_spacing = spacing * vector123
    assert np.allclose(uniform.spacing, expected_spacing)

    # Test direction
    rotation = pv.Transform().rotate_vector(vector123, 30).matrix[:3, :3]
    uniform.rotate(rotation, inplace=True)
    assert np.allclose(uniform.direction_matrix, rotation)

    # Test translation by centering data
    vector = np.array(uniform.center) * -1
    translation = pv.Transform().translate(vector)
    uniform.transform(translation, inplace=True)
    assert isinstance(uniform, pv.ImageData)
    assert np.array_equal(uniform.origin, vector)

    # Test applying a second translation
    translated = uniform.transform(translation, inplace=False)
    assert np.allclose(translated.origin, vector * 2)
    assert np.allclose(translated.center, uniform.origin)


def test_transform_imagedata_raises_with_shear(uniform):
    shear = np.eye(4)
    shear[0, 1] = 0.1

    match = (
        'The transformation has a shear component which is not supported by ImageData.\n'
        'Cast to StructuredGrid first to fully support shear transformations, or use\n'
        '`Transform.decompose()` to remove this component.'
    )

    with pytest.raises(ValueError, match=re.escape(match)):
        uniform.transform(shear, inplace=True)


def test_transform_filter_inplace_default_raises(cube):
    expected_msg = (
        'The default value of `inplace` for the filter `PolyData.transform` '
        'will change in the future.'
    )
    with pytest.raises(DeprecationError, match=expected_msg):
        _ = cube.transform(np.eye(4))


def test_reflect_mesh_about_point(datasets):
    for dataset in datasets:
        x_plane = 500
        reflected = dataset.reflect((1, 0, 0), point=(x_plane, 0, 0), progress_bar=True)
        assert reflected.n_cells == dataset.n_cells
        assert reflected.n_points == dataset.n_points
        assert np.allclose(x_plane - dataset.points[:, 0], reflected.points[:, 0] - x_plane)
        assert np.allclose(dataset.points[:, 1:], reflected.points[:, 1:])


def test_reflect_mesh_with_vectors(datasets):
    for dataset in datasets:
        if hasattr(dataset, 'compute_normals'):
            dataset.compute_normals(inplace=True, progress_bar=True)

        # add vector data to cell and point arrays
        dataset.cell_data['C'] = np.arange(dataset.n_cells)[:, np.newaxis] * np.array(
            [1, 2, 3],
            dtype=float,
        ).reshape((1, 3))
        dataset.point_data['P'] = np.arange(dataset.n_points)[:, np.newaxis] * np.array(
            [1, 2, 3],
            dtype=float,
        ).reshape((1, 3))

        reflected = dataset.reflect(
            (1, 0, 0),
            transform_all_input_vectors=True,
            inplace=False,
            progress_bar=True,
        )

        # assert isinstance(reflected, type(dataset))
        assert reflected.n_cells == dataset.n_cells
        assert reflected.n_points == dataset.n_points
        assert np.allclose(dataset.points[:, 0], -reflected.points[:, 0])
        assert np.allclose(dataset.points[:, 1:], reflected.points[:, 1:])

        # assert normals are reflected
        if hasattr(dataset, 'compute_normals'):
            assert np.allclose(
                dataset.cell_data['Normals'][:, 0],
                -reflected.cell_data['Normals'][:, 0],
            )
            assert np.allclose(
                dataset.cell_data['Normals'][:, 1:],
                reflected.cell_data['Normals'][:, 1:],
            )
            assert np.allclose(
                dataset.point_data['Normals'][:, 0],
                -reflected.point_data['Normals'][:, 0],
            )
            assert np.allclose(
                dataset.point_data['Normals'][:, 1:],
                reflected.point_data['Normals'][:, 1:],
            )

        # assert other vector fields are reflected
        assert np.allclose(dataset.cell_data['C'][:, 0], -reflected.cell_data['C'][:, 0])
        assert np.allclose(dataset.cell_data['C'][:, 1:], reflected.cell_data['C'][:, 1:])
        assert np.allclose(dataset.point_data['P'][:, 0], -reflected.point_data['P'][:, 0])
        assert np.allclose(dataset.point_data['P'][:, 1:], reflected.point_data['P'][:, 1:])


@pytest.mark.parametrize(
    'dataset',
    [
        examples.load_hexbeam(),  # UnstructuredGrid
        examples.load_airplane(),  # PolyData
        examples.load_structured(),  # StructuredGrid
    ],
)
def test_reflect_inplace(dataset):
    orig = dataset.copy()
    dataset.reflect((1, 0, 0), inplace=True, progress_bar=True)
    assert dataset.n_cells == orig.n_cells
    assert dataset.n_points == orig.n_points
    assert np.allclose(dataset.points[:, 0], -orig.points[:, 0])
    assert np.allclose(dataset.points[:, 1:], orig.points[:, 1:])


@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(rotate_amounts=n_numbers(4), translate_amounts=n_numbers(3))
def test_transform_should_match_vtk_transformation(rotate_amounts, translate_amounts, hexbeam):
    trans = pv.Transform()
    trans.check_finite = False
    trans.RotateWXYZ(*rotate_amounts)
    trans.translate(translate_amounts)
    trans.Update()

    # Apply transform with pyvista filter
    grid_a = hexbeam.copy()
    grid_a.transform(trans, inplace=True)

    # Apply transform with vtk filter
    grid_b = hexbeam.copy()
    f = _vtk.vtkTransformFilter()
    f.SetInputDataObject(grid_b)
    f.SetTransform(trans)
    f.Update()
    grid_b = pv.wrap(f.GetOutput())

    # treat INF as NAN (necessary for allclose)
    grid_a.points[np.isinf(grid_a.points)] = np.nan
    assert np.allclose(grid_a.points, grid_b.points, equal_nan=True)


@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(rotate_amounts=n_numbers(4))
def test_transform_should_match_vtk_transformation_non_homogeneous(rotate_amounts, hexbeam):
    # test non homogeneous transform
    trans_rotate_only = pv.Transform()
    trans_rotate_only.check_finite = False
    trans_rotate_only.RotateWXYZ(*rotate_amounts)
    trans_rotate_only.Update()

    grid_copy = hexbeam.copy()
    grid_copy.transform(trans_rotate_only, inplace=True)

    from pyvista.core.utilities.transformations import apply_transformation_to_points

    trans_arr = trans_rotate_only.matrix[:3, :3]
    trans_pts = apply_transformation_to_points(trans_arr, hexbeam.points)
    assert np.allclose(grid_copy.points, trans_pts, equal_nan=True)


def test_translate_should_not_fail_given_none(hexbeam):
    bounds = hexbeam.bounds
    hexbeam.transform(None, inplace=True)
    assert hexbeam.bounds == bounds


def test_translate_should_fail_bad_points_or_transform():
    points = np.random.default_rng().random((10, 2))
    bad_points = np.random.default_rng().random((10, 2))
    trans = np.random.default_rng().random((4, 4))
    bad_trans = np.random.default_rng().random((2, 4))
    with pytest.raises(ValueError):  # noqa: PT011
        pv.core.utilities.transformations.apply_transformation_to_points(trans, bad_points)

    with pytest.raises(ValueError):  # noqa: PT011
        pv.core.utilities.transformations.apply_transformation_to_points(bad_trans, points)


@settings(
    suppress_health_check=[HealthCheck.function_scoped_fixture],
    max_examples=HYPOTHESIS_MAX_EXAMPLES,
)
@given(array=arrays(dtype=np.float32, shape=array_shapes(max_dims=5, max_side=5)))
def test_transform_should_fail_given_wrong_numpy_shape(array, hexbeam):
    assume(array.shape not in [(3, 3), (4, 4)])
    match = 'Shape must be one of [(3, 3), (4, 4)]'
    with pytest.raises(ValueError, match=re.escape(match)):
        hexbeam.transform(array, inplace=True)


@pytest.mark.parametrize('axis_amounts', [[1, 1, 1], [0, 0, 0], [-1, -1, -1]])
def test_translate_should_translate_grid(hexbeam, axis_amounts):
    grid_copy = hexbeam.copy()
    grid_copy.translate(axis_amounts, inplace=True)

    grid_points = hexbeam.points.copy() + np.array(axis_amounts)
    assert np.allclose(grid_copy.points, grid_points)


@settings(
    suppress_health_check=[HealthCheck.function_scoped_fixture],
    max_examples=HYPOTHESIS_MAX_EXAMPLES,
)
@given(angle=one_of(floats(allow_infinity=False, allow_nan=False), integers()))
@pytest.mark.parametrize('axis', ['x', 'y', 'z'])
def test_rotate_should_match_vtk_rotation(angle, axis, hexbeam):
    trans = _vtk.vtkTransform()
    getattr(trans, f'Rotate{axis.upper()}')(angle)
    trans.Update()

    trans_filter = _vtk.vtkTransformFilter()
    trans_filter.SetTransform(trans)
    trans_filter.SetInputData(hexbeam)
    trans_filter.Update()
    grid_a = pv.UnstructuredGrid(trans_filter.GetOutput())

    grid_b = hexbeam.copy()
    getattr(grid_b, f'rotate_{axis}')(angle, inplace=True)
    assert np.allclose(grid_a.points, grid_b.points, equal_nan=True)


def test_rotate_90_degrees_four_times_should_return_original_geometry():
    sphere = pv.Sphere()
    sphere.rotate_y(90, inplace=True)
    sphere.rotate_y(90, inplace=True)
    sphere.rotate_y(90, inplace=True)
    sphere.rotate_y(90, inplace=True)
    assert np.all(sphere.points == pv.Sphere().points)


def test_rotate_180_degrees_two_times_should_return_original_geometry():
    sphere = pv.Sphere()
    sphere.rotate_x(180, inplace=True)
    sphere.rotate_x(180, inplace=True)
    assert np.all(sphere.points == pv.Sphere().points)


def test_rotate_vector_90_degrees_should_not_distort_geometry():
    cylinder = pv.Cylinder()
    rotated = cylinder.rotate_vector(vector=(1, 1, 0), angle=90)
    assert np.isclose(cylinder.volume, rotated.volume)


def test_rotations_should_match_by_a_360_degree_difference():
    mesh = examples.load_airplane()

    point = np.random.default_rng().random(3) - 0.5
    angle = (np.random.default_rng().random() - 0.5) * 360.0
    vector = np.random.default_rng().random(3) - 0.5

    # Rotate about x axis.
    rot1 = mesh.copy()
    rot2 = mesh.copy()
    rot1.rotate_x(angle=angle, point=point, inplace=True)
    rot2.rotate_x(angle=angle - 360.0, point=point, inplace=True)
    assert np.allclose(rot1.points, rot2.points)

    # Rotate about y axis.
    rot1 = mesh.copy()
    rot2 = mesh.copy()
    rot1.rotate_y(angle=angle, point=point, inplace=True)
    rot2.rotate_y(angle=angle - 360.0, point=point, inplace=True)
    assert np.allclose(rot1.points, rot2.points)

    # Rotate about z axis.
    rot1 = mesh.copy()
    rot2 = mesh.copy()
    rot1.rotate_z(angle=angle, point=point, inplace=True)
    rot2.rotate_z(angle=angle - 360.0, point=point, inplace=True)
    assert np.allclose(rot1.points, rot2.points)

    # Rotate about custom vector.
    rot1 = mesh.copy()
    rot2 = mesh.copy()
    rot1.rotate_vector(vector=vector, angle=angle, point=point, inplace=True)
    rot2.rotate_vector(vector=vector, angle=angle - 360.0, point=point, inplace=True)
    assert np.allclose(rot1.points, rot2.points)


def test_rotate_x():
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.rotate_x(30)
    assert isinstance(out, pv.ImageData)
    match = 'Shape must be one of [(3,), (1, 3), (3, 1)]'
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_x(30, point=5)
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_x(30, point=[1, 3])


def test_rotate_y():
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.rotate_y(30)
    assert isinstance(out, pv.ImageData)
    match = 'Shape must be one of [(3,), (1, 3), (3, 1)]'
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_y(30, point=5)
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_y(30, point=[1, 3])


def test_rotate_z():
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.rotate_z(30)
    assert isinstance(out, pv.ImageData)
    match = 'Shape must be one of [(3,), (1, 3), (3, 1)]'
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_z(30, point=5)
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_z(30, point=[1, 3])


def test_rotate_vector():
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.rotate_vector([1, 1, 1], 33)
    assert isinstance(out, pv.ImageData)
    match = 'Shape must be one of [(3,), (1, 3), (3, 1)]'
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_vector([1, 1], 33)
    with pytest.raises(ValueError, match=re.escape(match)):
        out = mesh.rotate_vector(30, 33)


def test_rotate():
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.rotate([[0, 1, 0], [1, 0, 0], [0, 0, 1]])
    assert isinstance(out, pv.ImageData)


def test_transform_integers():
    # regression test for gh-1943
    points = [
        [0, 0, 0],
        [1, 0, 0],
        [0, 1, 0],
    ]
    # build vtkPolyData from scratch to enforce int data
    poly = _vtk.vtkPolyData()
    poly.SetPoints(pv.vtk_points(points))
    poly = pv.wrap(poly)
    poly.verts = [1, 0, 1, 1, 1, 2]
    # define active and inactive vectors with int values
    for dataset_attrs in poly.point_data, poly.cell_data:
        for key in 'active_v', 'inactive_v', 'active_n', 'inactive_n':
            dataset_attrs[key] = poly.points
        dataset_attrs.active_vectors_name = 'active_v'
        dataset_attrs.active_normals_name = 'active_n'

    # active vectors and normals should be converted by default
    for key in 'active_v', 'inactive_v', 'active_n', 'inactive_n':
        assert poly.point_data[key].dtype == np.int_
        assert poly.cell_data[key].dtype == np.int_

    with pytest.warns(UserWarning, match=r'Integer points.*converted.*float32'):
        poly.rotate_x(angle=10, inplace=True)

    # check that points were converted and transformed correctly
    assert poly.points.dtype == np.float32
    assert poly.points[-1, 1] != 0
    # assert that exactly active vectors and normals were converted
    for key in 'active_v', 'active_n':
        assert poly.point_data[key].dtype == np.float32
        assert poly.cell_data[key].dtype == np.float32
    for key in 'inactive_v', 'inactive_n':
        assert poly.point_data[key].dtype == np.int_
        assert poly.cell_data[key].dtype == np.int_


@pytest.mark.xfail(reason='VTK bug')
def test_transform_integers_vtkbug_present():
    # verify that the VTK transform bug is still there
    # if this test starts to pass, we can remove the
    # automatic float conversion from ``DataSet.transform``
    # along with this test
    points = [
        [0, 0, 0],
        [1, 0, 0],
        [0, 1, 0],
    ]
    # build vtkPolyData from scratch to enforce int data
    poly = _vtk.vtkPolyData()
    poly.SetPoints(pv.vtk_points(points))

    # manually put together a rotate_x(10) transform
    trans_arr = pv.core.utilities.transformations.axis_angle_rotation((1, 0, 0), 10, deg=True)
    trans_mat = pv.vtkmatrix_from_array(trans_arr)
    trans = _vtk.vtkTransform()
    trans.SetMatrix(trans_mat)
    trans_filt = _vtk.vtkTransformFilter()
    trans_filt.SetInputDataObject(poly)
    trans_filt.SetTransform(trans)
    trans_filt.Update()
    poly = pv.wrap(trans_filt.GetOutputDataObject(0))
    # the bug is that e.g. 0.98 gets truncated to 0
    assert poly.points[-1, 1] != 0


def test_scale():
    mesh = examples.load_airplane()

    xyz = np.random.default_rng().random(3)
    scale1 = mesh.copy()
    scale2 = mesh.copy()
    scale1.scale(xyz, inplace=True)
    scale2.points *= xyz
    scale3 = mesh.scale(xyz, inplace=False)
    assert np.allclose(scale1.points, scale2.points)
    assert np.allclose(scale3.points, scale2.points)
    # test scalar scale case
    scale1 = mesh.copy()
    scale2 = mesh.copy()
    xyz = 4.0
    scale1.scale(xyz, inplace=True)
    scale2.scale([xyz] * 3, inplace=True)
    assert np.allclose(scale1.points, scale2.points)
    # test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.scale(xyz)
    assert isinstance(out, pv.ImageData)


def test_flip_x():
    mesh = examples.load_airplane()
    flip_x1 = mesh.copy()
    flip_x2 = mesh.copy()
    flip_x1.flip_x(point=(0, 0, 0), inplace=True)
    flip_x2.points[:, 0] *= -1.0
    assert np.allclose(flip_x1.points, flip_x2.points)
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.flip_x()
    assert isinstance(out, pv.ImageData)


def test_flip_y():
    mesh = examples.load_airplane()
    flip_y1 = mesh.copy()
    flip_y2 = mesh.copy()
    flip_y1.flip_y(point=(0, 0, 0), inplace=True)
    flip_y2.points[:, 1] *= -1.0
    assert np.allclose(flip_y1.points, flip_y2.points)
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.flip_y()
    assert isinstance(out, pv.ImageData)


def test_flip_z():
    mesh = examples.load_airplane()
    flip_z1 = mesh.copy()
    flip_z2 = mesh.copy()
    flip_z1.flip_z(point=(0, 0, 0), inplace=True)
    flip_z2.points[:, 2] *= -1.0
    assert np.allclose(flip_z1.points, flip_z2.points)
    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.flip_z()
    assert isinstance(out, pv.ImageData)


def test_flip_normal():
    mesh = examples.load_airplane()
    flip_normal1 = mesh.copy()
    flip_normal2 = mesh.copy()
    flip_normal1.flip_normal(normal=[1.0, 0.0, 0.0], inplace=True)
    flip_normal2.flip_x(inplace=True)
    assert np.allclose(flip_normal1.points, flip_normal2.points)

    flip_normal3 = mesh.copy()
    flip_normal4 = mesh.copy()
    flip_normal3.flip_normal(normal=[0.0, 1.0, 0.0], inplace=True)
    flip_normal4.flip_y(inplace=True)
    assert np.allclose(flip_normal3.points, flip_normal4.points)

    flip_normal5 = mesh.copy()
    flip_normal6 = mesh.copy()
    flip_normal5.flip_normal(normal=[0.0, 0.0, 1.0], inplace=True)
    flip_normal6.flip_z(inplace=True)
    assert np.allclose(flip_normal5.points, flip_normal6.points)

    # Test non-point-based mesh doesn't fail
    mesh = examples.load_uniform()
    out = mesh.flip_normal(normal=[1.0, 0.0, 0.5])
    assert isinstance(out, pv.ImageData)


@pytest.mark.parametrize('bounds', [(-1, 1, -1, 1, -1, 1), (0, 10, -5, 5, 2, 8)])
@pytest.mark.parametrize('inplace', [True, False])
def test_resize_bounds(sphere, bounds, inplace):
    """Test resize method with bounds parameter."""
    resized = sphere.resize(bounds=bounds, inplace=inplace)

    assert np.allclose(resized.bounds, bounds, atol=1e-10)
    assert (sphere is resized) == inplace


@pytest.mark.parametrize('bounds_size', [2.0, (0.5, 2.5, 3.5)])
@pytest.mark.parametrize('center', [None, (0.0, 0.0, 0.0), (1.5, 2.5, 3.5)])
def test_resize_bounds_size(sphere, bounds_size, center):
    """Test resize method with bounds_size parameter."""
    expected_center = sphere.center if center is None else center

    resized = sphere.resize(bounds_size=bounds_size, center=center)
    new_size = resized.bounds_size
    assert np.allclose(new_size, bounds_size)
    assert np.allclose(resized.center, expected_center)


@pytest.mark.parametrize('length', [42, 5.0])
@pytest.mark.parametrize('center', [None, (0.0, 0.0, 0.0), (1.5, 2.5, 3.5)])
def test_resize_length(sphere, length, center):
    """Test resize method with length parameter."""
    expected_center = sphere.center if center is None else center

    resized = sphere.resize(length=length, center=center)
    new_length = resized.length
    assert np.isclose(new_length, length)
    assert np.allclose(resized.center, expected_center)


@pytest.mark.parametrize('mesh', [pv.MultiBlock(), pv.PolyData()])
def test_resize_empty(mesh):
    resized = mesh.resize()
    assert resized.is_empty
    assert isinstance(resized, type(mesh))
    assert resized is not mesh


def test_resize_raises(sphere):
    """Test resize method error handling."""

    match = (
        'Cannot specify more than one resizing method. '
        'Choose either `bounds`, `bounds_size`, or `length` independently.'
    )
    with pytest.raises(ValueError, match=re.escape(match)):
        sphere.resize(bounds=[-1, 1, -1, 1, -1, 1], bounds_size=2.0)
    with pytest.raises(ValueError, match=re.escape(match)):
        sphere.resize(length=5, bounds_size=2.0)
    with pytest.raises(ValueError, match=re.escape(match)):
        sphere.resize(bounds=[-1, 1, -1, 1, -1, 1], length=5)

    match = '`bounds`, `bounds_size`, and `length` cannot all be None. Choose one resizing method.'
    with pytest.raises(ValueError, match=re.escape(match)):
        sphere.resize()

    match = '`center` can only be used with the `bounds_size` and `length` parameters.'
    with pytest.raises(ValueError, match=re.escape(match)):
        sphere.resize(bounds=[-1, 1, -1, 1, -1, 1], center=(0, 0, 0))

    match = '{name} values must all be greater than 0.0.'
    with pytest.raises(ValueError, match=match.format(name='length')):
        sphere.resize(length=0)
    with pytest.raises(ValueError, match=match.format(name='bounds_size')):
        sphere.resize(bounds_size=[-1, 2, 3])


def test_resize_zero_extent(plane):
    # This should not fail even with zero Z extent
    target_bounds = [-1, 1, -1, 1, -1, 1]
    resized = plane.resize(bounds=target_bounds)

    # X and Y should be resized, Z should remain at the target Z center
    expected_z_center = (target_bounds[4] + target_bounds[5]) / 2
    assert np.allclose(resized.points[:, 2], expected_z_center)


def test_resize_multiblock():
    sphere = pv.Sphere(center=(1, 2, 3))
    cube = pv.Cube(center=(-1, -2, -3))
    multi = pv.MultiBlock({'sphere': sphere, 'cube': cube})

    new_size = (7, 8, 9)
    resized = multi.resize(bounds_size=new_size)
    assert np.allclose(resized.bounds_size, new_size)
    # Test that blocks were not resized individually, but were
    # instead resized as part of the whole
    assert not np.allclose(resized['sphere'].bounds_size, new_size)
    assert not np.allclose(resized['cube'].bounds_size, new_size)


def _add_vtk_array(dataset, name, values, association: Literal['point', 'cell']):
    arr = _vtk.vtkFloatArray()
    arr.SetName(name)
    arr.SetNumberOfComponents(1)

    for v in values:
        arr.InsertNextValue(float(v))

    if association == 'point':
        dataset.GetPointData().AddArray(arr)
    else:  # association == "cell":
        dataset.GetCellData().AddArray(arr)


def _add_invalid_arrays(mesh):
    # Invalid point arrays (multiple), need more than 4 to test truncated repr
    _add_vtk_array(mesh, 'foo', range(10), association='point')
    _add_vtk_array(mesh, 'bar', range(15), association='point')
    _add_vtk_array(mesh, 'baz', range(12), association='point')
    _add_vtk_array(mesh, 'qux', range(13), association='point')
    _add_vtk_array(mesh, 'fred', range(14), association='point')
    _add_vtk_array(mesh, 'waldo', range(16), association='point')
    _add_vtk_array(mesh, 'thud', range(17), association='point')

    # Invalid cell array (single)
    _add_vtk_array(mesh, 'ham', range(11), association='cell')


@pytest.fixture
def sphere_with_invalid_arrays(sphere):
    _add_invalid_arrays(sphere)
    return sphere


@pytest.fixture
def grid_with_invalid_arrays(hexbeam):
    _add_invalid_arrays(hexbeam)
    return hexbeam


@pytest.mark.parametrize('as_composite', [True, False])
def test_validate_mesh_is_valid(sphere_with_invalid_arrays, as_composite):
    mesh = pv.PolyData()
    mesh = mesh.cast_to_multiblock() if as_composite else mesh
    report = mesh.validate_mesh()
    assert report.is_valid
    assert isinstance(report.mesh, pv.MultiBlock if as_composite else pv.PolyData)
    assert report.mesh is not mesh
    output_polydata = report.mesh[0] if as_composite else report.mesh
    assert 'validity_state' in output_polydata.array_names

    invalid_mesh = (
        pv.MultiBlock([sphere_with_invalid_arrays]) if as_composite else sphere_with_invalid_arrays
    )
    assert not invalid_mesh.validate_mesh().is_valid


def test_validate_mesh_default_fields():
    mesh = pv.ImageData()
    report1 = str(mesh.validate_mesh())
    report2 = str(mesh.validate_mesh(['data', 'cells', 'points']))
    assert report1 == report2


def test_validate_mesh_exclude_fields():
    mesh = pv.PolyData()
    exclude = str(mesh.validate_mesh(exclude_fields='cells'))
    include = str(mesh.validate_mesh(['data', 'points']))
    assert exclude == include

    exclude = str(mesh.validate_mesh(exclude_fields=['cells', 'points', 'cell_data_wrong_length']))
    include = str(mesh.validate_mesh('point_data_wrong_length'))
    assert exclude == include

    match = "Excluded field 'cells' must be a subset of the validation fields."
    with pytest.raises(ValueError, match=match):
        mesh.validate_mesh('points', exclude_fields='cells')

    match = "Excluded field 'points' must be a subset of the validation fields."
    with pytest.raises(ValueError, match=match):
        mesh.validate_mesh('cells', exclude_fields='points')

    match = "Excluded field 'data' must be a subset of the validation fields."
    with pytest.raises(ValueError, match=match):
        mesh.validate_mesh('points', exclude_fields='data')

    match = "Excluded field 'negative_size' must be a subset of the validation fields."
    with pytest.raises(ValueError, match=match):
        mesh.validate_mesh('points', exclude_fields='negative_size')


def test_validate_mesh_exclude_fields_subset(invalid_tetra_negative_volume):
    NEGATIVE_SIZE = 'negative_size'
    mesh = invalid_tetra_negative_volume
    assert not mesh.validate_mesh().is_valid
    assert not mesh.validate_mesh(NEGATIVE_SIZE).is_valid
    assert not mesh.validate_mesh('cells').is_valid
    assert mesh.validate_mesh('cells', exclude_fields=NEGATIVE_SIZE).is_valid

    # Test all cell fields are included except for the excluded one
    report = str(mesh.validate_mesh('cells', exclude_fields=NEGATIVE_SIZE, report_body='fields'))
    expected = (
        'Invalid cell ids:\n'
        '    Coincident points        : []\n'
        '    Degenerate faces         : []\n'
        '    Intersecting edges       : []\n'
        '    Intersecting faces       : []\n'
        '    Invalid point references : []\n'
        '    Inverted faces           : []\n'
        '    Non-contiguous edges     : []\n'
        '    Non-convex               : []\n'
        '    Non-planar faces         : []\n'
        '    Wrong number of points   : []\n'
        '    Zero size                : []'
    )
    assert expected in report
    assert 'Negative size' not in report

    fields = ['data', 'points', NEGATIVE_SIZE]
    report = str(mesh.validate_mesh(fields, exclude_fields=fields, report_body='fields'))
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : UnstructuredGrid\n'
        '    N Points                 : 4\n'
        '    N Cells                  : 1\n'
        '    Cell types               : {TETRA}\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()'
    )
    assert report == expected


def test_validate_mesh_cell_status():
    mesh = pv.PolyData()
    report_enum = mesh.validate_mesh(pv.CellStatus.DEGENERATE_FACES)
    report_str = mesh.validate_mesh('degenerate_faces')
    assert str(report_enum) == str(report_str)

    report_enum = mesh.validate_mesh(['unused_points', pv.CellStatus.DEGENERATE_FACES])
    report_str = mesh.validate_mesh(['unused_points', 'degenerate_faces'])
    assert str(report_enum) == str(report_str)


def test_validate_mesh_message(sphere_with_invalid_arrays):
    assert pv.PolyData().validate_mesh().message is None
    assert sphere_with_invalid_arrays.validate_mesh().message


def test_validate_mesh_point_arrays(sphere_with_invalid_arrays):
    # Dataset had invalid point AND cell arrays, but we validate point arrays only
    report = sphere_with_invalid_arrays.validate_mesh(['point_data_wrong_length'])
    assert report.point_data_wrong_length == ['foo', 'bar', 'baz', 'qux', 'fred', 'waldo', 'thud']
    assert report.cell_data_wrong_length is None

    # Clear cell arrays and validate ALL arrays
    sphere_with_invalid_arrays.cell_data.clear()
    report = sphere_with_invalid_arrays.validate_mesh('data')
    assert report.point_data_wrong_length == ['foo', 'bar', 'baz', 'qux', 'fred', 'waldo', 'thud']
    assert report.cell_data_wrong_length == []

    match = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 7 point arrays with incorrect length (length must be 422). '
        "Invalid arrays: 'foo' (10), 'bar' (15), 'baz' (12), 'qux' (13), ..."
    )
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        report = sphere_with_invalid_arrays.validate_mesh(action='warn')
    assert report.message == match


def test_validate_mesh_cell_arrays(sphere_with_invalid_arrays):
    # Dataset had invalid point AND cell arrays, but we validate cell arrays only
    report = sphere_with_invalid_arrays.validate_mesh('cell_data_wrong_length')
    assert report.cell_data_wrong_length == ['ham']
    assert report.point_data_wrong_length is None

    # Clear point arrays and validate ALL arrays
    sphere_with_invalid_arrays.point_data.clear()
    report = sphere_with_invalid_arrays.validate_mesh('data')
    assert report.cell_data_wrong_length == ['ham']
    assert report.point_data_wrong_length == []

    match = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 1 cell array with incorrect length (length must be 840). '
        "Invalid array: 'ham' (11)"
    )
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        report = sphere_with_invalid_arrays.validate_mesh(action='warn')
    assert report.message == match


def test_validate_mesh_raises(sphere_with_invalid_arrays):
    match = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 7 point arrays with incorrect length (length must be 422).'
        " Invalid arrays: 'foo' (10), 'bar' (15), 'baz' (12), 'qux' (13), ...\n"
        ' - Mesh has 1 cell array with incorrect length (length must be 840). '
        "Invalid array: 'ham' (11)"
    )
    with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
        sphere_with_invalid_arrays.validate_mesh(action='error')


@pytest.mark.needs_vtk_version(less_than=(9, 6, 0))
def test_validate_mesh_planarity_tolerance():
    match = 'Planarity tolerance requires VTK 9.6 or later.'
    with pytest.raises(pv.VTKVersionError, match=match):
        pv.UnstructuredGrid().validate_mesh(planarity_tolerance=0.2)


@pytest.mark.needs_vtk_version(9, 6, 0)
def test_validate_mesh_planarity_tolerance_polyhedron():
    # Build a hex-shaped polyhedron whose top face is non-planar (one vertex
    # pushed up out of the plane). With a strict planarity tolerance the
    # mesh is flagged invalid; with a loose tolerance it is accepted.
    points = [
        [0.0, 0.0, 0.0],
        [1.0, 0.0, 0.0],
        [1.0, 1.0, 0.0],
        [0.0, 1.0, 0.0],
        [0.0, 0.0, 1.0],
        [1.0, 0.0, 1.0],
        [1.0, 1.0, 2.0],  # pushed up out of the top face plane
        [0.0, 1.0, 1.0],
    ]
    faces = [
        [4, 0, 1, 2, 3],  # bottom
        [4, 4, 5, 6, 7],  # top (non-planar)
        [4, 0, 1, 5, 4],
        [4, 1, 2, 6, 5],
        [4, 2, 3, 7, 6],
        [4, 3, 0, 4, 7],
    ]
    polyhedron_connectivity = [len(faces), *[item for face in faces for item in face]]
    cells = [len(polyhedron_connectivity), *polyhedron_connectivity]
    mesh = pv.UnstructuredGrid(cells, [pv.CellType.POLYHEDRON], points)

    # Strict tolerance flags non-planarity
    report_strict = mesh.validate_mesh(planarity_tolerance=0.001)
    assert not report_strict.is_valid
    assert 'non-planar' in str(report_strict.message).lower()

    # Loose tolerance accepts the mesh's planarity (no NON_PLANAR_FACES status)
    report_loose = mesh.validate_mesh(planarity_tolerance=10.0)
    assert 'non-planar' not in str(report_loose.message or '').lower()


@pytest.fixture
def invalid_random_polydata():
    n = 20
    rng = np.random.default_rng(seed=103)
    points = rng.random(n * 3).reshape(-1, 3)

    faces = [[0, 1, n + 1]]
    faces = np.column_stack(
        (
            np.ones(
                len(faces),
            )
            * 3,
            faces,
        )
    ).astype(int)
    points = np.append(points, [[np.nan, 0, 0]], axis=0)
    return pv.PolyData(points, faces=faces)


def test_validate_mesh_report_str():
    report = pv.Sphere().validate_mesh(report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : PolyData\n'
        '    N Points                 : 842\n'
        '    N Cells                  : 1680\n'
        '    Cell types               : {TRIANGLE}\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []\n'
        'Invalid point ids:\n'
        '    Non-finite points        : []\n'
        '    Unused points            : []\n'
        'Invalid cell ids:\n'
        '    Coincident points        : []\n'
        '    Degenerate faces         : []\n'
        '    Intersecting edges       : []\n'
        '    Intersecting faces       : []\n'
        '    Invalid point references : []\n'
        '    Inverted faces           : []\n'
        '    Negative size            : []\n'
        '    Non-contiguous edges     : []\n'
        '    Non-convex               : []\n'
        '    Non-planar faces         : []\n'
        '    Wrong number of points   : []\n'
        '    Zero size                : []'
    )
    assert actual == expected


def test_validate_mesh_composite_report_str():
    multi = pv.Sphere().cast_to_multiblock()
    report = multi.validate_mesh(report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : MultiBlock\n'
        '    N Blocks                 : 1\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Blocks with invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []\n'
        'Blocks with invalid points:\n'
        '    Non-finite points        : []\n'
        '    Unused points            : []\n'
        'Blocks with invalid cells:\n'
        '    Coincident points        : []\n'
        '    Degenerate faces         : []\n'
        '    Intersecting edges       : []\n'
        '    Intersecting faces       : []\n'
        '    Invalid point references : []\n'
        '    Inverted faces           : []\n'
        '    Negative size            : []\n'
        '    Non-contiguous edges     : []\n'
        '    Non-convex               : []\n'
        '    Non-planar faces         : []\n'
        '    Wrong number of points   : []\n'
        '    Zero size                : []'
    )
    assert actual == expected


def test_validate_mesh_str_invalid_mesh(invalid_random_polydata):
    report = invalid_random_polydata.validate_mesh(
        exclude_fields=['negative_size', 'zero_size'], report_body='fields'
    )
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                         : PolyData\n'
        '    N Points                     : 21\n'
        '    N Cells                      : 1\n'
        '    Cell types                   : {TRIANGLE}\n'
        'Report summary:\n'
        '    Is valid                     : False\n'
        "    Invalid fields (3)           : ('non_finite_points', 'unused_points', "
        "'invalid_point_references')\n"
        'Invalid data arrays:\n'
        '    Cell data wrong length       : []\n'
        '    Point data wrong length      : []\n'
        'Invalid point ids:\n'
        '    Non-finite points (1)        : [20]\n'
        '    Unused points (19)           : [2, 3, 4, 5, 6, 7, ...]\n'
        'Invalid cell ids:\n'
        '    Coincident points            : []\n'
        '    Degenerate faces             : []\n'
        '    Intersecting edges           : []\n'
        '    Intersecting faces           : []\n'
        '    Invalid point references (1) : [0]\n'
        '    Inverted faces               : []\n'
        '    Non-contiguous edges         : []\n'
        '    Non-convex                   : []\n'
        '    Non-planar faces             : []\n'
        '    Wrong number of points       : []'
    )
    assert actual == expected


@pytest.fixture
def invalid_nested_multiblock(invalid_random_polydata):
    return pv.MultiBlock(
        dict(
            none=None,
            poly_root=invalid_random_polydata,
            nested=pv.MultiBlock(dict(poly_nested=invalid_random_polydata.copy())),
        )
    )


def test_validate_mesh_composite_str_invalid_mesh(invalid_nested_multiblock):
    report = invalid_nested_multiblock.validate_mesh(
        exclude_fields=['negative_size', 'zero_size'], report_body='fields'
    )
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                         : MultiBlock\n'
        '    N Blocks                     : 3\n'
        'Report summary:\n'
        '    Is valid                     : False\n'
        "    Invalid fields (3)           : ('non_finite_points', 'unused_points', "
        "'invalid_point_references')\n"
        'Blocks with invalid data arrays:\n'
        '    Cell data wrong length       : []\n'
        '    Point data wrong length      : []\n'
        'Blocks with invalid points:\n'
        '    Non-finite points (2)        : [1, 2]\n'
        '    Unused points (2)            : [1, 2]\n'
        'Blocks with invalid cells:\n'
        '    Coincident points            : []\n'
        '    Degenerate faces             : []\n'
        '    Intersecting edges           : []\n'
        '    Intersecting faces           : []\n'
        '    Invalid point references (2) : [1, 2]\n'
        '    Inverted faces               : []\n'
        '    Non-contiguous edges         : []\n'
        '    Non-convex                   : []\n'
        '    Non-planar faces             : []\n'
        '    Wrong number of points       : []'
    )
    assert actual == expected


def test_validate_mesh_composite_message(invalid_nested_multiblock):
    multi = invalid_nested_multiblock
    report = multi.validate_mesh(exclude_fields=['negative_size', 'zero_size'])
    actual = report.message
    expected = (
        'MultiBlock mesh is not valid:\n'
        " * Block id 1 'poly_root' PolyData mesh is not valid:\n"
        '   - Mesh has 19 unused points not referenced by any cell. Invalid point '
        'ids: [2, 3, 4, 5, 6, 7, ...]\n'
        '   - Mesh has 1 non-finite point. Invalid point id: [20]\n'
        '   - Mesh has 1 TRIANGLE cell with invalid point references. Invalid cell '
        'id: [0]\n'
        " * Block id 2 'nested' MultiBlock mesh is not valid:\n"
        "   * Block id 0 'poly_nested' PolyData mesh is not valid:\n"
        '     - Mesh has 19 unused points not referenced by any cell. Invalid '
        'point ids: [2, 3, 4, 5, 6, 7, ...]\n'
        '     - Mesh has 1 non-finite point. Invalid point id: [20]\n'
        '     - Mesh has 1 TRIANGLE cell with invalid point references. Invalid cell '
        'id: [0]'
    )
    assert actual == expected


def test_validate_mesh_composite_subreports(invalid_nested_multiblock):
    report = invalid_nested_multiblock.validate_mesh()
    # Test subreports
    assert len(report) == len(invalid_nested_multiblock)
    assert report[0] is None

    index = 1
    expected_subreport = str(invalid_nested_multiblock[index].validate_mesh())
    actual_subreport = str(report[index])
    assert actual_subreport == expected_subreport

    assert isinstance(report, Sized)
    for subreport in report:
        assert isinstance(subreport, (type(report), type(None)))

    match = 'Indexing mesh validation reports is only supported for composite meshes.'
    poly_subreport = report[1]
    assert isinstance(poly_subreport.mesh, pv.PolyData)
    with pytest.raises(TypeError, match=match):
        poly_subreport[0]

    multi_subreport = report[2]
    assert isinstance(multi_subreport.mesh, pv.MultiBlock)
    poly_subreport = multi_subreport[0]
    assert isinstance(poly_subreport.mesh, pv.PolyData)
    with pytest.raises(TypeError, match=match):
        poly_subreport[0]

    match = 'Length of mesh validation report is only defined for composite meshes.'
    with pytest.raises(TypeError, match=match):
        len(poly_subreport)


def test_validate_mesh_str_filtered():
    report = pv.PolyData().validate_mesh(['data', 'unused_points'], report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : PolyData\n'
        '    N Points                 : 0\n'
        '    N Cells                  : 0\n'
        '    Cell types               : set()\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []\n'
        'Invalid point ids:\n'
        '    Unused points            : []'
    )
    assert actual == expected

    report = pv.PolyData().validate_mesh(['memory_safe'], report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : PolyData\n'
        '    N Points                 : 0\n'
        '    N Cells                  : 0\n'
        '    Cell types               : set()\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []\n'
        'Invalid cell ids:\n'
        '    Invalid point references : []'
    )
    assert actual == expected


def test_validate_mesh_pointset(ant):
    pset = ant.cast_to_pointset()
    report = pset.validate_mesh(report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : PointSet\n'
        '    N Points                 : 486\n'
        '    N Cells                  : 0\n'
        '    Cell types               : set()\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []\n'
        'Invalid point ids:\n'
        '    Non-finite points        : []'
    )
    assert actual == expected

    report = pset.validate_mesh('data', report_body='fields')
    actual = str(report)
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type                     : PointSet\n'
        '    N Points                 : 486\n'
        '    N Cells                  : 0\n'
        '    Cell types               : set()\n'
        'Report summary:\n'
        '    Is valid                 : True\n'
        '    Invalid fields           : ()\n'
        'Invalid data arrays:\n'
        '    Cell data wrong length   : []\n'
        '    Point data wrong length  : []'
    )
    assert actual == expected


def test_validate_mesh_report_body(invalid_tetra_negative_volume):
    report = pv.PolyData().validate_mesh(report_body='message')
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type           : PolyData\n'
        '    N Points       : 0\n'
        '    N Cells        : 0\n'
        '    Cell types     : set()\n'
        'Report summary:\n'
        '    Is valid       : True\n'
        '    Invalid fields : ()'
    )
    actual = str(report)
    assert actual == expected

    report = invalid_tetra_negative_volume.validate_mesh(report_body='message')
    expected = (
        'Mesh Validation Report\n'
        '----------------------\n'
        'Mesh info:\n'
        '    Type               : UnstructuredGrid\n'
        '    N Points           : 4\n'
        '    N Cells            : 1\n'
        '    Cell types         : {TETRA}\n'
        'Report summary:\n'
        '    Is valid           : False\n'
        "    Invalid fields (1) : ('negative_size',)\n"
        'Error message:\n'
        '    UnstructuredGrid mesh is not valid:\n'
        '     - Mesh has 1 TETRA cell with negative volume. Invalid cell id: [0]'
    )
    actual = str(report)
    assert actual == expected


def test_cell_validator_pointset_raises():
    match = 'Cell operations are not supported'
    with pytest.raises(pv.PointSetCellOperationError, match=match):
        pv.PointSet().cell_validator()


def test_cell_validator():
    sphere = pv.Sphere()
    sphere.cell_data['data'] = range(sphere.n_cells)
    validated = sphere.cell_validator()
    assert validated.active_scalars_name == 'validity_state'
    assert isinstance(validated, pv.PolyData)
    assert validated.field_data.keys() == ['invalid', *CELL_STATUS_ARRAY_NAMES]
    assert validated.array_names == [
        'validity_state',
        'invalid',
        *CELL_STATUS_ARRAY_NAMES,
        'Normals',
        'data',
    ]
    for name in CELL_STATUS_ARRAY_NAMES:
        array = validated.field_data[name]
        assert array.shape == (0,)


@pytest.mark.needs_vtk_version(9, 6, 0)
def test_cell_status():
    expected_pyvista_values = list(pv.CellStatus)
    expected_vtk_values = list(vars(_vtk.vtkCellStatus).values())

    # Map VTK enum members PyVista enum members
    VTK_TO_CELL_STATUS = {
        _vtk.vtkCellStatus.Valid: pv.CellStatus.VALID,
        _vtk.vtkCellStatus.WrongNumberOfPoints: pv.CellStatus.WRONG_NUMBER_OF_POINTS,
        _vtk.vtkCellStatus.IntersectingEdges: pv.CellStatus.INTERSECTING_EDGES,
        _vtk.vtkCellStatus.IntersectingFaces: pv.CellStatus.INTERSECTING_FACES,
        _vtk.vtkCellStatus.NoncontiguousEdges: pv.CellStatus.NON_CONTIGUOUS_EDGES,
        _vtk.vtkCellStatus.Nonconvex: pv.CellStatus.NON_CONVEX,
        _vtk.vtkCellStatus.FacesAreOrientedIncorrectly: pv.CellStatus.INVERTED_FACES,
        _vtk.vtkCellStatus.NonPlanarFaces: pv.CellStatus.NON_PLANAR_FACES,
        _vtk.vtkCellStatus.DegenerateFaces: pv.CellStatus.DEGENERATE_FACES,
        _vtk.vtkCellStatus.CoincidentPoints: pv.CellStatus.COINCIDENT_POINTS,
    }

    for vtk_val, pyvista_val in VTK_TO_CELL_STATUS.items():
        assert vtk_val == pyvista_val

        assert vtk_val in expected_vtk_values
        assert pyvista_val in expected_pyvista_values

        expected_vtk_values.remove(vtk_val)
        expected_pyvista_values.remove(pyvista_val)

    # Ensure all values are accounted for and we're not missing any
    assert expected_vtk_values == []
    # There should only be pyvista-only status values
    pyvista_specific_values = [info.value for info in _PYVISTA_CELL_STATUS_INFO.values()]
    assert expected_pyvista_values == pyvista_specific_values


@pytest.fixture
def invalid_tetra_missing_point():
    # Define tetra with one point missing
    cells = [3, 0, 1, 2]
    celltypes = [pv.CellType.TETRA]
    points = [
        [-1.0, 1.0, -1.0],
        [1.0, 1.0, 1.0],
        [-1.0, 1.0, -1.0],
    ]
    return pv.UnstructuredGrid(cells, celltypes, points)


@pytest.fixture
def invalid_tetra_negative_volume():
    # Regular tetra but with first two points swapped
    cells = [4, 0, 1, 2, 3]
    celltypes = [pv.CellType.TETRA]
    points = [
        [1.0, 1.0, 1.0],
        [1.0, -1.0, -1.0],
        [-1.0, 1.0, -1.0],
        [-1.0, -1.0, 1.0],
    ]
    return pv.UnstructuredGrid(cells, celltypes, points)


@pytest.mark.parametrize('as_composite', [True, False])
def test_cell_validator_invalid_tetra(
    invalid_tetra_missing_point, invalid_tetra_negative_volume, as_composite
):
    # Use vtkAppend instead of pv.merge for consistent ordering
    # since pyvista merge order changed in VTK 9.5.
    append = _vtk.vtkAppendFilter()
    append.AddInputData(invalid_tetra_missing_point)
    append.AddInputData(invalid_tetra_negative_volume)
    append.Update()

    invalid_input = pv.wrap(append.GetOutput())
    mesh = invalid_input.cast_to_multiblock() if as_composite else invalid_input
    validated = mesh.cell_validator()
    assert type(validated) is type(mesh)
    single_mesh = validated[0] if as_composite else validated
    for name in CELL_STATUS_ARRAY_NAMES:
        if name in (
            pv.CellStatus.WRONG_NUMBER_OF_POINTS.name.lower(),
            pv.CellStatus.ZERO_SIZE.name.lower(),
        ):
            expected_cell_ids = [0]
            assert single_mesh[name].tolist() == expected_cell_ids
        elif name == pv.CellStatus.NEGATIVE_SIZE.name.lower():
            expected_cell_ids = [1]
            assert single_mesh[name].tolist() == expected_cell_ids
        else:
            array = single_mesh.field_data[name]
            assert array.shape == (0,)


def test_validate_mesh_negative_volume(invalid_tetra_negative_volume):
    message = invalid_tetra_negative_volume.validate_mesh().message
    expected = (
        'UnstructuredGrid mesh is not valid:\n'
        ' - Mesh has 1 TETRA cell with negative volume. Invalid cell id: [0]'
    )
    assert message == expected


def test_validate_mesh_degenerate_cells():
    def append_mixed_cells(dataset):
        # Use append, not pv.merge, due to change in merge order in vtk 9.5
        valid_tetra = examples.cells.Tetrahedron().translate((2, 2, 2))
        valid_vertex = examples.cells.Vertex().translate((-2, -2, -2))
        append = _vtk.vtkAppendFilter()
        append.AddInputData(dataset)
        append.AddInputData(valid_tetra)
        append.AddInputData(valid_vertex)
        append.Update()
        return pv.wrap(append.GetOutput())

    # Line with coincident points
    invalid_mesh = pv.Line((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        state = mesh.cell_validator()['validity_state']
        assert state[0] == pv.CellStatus.ZERO_SIZE
    match = 'Mesh has 1 LINE cell with zero length. Invalid cell id: [0]'
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
            mesh.validate_mesh(action='error')

    # Degenerate triangle
    points = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]
    invalid_mesh = pv.PolyData(points, faces=[3, 0, 1, 2])
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        state = mesh.cell_validator()['validity_state']
        assert state[0] == pv.CellStatus.ZERO_SIZE
    match = 'Mesh has 1 TRIANGLE cell with zero area. Invalid cell id: [0]'
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
            mesh.validate_mesh(action='error')

    # Degenerate voxel
    invalid_mesh = pv.ImageData(dimensions=(2, 2, 2), spacing=(1.0, 1.0, 0.0))
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        state = mesh.cell_validator()['validity_state']
        assert state[0] & pv.CellStatus.ZERO_SIZE
    match = 'Mesh has 1 VOXEL cell with zero volume. Invalid cell id: [0]'
    for mesh in [invalid_mesh, append_mixed_cells(invalid_mesh)]:
        with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
            mesh.validate_mesh(action='error')

    # POLY_VERTEX with no points
    invalid_mesh = pv.UnstructuredGrid([0], [pv.CellType.POLY_VERTEX], [])
    match = 'Mesh has 1 POLY_VERTEX cell with zero size. Invalid cell id: [0]'
    with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
        invalid_mesh.validate_mesh(action='error')

    # Test valid voxel with tiny volume does not generate false positive
    mesh = pv.ImageData(dimensions=(2, 2, 2), spacing=(0.0001, 0.0002, 0.0003))
    assert np.isclose(mesh.volume, 6e-12)
    assert mesh.validate_mesh().is_valid

    # Force invalid with manual tolerance
    match = 'Mesh has 1 VOXEL cell with zero volume. Invalid cell id: [0]'
    with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
        mesh.validate_mesh(size_tolerance=1e-8, action='error')
    with pytest.raises(pv.InvalidMeshError, match=re.escape(match)):
        mesh.cast_to_multiblock().validate_mesh(size_tolerance=1e-8, action='error')


def test_validate_mesh_invalid_point_references():
    # Define mesh with a cell that has point indices > n_points
    cells = [3, 0, 1, 2]
    celltypes = [pv.CellType.TRIANGLE]
    points = [0.0, 0.0, 0.0]
    grid = pv.UnstructuredGrid(cells, celltypes, points)

    report = grid.validate_mesh('invalid_point_references')
    expected_cell_ids = [0]
    assert report.invalid_point_references == expected_cell_ids


@pytest.fixture
def invalid_hexahedron():
    points = [
        [0.0, 0.0, 0.0],
        [1.0, 0.0, 0.0],
        [1.0, 1.0, 0.0],
        [0.0, 1.0, 0.0],
        [0.0, 0.0, 1.0],
        [1.0, 0.0, 1.0],
        [1.0, 1.0, 1.0],
        [0.0, 1.0, 1.0],
    ]

    # Swap first two points to create a bad cell
    cells = [8, 1, 0, 2, 3, 4, 5, 6, 7]
    celltypes = [pv.CellType.HEXAHEDRON]

    return pv.UnstructuredGrid(cells, celltypes, points)


@pytest.fixture
def poly_with_invalid_point():
    poly = pv.PolyData()
    poly.points = [[np.nan, 0.0, 0.0]]
    return poly


@pytest.fixture
def single_cell_invalid_point_references():
    return pv.PolyData([0.0, 0.0, 0.0], [3, 0, 1, 1])


@pytest.fixture
def mixed_2d_cells_invalid_point_references():
    return pv.PolyData([0.0, 0.0, 0.0], [3, 0, 1, 1, 4, 0, 1, 1, 1])


@pytest.fixture
def mixed_dimension_cells_invalid_point_references():
    return pv.PolyData([0.0, 0.0, 0.0], faces=[3, 0, 1, 1], verts=[2, 0, -1])


@pytest.mark.needs_vtk_version(9, 6, 0)
def test_cell_validator_intersecting_edges_nonconvex(invalid_hexahedron):
    validated = invalid_hexahedron.cell_validator()
    expected_cell_ids = [0]
    expected_invalid_fields = ['intersecting_edges', 'non_planar_faces', 'inverted_faces']
    for name in CELL_STATUS_ARRAY_NAMES:
        if name in expected_invalid_fields:
            assert validated[name].tolist() == expected_cell_ids, name
        else:
            array = validated.field_data[name]
            assert array.shape == (0,), name
    assert validated['invalid'].tolist() == expected_cell_ids

    # Test validating specific fields
    report = invalid_hexahedron.validate_mesh('cells')
    assert report.intersecting_edges is not None
    assert report.non_convex is not None
    assert report.inverted_faces is not None

    report = invalid_hexahedron.validate_mesh('non_convex')
    assert report.intersecting_edges is None
    assert report.non_convex is not None
    assert report.inverted_faces is None


@pytest.mark.needs_vtk_version(9, 6, 0)
@pytest.mark.skipif(sys.platform == 'Darwin', reason='Results differ for macOS and older vtk')
def test_validate_mesh_error_message(invalid_hexahedron, poly_with_invalid_point):
    def _format_composite(match):
        prefix = "MultiBlock mesh is not valid:\n * Block id 0 'Block-00' "
        return prefix + match.replace(' - ', '   - ')

    # Test single cell
    match = (
        'UnstructuredGrid mesh is not valid:\n'
        ' - Mesh has 1 HEXAHEDRON cell with intersecting edges. Invalid cell id: [0]\n'
        ' - Mesh has 1 HEXAHEDRON cell with inverted faces. Invalid cell id: [0]\n'
        ' - Mesh has 1 HEXAHEDRON cell with non-planar faces. Invalid cell id: [0]'
    )
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        invalid_hexahedron.validate_mesh(action='warn')
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(_format_composite(match))):
        invalid_hexahedron.cast_to_multiblock().validate_mesh(action='warn')

    match = (
        'UnstructuredGrid mesh is not valid:\n'
        ' - Mesh has 2 HEXAHEDRON cells with intersecting edges. Invalid cell ids: [0, 1]\n'
        ' - Mesh has 2 HEXAHEDRON cells with inverted faces. Invalid cell ids: [0, 1]\n'
        ' - Mesh has 2 HEXAHEDRON cells with non-planar faces. Invalid cell ids: [0, 1]'
    )
    invalid_hexahedrons = pv.merge([invalid_hexahedron, invalid_hexahedron.translate((3, 3, 3))])
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        invalid_hexahedrons.validate_mesh(action='warn')
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(_format_composite(match))):
        invalid_hexahedrons.cast_to_multiblock().validate_mesh(action='warn')

    # Test points
    match = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 1 unused point not referenced by any cell. Invalid point id: [0]\n'
        ' - Mesh has 1 non-finite point. Invalid point id: [0]'
    )
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        poly_with_invalid_point.validate_mesh(action='warn')
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(_format_composite(match))):
        poly_with_invalid_point.cast_to_multiblock().validate_mesh(action='warn')

    poly_with_invalid_point.points = poly_with_invalid_point.points.tolist() * 100
    # Test multiple points
    ids = '[0, 1, 2, 3, 4, 5, ...]'
    match = (
        'PolyData mesh is not valid:\n'
        f' - Mesh has 100 unused points not referenced by any cell. Invalid point ids: {ids}\n'
        f' - Mesh has 100 non-finite points. Invalid point ids: {ids}'
    )
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(match)):
        poly_with_invalid_point.validate_mesh(action='warn')
    with pytest.warns(pv.InvalidMeshWarning, match=re.escape(_format_composite(match))):
        poly_with_invalid_point.cast_to_multiblock().validate_mesh(action='warn')


@pytest.mark.needs_vtk_version((9, 5, 0), reason='Suspected issue with fixtures for older VTK')
def test_validate_mesh_distinct_cell_types(
    single_cell_invalid_point_references,
    mixed_2d_cells_invalid_point_references,
    mixed_dimension_cells_invalid_point_references,
):
    kwargs = dict(exclude_fields=['negative_size', 'zero_size'])
    message = single_cell_invalid_point_references.validate_mesh(**kwargs).message
    expected = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 1 TRIANGLE cell with invalid point references. Invalid cell id: [0]'
    )
    assert expected == message

    message = mixed_2d_cells_invalid_point_references.validate_mesh(**kwargs).message
    expected = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 1 TRIANGLE cell with invalid point references. Invalid cell id: [0]\n'
        ' - Mesh has 1 QUAD cell with invalid point references. Invalid cell id: [1]'
    )
    assert expected == message

    message = mixed_dimension_cells_invalid_point_references.validate_mesh(**kwargs).message
    expected = (
        'PolyData mesh is not valid:\n'
        ' - Mesh has 1 POLY_VERTEX cell with invalid point references. Invalid cell id: [0]\n'
        ' - Mesh has 1 TRIANGLE cell with invalid point references. Invalid cell id: [1]'
    )
    assert expected == message


@pytest.mark.parametrize('as_grid', [True, False])
@pytest.mark.parametrize('validate', [True, 'cells'])
def test_init_invalid_mesh(invalid_random_polydata, tmp_path, as_grid, validate):
    if as_grid:
        alg = _vtk.vtkAppendFilter()
        alg.AddInputData(invalid_random_polydata)
        alg.Update()
        vtk_mesh = alg.GetOutput()
        mesh = pv.UnstructuredGrid()
        mesh.ShallowCopy(vtk_mesh)
        array_args = mesh.cells, mesh.celltypes, mesh.points
    else:
        mesh = invalid_random_polydata
        vtk_mesh = _vtk.vtkPolyData()
        vtk_mesh.ShallowCopy(mesh)
        array_args = mesh.points, mesh.faces
    mesh_type = type(mesh)

    filepath = tmp_path / 'invalid.vtk'
    mesh.save(filepath)

    match = 'mesh is not valid'

    # Init from file
    with pytest.raises(pv.InvalidMeshError, match=match):
        mesh_type(filepath, validate=validate)

    # Init from unwrapped VTK mesh
    with pytest.raises(pv.InvalidMeshError, match=match):
        mesh_type(vtk_mesh, validate=validate)

    # Init from PyVista mesh
    with pytest.raises(pv.InvalidMeshError, match=match):
        mesh_type(mesh, validate=validate)

    # Init from arrays
    with pytest.raises(pv.InvalidMeshError, match=match):
        mesh_type(*array_args, validate=validate)


@pytest.mark.parametrize(
    'mesh',
    [
        pv.ImageData(),
        pv.RectilinearGrid(),
        pv.StructuredGrid(),
        pv.PointSet(),
        pv.PolyData(),
        pv.UnstructuredGrid(),
        pv.MultiBlock([pv.PolyData()]),
        # pv.ExplicitStructuredGrid(),  Seg fault with empty mesh. This type is tested separately.
    ],
)
@pytest.mark.parametrize('validate', [True, 'data'])
def test_init_mesh_validate(mesh, validate):
    mesh_type = type(mesh)
    if mesh_type is pv.MultiBlock:
        _add_invalid_arrays(mesh[0])
    else:
        _add_invalid_arrays(mesh)

    match = 'mesh is not valid'
    with pytest.raises(pv.InvalidMeshError, match=match):
        mesh_type(mesh, validate=validate)


def test_validate_mesh_explicit_structured_grid():
    grid = examples.load_explicit_structured()
    valid_grid = pv.ExplicitStructuredGrid(grid, validate=True)
    assert valid_grid == grid


def test_extract_surface_multiblock_no_args(multiblock_all_with_nested_and_none):
    # Get output directly from vtkCompositeDataGeometryFilter
    poly_from_vtk_filter = multiblock_all_with_nested_and_none._composite_geometry_filter()

    # Test branch without any config options, similar to vtkCompositeDataGeometryFilter
    kwargs = dict(
        algorithm='dataset_surface',
        pass_cellid=False,
        pass_pointid=False,
        progress_bar=False,
    )
    poly_no_config = multiblock_all_with_nested_and_none.extract_surface(**kwargs)
    assert poly_no_config == poly_from_vtk_filter


@pytest.mark.parametrize('algorithm', ['geometry', 'dataset_surface', None, _SENTINEL])
@pytest.mark.parametrize('bool_kwargs', [True, False])
def test_extract_surface_datasets(multiblock_all, algorithm, bool_kwargs):
    kwargs = dict(
        algorithm=algorithm,
        progress_bar=bool_kwargs,
        pass_cellid=bool_kwargs,
        pass_pointid=bool_kwargs,
    )
    for dataobj in (*multiblock_all, multiblock_all):
        if algorithm is _SENTINEL:
            with pytest.warns(pv.PyVistaFutureWarning):
                surf = dataobj.extract_surface(**kwargs)
        else:
            surf = dataobj.extract_surface(**kwargs)

        assert surf is not None
        assert isinstance(surf, pv.PolyData)
        assert ('vtkOriginalPointIds' in surf.point_data) == bool_kwargs
        assert ('vtkOriginalCellIds' in surf.cell_data) == bool_kwargs


@pytest.mark.parametrize('as_multiblock', [True, False])
def test_extract_surface_nonlinear(as_multiblock):
    # create a single quadratic hexahedral cell
    lin_pts = np.array(
        [
            [-1, -1, -1],  # node 0
            [1, -1, -1],  # node 1
            [1, 1, -1],  # node 2
            [-1, 1, -1],  # node 3
            [-1, -1, 1],  # node 4
            [1, -1, 1],  # node 5
            [1, 1, 1],  # node 6
            [-1, 1, 1],  # node 7
        ],
        np.double,
    )

    quad_pts = np.array(
        [
            (lin_pts[1] + lin_pts[0]) / 2,  # between point 0 and 1
            (lin_pts[1] + lin_pts[2]) / 2,  # between point 1 and 2
            (lin_pts[2] + lin_pts[3]) / 2,  # and so on...
            (lin_pts[3] + lin_pts[0]) / 2,
            (lin_pts[4] + lin_pts[5]) / 2,
            (lin_pts[5] + lin_pts[6]) / 2,
            (lin_pts[6] + lin_pts[7]) / 2,
            (lin_pts[7] + lin_pts[4]) / 2,
            (lin_pts[0] + lin_pts[4]) / 2,
            (lin_pts[1] + lin_pts[5]) / 2,
            (lin_pts[2] + lin_pts[6]) / 2,
            (lin_pts[3] + lin_pts[7]) / 2,
        ],
    )

    # introduce a minor variation to the location of the mid-side points
    quad_pts += np.random.default_rng().random(quad_pts.shape) * 0.25
    pts = np.vstack((lin_pts, quad_pts))

    cells = np.hstack((20, np.arange(20))).astype(np.int64, copy=False)
    celltypes = np.array([pv.CellType.QUADRATIC_HEXAHEDRON])
    grid = pv.UnstructuredGrid(cells, celltypes, pts)
    grid = grid.cast_to_multiblock() if as_multiblock else grid

    # expect each face to be divided 6 times since it has a midside node
    surf = grid.extract_surface(algorithm=None, progress_bar=True)
    assert surf.n_faces == 36
    surf = grid.extract_surface(algorithm='dataset_surface', progress_bar=True)
    assert surf.n_faces == 36

    # expect each face to be divided several more times than the linear extraction
    surf_subdivided = grid.extract_surface(
        algorithm=None, nonlinear_subdivision=5, progress_bar=True
    )
    assert surf_subdivided.n_faces > surf.n_faces
    match = (
        'geometry algorithm cannot process non-linear cells and therefore '
        'cannot be used to control non-linear subdivision.'
    )
    with pytest.raises(ValueError, match=match):
        grid.extract_surface(algorithm='geometry', nonlinear_subdivision=5)

    if as_multiblock:
        expected_error = RuntimeError
        match = 'could not be applied to the block at index 0'
    else:
        expected_error = ValueError
        match = (
            'Mesh contains non-linear cells which cannot be processed by the geometry algorithm.'
        )
    with pytest.raises(expected_error, match=match):
        grid.extract_surface(algorithm='geometry')

    # No subdivision, expect one face per cell
    surf_no_subdivide = grid.extract_surface(
        algorithm=None, nonlinear_subdivision=0, progress_bar=True
    )
    assert surf_no_subdivide.n_faces == 6
