import numpy as np
import pytest
from tests.node_tests._shared import make_field


def make_tip(shape="parabola", radius=10e-9, half_angle=20.0, n_pixels=65, field=None):
    from backend.nodes.tip_model import TipModel
    if field is None:
        # 1 nm/pixel reference field
        field = make_field(shape=(128, 128), xreal=128e-9, yreal=128e-9)
    node = TipModel()
    result, = node.process(field=field, shape=shape, radius=radius,
                           half_angle=half_angle, n_pixels=n_pixels)
    return result


# ── Shape and dimensionality ────────────────────────────────────────────────

def test_tip_output_is_data_field():
    from backend.data_types import DataField
    tip = make_tip()
    assert isinstance(tip, DataField)


def test_tip_shape_matches_n_pixels():
    """Output grid must be (n_pixels, n_pixels)."""
    for n in (5, 9, 33, 65):
        tip = make_tip(n_pixels=n)
        assert tip.data.shape == (n, n)


def test_tip_n_pixels_even_forced_odd():
    """Even n_pixels should be silently bumped to n+1."""
    tip = make_tip(n_pixels=64)
    assert tip.data.shape[0] % 2 == 1
    assert tip.data.shape[0] == 65


def test_tip_xreal_equals_n_times_pixel_size():
    """xreal must equal n_pixels * pixel_size of the reference field."""
    pixel_size = 1e-9
    field = make_field(shape=(128, 128), xreal=128 * pixel_size, yreal=128 * pixel_size)
    tip = make_tip(n_pixels=65, field=field)
    assert abs(tip.xreal - 65 * pixel_size) < 1e-25


def test_tip_units_inherited_from_field():
    field = make_field()
    field.si_unit_xy = "nm"
    field.si_unit_z = "V"
    tip = make_tip(field=field)
    assert tip.si_unit_xy == "nm"
    assert tip.si_unit_z == "V"


# ── Apex and minimum conventions ────────────────────────────────────────────

@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_min_is_zero(shape):
    """All shapes must shift the data so the minimum is 0."""
    tip = make_tip(shape=shape)
    assert tip.data.min() >= -1e-15


@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_apex_at_centre(shape):
    """Centre pixel must equal the maximum for all shapes."""
    tip = make_tip(shape=shape)
    n = tip.data.shape[0]
    ci = n // 2
    assert tip.data[ci, ci] == pytest.approx(tip.data.max(), abs=1e-20)


@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_radial_symmetry(shape):
    """All shapes must be left-right and up-down symmetric."""
    tip = make_tip(shape=shape)
    data = tip.data
    assert np.allclose(data, np.flipud(data), atol=1e-12)
    assert np.allclose(data, np.fliplr(data), atol=1e-12)


# ── Parabola formula verification ────────────────────────────────────────────

def test_parabola_apex_formula():
    """Parabola apex height = (half_width)² / R (Gwyddion: z0 = 2a·x_half²)."""
    radius = 20e-9
    n = 65
    pixel_size = 1e-9
    field = make_field(shape=(256, 256), xreal=256 * pixel_size, yreal=256 * pixel_size)
    tip = make_tip(shape="parabola", radius=radius, n_pixels=n, field=field)

    x_half = (n // 2) * pixel_size    # = 32 nm
    expected_apex = x_half**2 / radius # = 1024e-18 / 20e-9 = 51.2 nm
    assert abs(tip.data[n // 2, n // 2] - expected_apex) < 1e-12 * expected_apex


def test_parabola_decreases_monotonically_from_centre():
    """Parabola row profile must be monotonically decreasing from centre outward."""
    tip = make_tip(shape="parabola", n_pixels=33)
    ci = 16
    row = tip.data[ci, :]
    assert np.all(np.diff(row[:ci + 1]) >= 0)   # left half increases toward centre
    assert np.all(np.diff(row[ci:]) <= 0)        # right half decreases from centre


def test_parabola_corner_value_is_zero():
    """Gwyddion constructs z0 so that z at (±half, ±half) = 0 exactly."""
    n = 33
    tip = make_tip(shape="parabola", n_pixels=n)
    corner = tip.data[0, 0]
    # The corner is by construction the minimum; after the min-shift it should be ~0
    assert abs(corner) < 1e-15 * tip.data[n // 2, n // 2]


# ── Cone shape ──────────────────────────────────────────────────────────────

def test_cone_apex_greater_than_edges():
    tip = make_tip(shape="cone", half_angle=20.0, n_pixels=33)
    ci = 16
    assert tip.data[ci, ci] > tip.data[0, 0]
    assert tip.data[0, 0] >= -1e-15   # edges should be ~0


def test_cone_decreases_away_from_centre():
    """Cone must be monotonically decreasing along any radial line from centre."""
    tip = make_tip(shape="cone", half_angle=20.0, n_pixels=33)
    ci = 16
    row = tip.data[ci, :]
    assert np.all(np.diff(row[ci:]) <= 0)


def test_cone_different_half_angles_differ():
    """Tips with different half-angles must produce different shapes."""
    tip_wide = make_tip(shape="cone", half_angle=40.0, n_pixels=33)
    tip_narrow = make_tip(shape="cone", half_angle=10.0, n_pixels=33)
    assert not np.allclose(tip_wide.data, tip_narrow.data)


# ── Sphere shape ─────────────────────────────────────────────────────────────

def test_sphere_curvature_near_apex():
    """Near the apex the sphere cap gives z ≈ sqrt(R²-r²), so
    z[ci,ci] - z[ci, ci+1] ≈ pixel_size² / (2R) for r << R."""
    radius = 100e-9
    pixel_size = 1e-9
    field = make_field(shape=(256, 256), xreal=256 * pixel_size, yreal=256 * pixel_size)
    tip = make_tip(shape="sphere", radius=radius, n_pixels=33, field=field)
    ci = 16
    delta_z = tip.data[ci, ci] - tip.data[ci, ci + 1]
    expected = pixel_size**2 / (2 * radius)
    # relative tolerance 0.1% (small-angle approximation holds well for r << R)
    assert abs(delta_z - expected) / expected < 1e-3
