"""
Unit tests for library/mask_generator.py.

These cover the procedural mask functions that drive --train_inpainting:
shape/size/mode invariants, seed determinism, binary-value invariant, coverage
bounds, and combine_masks logic. visualize_masks.py covers the qualitative side
(eyeballing variety); these tests cover the contractual side.
"""

import os
import sys

import numpy as np
import pytest
from PIL import Image

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from library.mask_generator import (
    cloud_mask,
    polygon_mask,
    shape_mask,
    wobbly_ellipse_mask,
    combine_masks,
    random_mask,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _coverage(mask: Image.Image) -> float:
    arr = np.array(mask, dtype=np.uint8)
    return float((arr >= 128).sum()) / arr.size


def _is_binary(mask: Image.Image) -> bool:
    arr = np.array(mask, dtype=np.uint8)
    unique = set(np.unique(arr).tolist())
    return unique.issubset({0, 255})


# ---------------------------------------------------------------------------
# Shape / mode / size invariants
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("size", [(64, 64), (128, 96), (200, 256)])
@pytest.mark.parametrize(
    "fn",
    [
        lambda w, h, s: cloud_mask(w, h, seed=s),
        lambda w, h, s: polygon_mask(w, h, seed=s),
        lambda w, h, s: shape_mask(w, h, seed=s),
        lambda w, h, s: wobbly_ellipse_mask(w, h, seed=s),
        lambda w, h, s: random_mask(w, h, seed=s),
    ],
    ids=["cloud", "polygon", "shape", "wobbly_ellipse", "random"],
)
def test_mask_shape_and_mode(size, fn):
    w, h = size
    m = fn(w, h, 0)
    assert isinstance(m, Image.Image)
    assert m.mode == "L"
    assert m.size == (w, h)


@pytest.mark.parametrize(
    "fn",
    [
        lambda s: cloud_mask(64, 64, seed=s),
        lambda s: polygon_mask(64, 64, seed=s),
        lambda s: shape_mask(64, 64, seed=s),
        lambda s: wobbly_ellipse_mask(64, 64, seed=s),
        lambda s: random_mask(64, 64, seed=s),
    ],
    ids=["cloud", "polygon", "shape", "wobbly_ellipse", "random"],
)
def test_mask_is_binary(fn):
    # Each generator must produce strictly {0, 255} values.
    assert _is_binary(fn(42))


# ---------------------------------------------------------------------------
# Seed determinism
# ---------------------------------------------------------------------------

@pytest.mark.parametrize(
    "fn",
    [
        lambda s: cloud_mask(96, 96, seed=s),
        lambda s: polygon_mask(96, 96, seed=s),
        lambda s: shape_mask(96, 96, seed=s),
        lambda s: wobbly_ellipse_mask(96, 96, seed=s),
        lambda s: random_mask(96, 96, seed=s),
    ],
    ids=["cloud", "polygon", "shape", "wobbly_ellipse", "random"],
)
def test_seed_reproducible(fn):
    a = np.array(fn(123))
    b = np.array(fn(123))
    assert np.array_equal(a, b)


@pytest.mark.parametrize(
    "fn",
    [
        lambda s: cloud_mask(96, 96, seed=s),
        lambda s: polygon_mask(96, 96, seed=s),
        lambda s: shape_mask(96, 96, seed=s),
        lambda s: wobbly_ellipse_mask(96, 96, seed=s),
        lambda s: random_mask(96, 96, seed=s),
    ],
    ids=["cloud", "polygon", "shape", "wobbly_ellipse", "random"],
)
def test_seed_diversity(fn):
    # Different seeds should very likely give different masks. We sample many
    # seeds so a stuck generator can't pass by coincidence.
    samples = {np.array(fn(s)).tobytes() for s in range(8)}
    assert len(samples) > 1


# ---------------------------------------------------------------------------
# Coverage bounds
# ---------------------------------------------------------------------------

def test_cloud_threshold_controls_coverage():
    # threshold is approximate but should track monotonically.
    low = _coverage(cloud_mask(128, 128, threshold=0.1, seed=0))
    high = _coverage(cloud_mask(128, 128, threshold=0.3, seed=0))
    assert low < high
    # threshold ~ target fraction of masked area; allow generous slack.
    assert 0.05 <= low <= 0.20
    assert 0.20 <= high <= 0.45


def test_polygon_coverage_within_bounds():
    # Single polygon, fixed seed; coverage should fall roughly within bounds.
    # The bounds describe each polygon's area target; the rasterised result
    # can over-/under-shoot a little, so we use loose envelopes.
    samples = [
        _coverage(polygon_mask(128, 128, n_points=6, irregularity=0.0,
                               min_coverage=0.15, max_coverage=0.35,
                               n_polygons=1, seed=s))
        for s in range(16)
    ]
    mean = float(np.mean(samples))
    assert 0.05 < mean < 0.5


def test_wobbly_ellipse_coverage_tracks_target():
    # coverage parameter should approximately control the masked fraction.
    samples_low = [_coverage(wobbly_ellipse_mask(128, 128, coverage=0.15,
                                                 wobble_scale=0.1, seed=s))
                   for s in range(8)]
    samples_high = [_coverage(wobbly_ellipse_mask(128, 128, coverage=0.4,
                                                  wobble_scale=0.1, seed=s))
                    for s in range(8)]
    assert np.mean(samples_low) < np.mean(samples_high)


# ---------------------------------------------------------------------------
# combine_masks
# ---------------------------------------------------------------------------

def test_combine_masks_union_is_or():
    a = Image.fromarray(np.array([[0, 255], [0, 0]], dtype=np.uint8), mode="L")
    b = Image.fromarray(np.array([[0, 0],   [255, 0]], dtype=np.uint8), mode="L")
    out = np.array(combine_masks(a, b, mode="union"))
    assert np.array_equal(out, np.array([[0, 255], [255, 0]], dtype=np.uint8))


def test_combine_masks_intersection_is_and():
    a = Image.fromarray(np.array([[255, 255], [0, 0]], dtype=np.uint8), mode="L")
    b = Image.fromarray(np.array([[255, 0],   [0, 0]], dtype=np.uint8), mode="L")
    out = np.array(combine_masks(a, b, mode="intersection"))
    assert np.array_equal(out, np.array([[255, 0], [0, 0]], dtype=np.uint8))


def test_combine_masks_three_inputs_union():
    a = Image.fromarray(np.array([[255, 0]], dtype=np.uint8), mode="L")
    b = Image.fromarray(np.array([[0, 0]],   dtype=np.uint8), mode="L")
    c = Image.fromarray(np.array([[0, 255]], dtype=np.uint8), mode="L")
    out = np.array(combine_masks(a, b, c, mode="union"))
    assert np.array_equal(out, np.array([[255, 255]], dtype=np.uint8))


# ---------------------------------------------------------------------------
# random_mask sanity
# ---------------------------------------------------------------------------

def test_random_mask_nonempty():
    # Across a handful of seeds, at least one produced mask must have non-zero
    # coverage (a generator that always returns all-zero would silently break
    # training without a crash).
    coverages = [_coverage(random_mask(128, 128, seed=s)) for s in range(8)]
    assert max(coverages) > 0.01
