import numpy as np
import pytest

from pyspat.core import MaskWindow, RectangleWindow, PolyWindow

def test_area_and_contains_simple_mask():
    mask = np.array([[0, 1, 1],
                     [1, 1, 0],
                     [0, 1, 1]], dtype=bool)
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(1.0, 1.0))
    assert W.area() == pytest.approx(mask.sum(), rel=0, abs=0)

    pts = np.array([
        (0.5, 1.5),
        (1.5, 0.5),
        (2.5, 1.5),
        (3.0, 3.0),
        (-1.0, 0.0),
    ])
    inside = W.contains(pts)
    assert inside.tolist() == [True, True, False, False, False]

def test_sample_uniform_is_inside_and_spread():
    rng = np.random.default_rng(123)
    mask = np.zeros((20, 30), bool)
    mask[4:18, 5:25] = True
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(0.5, 0.5))

    pts = W.sample_uniform(1000, rng)
    assert W.contains(pts).all()
    assert pts[:, 0].min() >= 5 * 0.5 and pts[:, 0].max() < 25 * 0.5 + 0.5
    assert pts[:, 1].min() >= 4 * 0.5 and pts[:, 1].max() < 18 * 0.5 + 0.5

def test_erode_removes_boundary_layers_and_handles_empty():
    mask = np.ones((10, 10), bool)
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(1.0, 1.0))

    W1 = W.erode(1.0)
    assert W1.mask.sum() == 64
    assert W1.area() == pytest.approx(64.0)

    Wbig = W.erode(100.0)
    assert Wbig.mask.sum() == 0
    assert Wbig.area() == 0.0

def test_distance_to_boundary_matches_expected_centre_distance():
    mask = np.zeros((21, 21), bool)
    mask[5:16, 5:16] = True
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(1.0, 1.0))

    dt = W.distance_to_boundary().values
    assert dt[10, 10] == pytest.approx(5.0)
    assert dt[5, 10] == pytest.approx(0.0)
    assert dt[6, 10] == pytest.approx(1.0)

def test_overlap_fraction_zero_pad_shift_no_wrap():
    mask = np.zeros((6, 6), bool)
    mask[1:5, 1:5] = True
    W = MaskWindow(mask)
    assert W.overlap_fraction((0.0, 0.0)) == pytest.approx(1.0)
    assert W.overlap_fraction((1.0, 0.0)) == pytest.approx(12 / 16)
    assert W.overlap_fraction((10.0, 0.0)) == pytest.approx(0.0)

def test_boundary_convention_left_bottom_inclusive_right_top_exclusive():
    mask = np.ones((4, 4), dtype=bool)
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(1.0, 1.0))
    pts_in = np.array([
        (0.0 + 1e-12, 0.0 + 1e-12),
        (0.0, 0.0),
        (3.999999999, 0.5),
        (0.5, 3.999999999),
    ])
    assert W.contains(pts_in).all()
    pts_out = np.array([
        (4.0, 0.5),
        (0.5, 4.0),
        (4.0, 4.0),
    ])
    assert (~W.contains(pts_out)).all()

def test_area_respects_spacing():
    mask = np.zeros((3, 5), bool)
    mask[1:, 2:] = True
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(2.0, 0.5))
    expected_area = 6 * 2.0 * 0.5
    assert W.area() == pytest.approx(expected_area)

def test_sample_uniform_zero_and_empty_window_errors():
    mask = np.zeros((5, 5), bool)
    W = MaskWindow(mask)
    assert W.sample_uniform(0).shape == (0, 2)
    with pytest.raises(ValueError):
        W.sample_uniform(10)

def test_erode_negative_radius_raises():
    W = MaskWindow(np.ones((3, 3), bool))
    with pytest.raises(ValueError):
        W.erode(-1.0)

def test_erode_additivity_integers_equal():
    mask = np.zeros((31, 31), bool)
    mask[4:27, 4:27] = True
    W = MaskWindow(mask, spacing=(1.0, 1.0))
    W_rs = W.erode(2.0).erode(3.0)
    W_sum = W.erode(5.0)
    assert np.array_equal(W_rs.mask, W_sum.mask)

def test_erode_monotone_subset():
    mask = np.zeros((31, 31), bool)
    mask[4:27, 4:27] = True
    W = MaskWindow(mask, spacing=(1.0, 1.0))
    A = W.erode(2.5).mask
    B = W.erode(4.0).mask
    assert np.all((~B) | A)
    assert np.all((~A) | W.mask)

def test_erode_noninteger_composition_close():
    mask = np.zeros((31, 31), bool)
    mask[4:27, 4:27] = True
    W = MaskWindow(mask, spacing=(1.0, 1.0))
    A = W.erode(2.5).erode(1.5).mask
    B = W.erode(4.0).mask
    symdiff = np.logical_xor(A, B).sum()
    perim_proxy = int(4 * np.sqrt(B.sum()))
    assert symdiff <= perim_proxy

def test_distance_to_boundary_with_anisotropic_spacing():
    mask = np.zeros((21, 21), bool)
    mask[5:16, 5:16] = True
    W = MaskWindow(mask, origin=(0.0, 0.0), spacing=(2.0, 1.0))
    dt = W.distance_to_boundary().values
    assert dt[10, 10] == pytest.approx(5.0)
    assert dt[0, 0] == pytest.approx(0.0)

def test_overlap_fraction_rounding_and_symmetry():
    mask = np.zeros((8, 8), bool)
    mask[2:6, 2:6] = True
    W = MaskWindow(mask, spacing=(1.0, 1.0))
    assert W.overlap_fraction((1.0, 0.0)) == pytest.approx(
        W.overlap_fraction((-1.0, 0.0))
    )
    f0 = W.overlap_fraction((0.49, 0.0))
    f1 = W.overlap_fraction((0.51, 0.0))
    assert f0 == pytest.approx(1.0)
    assert f1 == pytest.approx(3 * 4 / 16)

def test_rectangle_area_contains_and_boundaries():
    R = RectangleWindow(0.0, 0.0, 10.0, 5.0)
    assert R.area() == pytest.approx(50.0)

    pts_in = np.array([
        (0.0, 0.0),
        (9.999999, 2.5),
        (3.0, 4.999999),
        (1.0, 1.0),
    ])
    assert R.contains(pts_in).all()

    pts_out = np.array([
        (10.0, 2.5),
        (3.0, 5.0),
        (-1.0, 0.0),
        (0.0, -1.0),
    ])
    assert (~R.contains(pts_out)).all()

def test_rectangle_sample_uniform_erode_and_overlap():
    rng = np.random.default_rng(0)
    R = RectangleWindow(0.0, 0.0, 10.0, 5.0)

    pts = R.sample_uniform(2000, rng)
    assert R.contains(pts).all()
    assert pts[:, 0].min() >= 0.0 and pts[:, 0].max() < 10.0
    assert pts[:, 1].min() >= 0.0 and pts[:, 1].max() < 5.0

    R1 = R.erode(1.0)
    assert R1.area() == pytest.approx(24.0)

    assert R.overlap_fraction((1.0, 0.0)) == pytest.approx((9.0 * 5.0) / (10.0 * 5.0))
    assert R.overlap_fraction((2.0, 1.0)) == pytest.approx((8.0 * 4.0) / 50.0)
    assert R.overlap_fraction((100.0, 0.0)) == pytest.approx(0.0)

def test_rectangle_distance_to_boundary_centre_and_edges():
    R = RectangleWindow(0.0, 0.0, 10.0, 10.0, dtb_shape=(101, 101))
    img = R.distance_to_boundary()
    dt = img.values
    i = dt.shape[0] // 2
    j = dt.shape[1] // 2
    assert dt[i, j] == pytest.approx(5.0, abs=max(img.spacing))
    assert dt[0, 0] == pytest.approx(0.0, abs=max(img.spacing))

def test_rectangle_erode_negative_zero_large_and_additivity():
    R = RectangleWindow(-2.0, 1.0, 6.0, 9.0)
    with pytest.raises(ValueError):
        R.erode(-0.1)

    R0 = R.erode(0.0)
    assert R0.area() == pytest.approx(R.area())

    Rbig = R.erode(100.0)
    assert Rbig.area() == pytest.approx(0.0)
    assert (~Rbig.contains(np.array([[0.0, 0.0]]))).all()

    Rrs = R.erode(2.0).erode(1.5)
    Rsum = R.erode(3.5)
    assert Rrs.area() == pytest.approx(Rsum.area())

def test_rectangle_erode_monotone_subset_and_symmetry():
    rng = np.random.default_rng(0)
    R = RectangleWindow(0.0, 0.0, 12.0, 8.0)
    A = R.erode(2.0)
    B = R.erode(3.0)
    assert B.area() < A.area() < R.area()
    ptsB = B.sample_uniform(2000, rng)
    assert A.contains(ptsB).all()

def test_poly_area_contains_boundaries_and_overlap():
    import shapely.geometry as sgeom

    poly = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    P = PolyWindow(poly)

    assert P.area() == pytest.approx(100.0)

    pts_in = np.array([(1.0, 1.0), (9.999, 5.0), (5.0, 9.999)])
    assert P.contains(pts_in).all()

    pts_boundary = np.array([(0.0, 0.0), (10.0, 5.0), (5.0, 10.0)])
    assert (~P.contains(pts_boundary)).all()

    assert P.overlap_fraction((2.0, 0.0)) == pytest.approx(0.8)

def test_poly_sample_uniform_erode_distance_and_zero_large():
    import shapely.geometry as sgeom

    poly = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    P = PolyWindow(poly)

    rng = np.random.default_rng(123)
    pts = P.sample_uniform(2000, rng)
    assert P.contains(pts).all()

    P1 = P.erode(1.0)
    assert P1.area() == pytest.approx(64.0)

    Pbig = P.erode(100.0)
    assert Pbig.area() == pytest.approx(0.0)

    img = P.distance_to_boundary()
    i = img.values.shape[0] // 2
    j = img.values.shape[1] // 2
    assert img.values[i, j] == pytest.approx(5.0, abs=max(img.spacing))

def test_poly_dtb_at_points_exact_and_near_edges():
    import shapely.geometry as sgeom

    poly = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    P = PolyWindow(poly)

    pts = np.array([[5.0, 5.0], [1.0, 9.0], [9.0, 1.0], [0.1, 0.1]])
    d = P.dtb_at_points(pts)
    assert d[0] == pytest.approx(5.0)
    assert d[1] == pytest.approx(1.0)
    assert d[2] == pytest.approx(1.0)
    assert d[3] == pytest.approx(0.1, abs=1e-6)

def test_poly_with_hole_contains_and_erode_behaviour():
    import shapely.geometry as sgeom

    outer = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    hole = sgeom.Polygon([(4, 4), (6, 4), (6, 6), (4, 6)])
    poly_with_hole = sgeom.Polygon(outer.exterior.coords, [hole.exterior.coords])
    P = PolyWindow(poly_with_hole)

    assert P.area() == pytest.approx(96.0)

    pts = np.array([[5.0, 5.0], [3.0, 3.0]])
    inside = P.contains(pts)
    assert inside.tolist() == [False, True]

    d = P.dtb_at_points(np.array([[5.0, 5.0]]))
    assert d[0] == pytest.approx(1.0)

    P1 = P.erode(1.0)
    assert P1.area() == pytest.approx(48.0)

def test_poly_multipolygon_area_contains_and_erosion():
    import shapely.geometry as sgeom

    a = sgeom.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
    b = sgeom.Polygon([(3, 0), (4, 0), (4, 1), (3, 1)])
    mp = sgeom.MultiPolygon([a, b])
    P = PolyWindow(mp)

    assert P.area() == pytest.approx(2.0)
    pts = np.array([[0.5, 0.5], [3.5, 0.5], [2.0, 0.5]])
    inside = P.contains(pts)
    assert inside.tolist() == [True, True, False]

    P1 = P.erode(0.1)
    assert P1.area() == pytest.approx(2.0 * 0.8 * 0.8, rel=1e-3)

def test_poly_overlap_fraction_symmetry_and_limits():
    import shapely.geometry as sgeom

    poly = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    P = PolyWindow(poly)

    assert P.overlap_fraction((2.0, 3.0)) == pytest.approx(P.overlap_fraction((-2.0, -3.0)))
    assert P.overlap_fraction((1e6, 0.0)) == pytest.approx(0.0)
    assert P.overlap_fraction((0.0, 0.0)) == pytest.approx(1.0)

def test_poly_erode_additivity_area():
    import shapely.geometry as sgeom
    P = PolyWindow(sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]))
    a = P.erode(1.5).erode(0.5).area()
    b = P.erode(2.0).area()
    assert a == pytest.approx(b, rel=1e-6)

def test_poly_erode_monotone_subset_sampling():
    import shapely.geometry as sgeom
    rng = np.random.default_rng(1)
    P = PolyWindow(sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]))
    A = P.erode(1.0)
    B = P.erode(2.0)
    pts = B.sample_uniform(2000, rng)
    assert A.contains(pts).all()

def test_rectangle_overlap_diagonal_formula():
    R = RectangleWindow(0, 0, 10, 5)
    dx, dy = 2.3, 1.7
    expected = max(0.0, (10 - dx)) * max(0.0, (5 - dy)) / 50.0
    assert R.overlap_fraction((dx, dy)) == pytest.approx(expected, rel=1e-12)

def test_mask_contains_raises_on_bad_xy_shape():
    W = MaskWindow(np.ones((3, 3), bool))
    with pytest.raises(ValueError):
        W.contains(np.array([0.0, 1.0, 2.0]))
    with pytest.raises(ValueError):
        W.contains(np.array([[0.0], [1.0]]))

def test_rectangle_rejects_degenerate_extents_and_negative_sample():
    with pytest.raises(ValueError):
        RectangleWindow(1.0, 0.0, 0.0, 1.0)
    with pytest.raises(ValueError):
        RectangleWindow(0.0, 1.0, 1.0, 1.0)
    R = RectangleWindow(0.0, 0.0, 2.0, 1.0)
    with pytest.raises(ValueError):
        R.sample_uniform(-5)

def test_poly_rejects_non_polygon_like_geometries():
    import shapely.geometry as sgeom
    with pytest.raises(ValueError):
        PolyWindow(sgeom.LineString([(0, 0), (1, 1)]))

def test_rectangle_overlap_fraction_symmetry_and_sign_invariance():
    R = RectangleWindow(0.0, 0.0, 10.0, 5.0)
    dx, dy = 2.3, 1.7
    f = R.overlap_fraction((dx, dy))
    assert f == pytest.approx(R.overlap_fraction((-dx, dy)))
    assert f == pytest.approx(R.overlap_fraction((dx, -dy)))
    assert f == pytest.approx(R.overlap_fraction((-dx, -dy)))

def test_poly_square_overlap_matches_rectangle_diagonal_formula():
    import shapely.geometry as sgeom
    P = PolyWindow(sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]))
    R = RectangleWindow(0.0, 0.0, 10.0, 10.0)
    dx, dy = 1.7, 3.2
    expected = max(0.0, (10.0 - dx)) * max(0.0, (10.0 - dy)) / 100.0
    assert P.overlap_fraction((dx, dy)) == pytest.approx(expected, rel=1e-12)
    assert R.overlap_fraction((dx, dy)) == pytest.approx(expected, rel=1e-12)

def test_rectangle_dtb_monotone_along_central_ray():
    R = RectangleWindow(0.0, 0.0, 10.0, 10.0, dtb_shape=(101, 101))
    xs = np.linspace(5.0, 9.9, 20)
    pts = np.column_stack([xs, np.full_like(xs, 5.0)])
    d = R.dtb_at_points(pts) if hasattr(R, "dtb_at_points") else R.distance_to_boundary().values[50, 50:70]
    diffs = np.diff(d)
    assert np.all(diffs <= 1e-9)

def test_poly_hole_boundary_excluded_and_large_erode_empty():
    import shapely.geometry as sgeom
    outer = sgeom.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    hole = sgeom.Polygon([(4, 4), (6, 4), (6, 6), (4, 6)])
    poly_with_hole = sgeom.Polygon(outer.exterior.coords, [hole.exterior.coords])
    P = PolyWindow(poly_with_hole)
    assert not P.contains(np.array([[4.0, 5.0]])).item()
    P_big = P.erode(6.0)
    assert P_big.area() == pytest.approx(0.0)
    assert not P_big.contains(np.array([[5.0, 5.0]])).item()

def test_poly_multipolygon_sampling_proportional_to_area():
    import shapely.geometry as sgeom
    small = sgeom.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
    large = sgeom.Polygon([(3, 0), (5, 0), (5, 2), (3, 2)])
    P = PolyWindow(sgeom.MultiPolygon([small, large]))
    rng = np.random.default_rng(42)
    pts = P.sample_uniform(4000, rng)
    in_small = (pts[:, 0] >= 0.0) & (pts[:, 0] < 1.0) & (pts[:, 1] >= 0.0) & (pts[:, 1] < 1.0)
    frac_small = in_small.mean()
    assert frac_small == pytest.approx(0.2, abs=0.03)

def test_sample_uniform_zero_length_for_all_windows():
    rng = np.random.default_rng(1)
    Wm = MaskWindow(np.ones((3, 3), bool))
    Wr = RectangleWindow(0.0, 0.0, 2.0, 1.0)
    import shapely.geometry as sgeom
    Wp = PolyWindow(sgeom.Polygon([(0, 0), (2, 0), (2, 1), (0, 1)]))
    for W in (Wm, Wr, Wp):
        pts0 = W.sample_uniform(0, rng)
        assert pts0.shape == (0, 2)
