"""
Consolidated test suite for aabpl.

Covers:
  radius_search  — all stats, multi-stat, multi-c, nest_depth, pts_target,
                   keep_cols, area_weight, config.VALIDATE, helpers
  detect_cluster_pts — all r spec types (single / list / bands / wbands),
                       multi-c, stat variants, cluster boolean integrity
  guard checks   — rand_dist / cluster_pts raise clear errors on radius_search grids
  plot smoke     — grid.plot.* run without error (Agg backend)

Run with:  python -m pytest tests/run_all_tests.py -v
"""
import pytest
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use('Agg')


# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------

def _make_pts(n=800, seed=0):
    rng = np.random.default_rng(seed)
    return pd.DataFrame({
        'x':    rng.uniform(0, 50_000, n),
        'y':    rng.uniform(0, 50_000, n),
        'val':  rng.uniform(1, 10, n),
        'val2': rng.uniform(0, 5, n),
    })


# Tiny dataset for cluster tests — 30 pts, ~2 km extent, r <= 500 m
_RNG = np.random.default_rng(42)
_CX = _RNG.uniform(580_000, 580_400, 10)
_CY = _RNG.uniform(4_510_000, 4_510_400, 10)
_SX = _RNG.uniform(580_000, 582_000, 20)
_SY = _RNG.uniform(4_510_000, 4_512_000, 20)
_X  = np.concatenate([_CX, _SX])
_Y  = np.concatenate([_CY, _SY])
_EMP = np.concatenate([_RNG.integers(50, 200, 10).astype(float),
                       _RNG.integers(1,  10,  20).astype(float)])
_POP = _RNG.integers(100, 500, 30).astype(float)
_CLUSTER_CRS = 'EPSG:32618'


def _make_cluster_pts():
    return pd.DataFrame({'x': _X, 'y': _Y, 'emp': _EMP, 'pop': _POP})


_SEARCH_DEV = {'nest_depth': 2, 'spacing_over_radius': 2.0}
_R = 5_000  # radius for radius_search tests (Cartesian, no CRS)
_CR = 500   # radius for cluster tests (metres, EPSG:32618)

# Standalone radius_* convenience functions are thin wrappers around
# radius_search(stat=<fixed>) -- previously never exercised directly (only
# through radius_search(stat=...)). Route single-string-stat calls through
# the matching wrapper until each has been hit once, so the whole suite
# naturally covers every wrapper without a dedicated test per function.
# Once all are covered, keep routing through them (simpler than reverting
# to radius_search — list-stat calls, which no wrapper supports, still go
# through radius_search directly and exercise it plenty).
_UNCOVERED_RADIUS_FNS = {'sum', 'count', 'mean', 'variance', 'std', 'cv', 'skewness', 'kurtosis'}


def _rs(pts, stat='sum', c=None, **kw):
    """Thin wrapper around radius_search (or, for coverage, the matching
    standalone radius_<stat> function) with test defaults."""
    import aabpl
    if c is None:
        c = ['val']
    _suffix_ok = isinstance(kw.get('suffix'), (str, type(None)))
    if isinstance(stat, str) and stat in _UNCOVERED_RADIUS_FNS and _suffix_ok:
        _UNCOVERED_RADIUS_FNS.discard(stat)
        fn = getattr(aabpl, f'radius_{stat}')
        return fn(pts=pts, crs='', r=_R, c=c, x='x', y='y',
                  silent=True, _dev=_SEARCH_DEV, **kw)
    return aabpl.radius_search(
        pts=pts, crs='', r=_R, c=c, x='x', y='y',
        stat=stat, silent=True, _dev=_SEARCH_DEV, **kw,
    )


def _detect(r, c='emp', stat='sum', keep_cols=False, **kw):
    """Thin wrapper around detect_cluster_pts with test defaults."""
    import aabpl
    pts = _make_cluster_pts()
    grid = aabpl.detect_cluster_pts(
        pts=pts, crs=_CLUSTER_CRS, r=r, c=c, stat=stat,
        x='x', y='y',
        null_distribution=500,
        k_th_percentile=95.0,
        random_seed=0,
        keep_cols=keep_cols,
        silent=True,
        **kw,
    )
    return grid, pts


# ===========================================================================
# A. radius_search — stat coverage
# ===========================================================================

@pytest.mark.parametrize('stat,suf', [
    ('sum',   f'_sum_{_R}'),
    ('count', f'_cnt_{_R}'),
    ('mean',  f'_avg_{_R}'),
])
def test_rs_basic_stat(stat, suf):
    pts = _make_pts()
    _rs(pts, stat=stat, suffix=suf)
    out = [c for c in pts.columns if c.endswith(suf)]
    assert len(out) == 1, f'stat={stat}: expected 1 col ending {suf!r}, got {list(pts.columns)}'
    assert (pts[out[0]] >= 0).all()


@pytest.mark.parametrize('stat', ['variance', 'std', 'cv', 'skewness', 'kurtosis'])
def test_rs_moment_stat(stat):
    pts = _make_pts()
    _rs(pts, stat=stat)
    out = [c for c in pts.columns if c.startswith('val_')]
    assert len(out) == 1, f'stat={stat}: expected 1 output col, got {out}'


# ===========================================================================
# B. radius_search — multi-stat lists
# ===========================================================================

def test_rs_multi_stat_sum_count_mean():
    pts = _make_pts()
    _rs(pts, stat=['sum', 'count', 'mean'])
    for col in [f'val_sum_{_R}', f'val_cnt_{_R}', f'val_avg_{_R}']:
        assert col in pts.columns, f'missing {col}'
    # mean == sum / count where count > 0
    mask = pts[f'val_cnt_{_R}'] > 0
    diff = (pts.loc[mask, f'val_avg_{_R}']
            - pts.loc[mask, f'val_sum_{_R}'] / pts.loc[mask, f'val_cnt_{_R}']).abs().max()
    assert diff < 1e-9, f'mean != sum/count, diff={diff}'


def test_rs_multi_stat_sum_variance():
    pts = _make_pts()
    _rs(pts, stat=['sum', 'variance'])
    assert f'val_sum_{_R}' in pts.columns
    assert f'val_var_{_R}' in pts.columns
    leaked = [c for c in pts.columns if '__rs_int__' in c]
    assert not leaked, f'internal suffix leaked: {leaked}'


def test_rs_multi_stat_two_cols():
    pts = _make_pts()
    _rs(pts, stat=['sum', 'mean'], c=['val', 'val2'])
    for base in ['val', 'val2']:
        for suf in [f'_sum_{_R}', f'_avg_{_R}']:
            assert base + suf in pts.columns


def test_rs_multi_stat_custom_suffix():
    pts = _make_pts()
    _rs(pts, stat=['sum', 'count'], suffix={'sum': '_s5k', 'count': '_n5k'})
    assert 'val_s5k' in pts.columns
    assert 'val_n5k' in pts.columns


# ===========================================================================
# C. radius_search — nest_depth variants
# ===========================================================================

@pytest.mark.parametrize('nd', [0, 2, 3])
def test_rs_nest_depth(nd):
    import aabpl
    pts = _make_pts()
    aabpl.radius_search(pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
                        stat='sum', silent=True,
                        _dev={'nest_depth': nd, 'spacing_over_radius': 2.0})
    col = [c for c in pts.columns if c.startswith('val_')][0]
    assert pts[col].sum() > 0


def test_rs_nest_depth_consistency():
    import aabpl
    sums = {}
    for nd in [2, 3]:
        pts = _make_pts()
        aabpl.radius_search(pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
                            stat='sum', silent=True,
                            _dev={'nest_depth': nd, 'spacing_over_radius': 2.0})
        col = [c for c in pts.columns if c.startswith('val_')][0]
        sums[nd] = pts[col].sum()
    rel = abs(sums[3] - sums[2]) / max(sums[2], 1)
    assert rel < 0.05, f'nd=2 vs nd=3 diverge {rel:.1%}'


# ===========================================================================
# D. radius_search — pts_target
# ===========================================================================

def test_rs_pts_target():
    import aabpl
    pts_source = _make_pts()
    pts_target = _make_pts(300, seed=42)
    aabpl.radius_search(pts=pts_target, crs='', r=_R, c=['val'], x='x', y='y',
                        pts_target=pts_source, silent=True, _dev=_SEARCH_DEV)
    assert f'val_sum_{_R}' in pts_target.columns
    assert f'val_sum_{_R}' not in pts_source.columns


def test_rs_pts_target_half_overlapping_bbox():
    """pts_source and pts_target bounding boxes overlap over only half their
    extent (source: x in [0,50000], target: x in [25000,75000], both y in
    [0,50000]) -- regression guard for the pts_target clip-to-relevant-window
    optimization in radius_search: a target point sitting just outside the
    source's own bbox (but within r of a source point near the boundary)
    must not be silently dropped."""
    import aabpl
    rng = np.random.default_rng(7)
    n_fill = 100
    # Fill points shape the bounding boxes (half-overlapping in x) but carry
    # val=0 so they can never contaminate the probe's sum, even if some land
    # within r of the probe by chance.
    pts_source = pd.DataFrame({
        'x': rng.uniform(0, 50_000, n_fill),
        'y': rng.uniform(0, 50_000, n_fill),
        'val': 0.0,
    })
    pts_target = pd.DataFrame({
        'x': rng.uniform(25_000, 75_000, n_fill),
        'y': rng.uniform(0, 50_000, n_fill),
        'val': 0.0,
    })

    # Deterministic probe: source point near the overlap boundary; one target
    # point just within r (must be found), one just beyond r (must be excluded).
    probe_src_idx = len(pts_source)
    pts_source = pd.concat([pts_source, pd.DataFrame({'x': [49_000.0], 'y': [0.0], 'val': [1.0]})],
                            ignore_index=True)
    pts_target = pd.concat([pts_target, pd.DataFrame({
        'x': [49_000.0 + _R - 1, 49_000.0 + _R + 1],  # within r, beyond r
        'y': [0.0, 0.0],
        'val': [1.0, 1.0],
    })], ignore_index=True)

    aabpl.radius_search(pts=pts_source, crs='', r=_R, c=['val'], x='x', y='y',
                        stat='sum', exclude_self=False, silent=True,
                        pts_target=pts_target, x_tgt='x', y_tgt='y', _dev=_SEARCH_DEV)

    probe_sum = pts_source.loc[pts_source['x'] == 49_000.0, f'val_sum_{_R}'].iloc[0]
    assert probe_sum == 1.0, (
        f'expected exactly 1 (the within-r target point) at the overlap boundary, got {probe_sum} -- '
        'a target point may have been incorrectly clipped away'
    )


def test_cluster_pts_pts_target_far_via_study_area():
    """detect_cluster_pts delegates its actual aggregation to radius_search()
    (see the pts_target clip-to-relevant-window optimization there) --
    regression guard that a target point relevant only because study_area
    extends far beyond the source points' own bounding box is not silently
    dropped when a null-distribution point lands near it."""
    import aabpl
    from shapely.geometry import Polygon
    r = 1000.0
    pts = pd.DataFrame({'x': [0.0, 10.0], 'y': [0.0, 10.0], 'val': [1.0, 1.0]})
    study_area = Polygon([(-20_000, -20_000), (20_000, -20_000), (20_000, 20_000), (-20_000, 20_000)])
    pts_target = pd.DataFrame({'x': [15_000.0], 'y': [15_000.0], 'val': [1.0]})

    grid = aabpl.detect_cluster_pts(
        pts=pts, crs='', r=r, c=['val'], x='x', y='y', stat='sum',
        study_area=study_area, pts_target=pts_target, x_tgt='x', y_tgt='y',
        null_distribution=5000, random_seed=0, silent=True,
    )
    nd = grid.null_distribution
    dists = ((nd['x'].values - 15_000.0) ** 2 + (nd['y'].values - 15_000.0) ** 2) ** 0.5
    near = nd[dists <= r]
    assert len(near) > 0, 'test setup: expected some null-distribution points near the far target'
    assert (near[f'val_sum_{r:g}'] == 1.0).all(), (
        'far target point (relevant only via study_area extension) was incorrectly clipped away'
    )


def test_cluster_cells_pts_target_far_via_study_area():
    """Same regression guard as test_cluster_pts_pts_target_far_via_study_area,
    exercised through detect_cluster_cells (which delegates to detect_cluster_pts,
    which delegates to radius_search)."""
    import aabpl
    from shapely.geometry import Polygon
    r = 1000.0
    pts = pd.DataFrame({'x': [0.0, 10.0, 20.0], 'y': [0.0, 10.0, 20.0], 'val': [1.0, 1.0, 1.0]})
    study_area = Polygon([(-20_000, -20_000), (20_000, -20_000), (20_000, 20_000), (-20_000, 20_000)])
    pts_target = pd.DataFrame({'x': [15_000.0], 'y': [15_000.0], 'val': [1.0]})

    # Correctness of the delegated search itself is covered by
    # test_cluster_pts_pts_target_far_via_study_area; this just guards that
    # the delegation chain through detect_cluster_cells keeps working.
    grid = aabpl.detect_cluster_cells(
        pts=pts, crs='', r=r, c=['val'], x='x', y='y', stat='sum',
        study_area=study_area, pts_target=pts_target, x_tgt='x', y_tgt='y',
        null_distribution=5000, random_seed=0, silent=True,
    )
    assert grid is not None


# ===========================================================================
# E. radius_search — keep_cols / helper cleanup
# ===========================================================================

def test_rs_keep_cols_false():
    pts = _make_pts()
    before = set(pts.columns)
    _rs(pts, stat='sum')
    added = set(pts.columns) - before
    assert added == {f'val_sum_{_R}'}, f'unexpected cols: {added}'


def test_rs_keep_cols_true_no_internals():
    pts = _make_pts()
    before = set(pts.columns)
    _rs(pts, stat='sum', keep_cols=True)
    added = set(pts.columns) - before
    leaked = [c for c in added if '__rs_int__' in c]
    assert not leaked
    assert f'val_sum_{_R}' in added


def test_rs_variance_no_count_sum_leak():
    import aabpl
    pts = _make_pts()
    before = set(pts.columns)
    aabpl.radius_variance(pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
                          silent=True, _dev=_SEARCH_DEV)
    added = set(pts.columns) - before
    leaked = [c for c in added if ('cnt' in c or 'count' in c or 'sum' in c)
              and c != f'val_var_{_R}']
    assert not leaked, f'count/sum leaked: {leaked}'


def test_rs_multi_variance_count_both_present():
    pts = _make_pts()
    _rs(pts, stat=['variance', 'count'])
    assert any('var' in c for c in pts.columns)
    assert any('cnt' in c or 'count' in c for c in pts.columns)


# ===========================================================================
# F. radius_search — area_weight and config.VALIDATE
# ===========================================================================

@pytest.mark.parametrize('area_weight', ['exact', 'logit', 'flat', 'binary'])
def test_rs_area_weight_modes(area_weight):
    import aabpl
    # small dataset -- 'exact' mode is the slowest path (per-point Shapely
    # geometry work), keep this test cheap.
    pts = _make_pts(200, seed=3)
    poly_study_area = None
    from shapely.geometry import Polygon
    poly_study_area = Polygon([(0, 0), (50_000, 0), (50_000, 50_000), (0, 50_000)])
    aabpl.radius_search(pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
                        stat='sum', area_weight=area_weight, study_area=poly_study_area,
                        silent=True, _dev=_SEARCH_DEV)
    assert f'val_sum_{_R}' in pts.columns
    assert (pts[f'val_sum_{_R}'] >= 0).all()


def test_rs_area_weight_none_is_unweighted():
    # area_weight=None (the default) should still work -- no valid_area_share
    # column, no weighting applied.
    pts = _make_pts()
    _rs(pts, stat='sum', area_weight=None)
    assert f'val_sum_{_R}' in pts.columns
    assert not any('valid_area_share' in c for c in pts.columns)


@pytest.mark.parametrize('area_weight,max_diff_tol', [
    ('exact',  0.02),   # exact mode computes true polygon intersection -- should be near-perfect
    ('logit',  0.2),
    ('flat',   0.3),
    ('binary', 0.4),    # coarsest approximation (binary in/out per cell) -- loosest tolerance
])
def test_rs_validate_area(area_weight, max_diff_tol):
    """cfg.VALIDATE_AREA: compares the library's computed valid_area_share
    against an exact Shapely circle-intersect-study_area fraction, and
    attaches results to grid.area_weight_validation (mad/max_diff per
    area_weight mode) instead of only printing them. Covers all 5 modes,
    not just one -- each has a different intended precision/speed tradeoff,
    so each gets its own tolerance rather than sharing a single bound."""
    import aabpl
    import aabpl.config as config
    from shapely.geometry import Polygon
    config.VALIDATE_AREA = True
    try:
        pts = _make_pts(300, seed=5)
        poly = Polygon([(0, 0), (50_000, 0), (50_000, 50_000), (0, 50_000)])
        grid = aabpl.radius_search(pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
                                   stat='sum', area_weight=area_weight, study_area=poly,
                                   silent=True, _dev=_SEARCH_DEV)
        assert hasattr(grid, 'area_weight_validation'), \
            'VALIDATE_AREA=True should attach grid.area_weight_validation'
        assert area_weight in grid.area_weight_validation
        result = grid.area_weight_validation[area_weight]
        assert 'mad' in result and 'max_diff' in result
        assert result['max_diff'] < max_diff_tol, \
            f'{area_weight}: area-weight validation error too large: {result}'
    finally:
        config.VALIDATE_AREA = False


def test_rs_config_validate():
    """cfg.VALIDATE: brute-force-checks computed sums against a subset of
    points, attaches results to grid.sum_validation (n_tested/n_errors/
    max_diff) instead of only printing them."""
    import aabpl.config as config
    config.VALIDATE = True
    try:
        pts = _make_pts()
        grid = _rs(pts, stat='sum')
        assert f'val_sum_{_R}' in pts.columns
        assert hasattr(grid, 'sum_validation'), \
            'cfg.VALIDATE=True should attach grid.sum_validation'
        result = next(iter(grid.sum_validation.values()))
        assert result['n_tested'] > 0
        assert result['n_errors'] == 0, f'brute-force validation found errors: {result}'
    finally:
        config.VALIDATE = False



# ===========================================================================
# H. detect_cluster_pts — r spec types
# ===========================================================================

def test_cluster_r_single():
    # Single-r naming: {c}_cluster_{stat}_{r}
    grid, pts = _detect(r=_CR, keep_cols=True)
    assert f'emp_sum_{_CR}' in pts.columns
    assert f'emp_cluster_sum_{_CR}' in pts.columns
    assert pts[f'emp_cluster_sum_{_CR}'].dtype == bool


def test_cluster_r_list():
    grid, pts = _detect(r=[300, _CR], keep_cols=True)
    assert f'emp_sum_300' in pts.columns
    assert f'emp_sum_{_CR}' in pts.columns
    assert f'emp_sum_300_cluster' in pts.columns
    assert f'emp_sum_{_CR}_cluster' in pts.columns


def test_cluster_r_list_cluster_cols_no_keep():
    grid, pts = _detect(r=[300, _CR])
    assert 'emp_sum_300_cluster' in pts.columns
    assert f'emp_sum_{_CR}_cluster' in pts.columns
    assert pts['emp_sum_300_cluster'].dtype == bool


def test_cluster_r_bands():
    grid, pts = _detect(r=[(0, 300), (300, _CR)])
    assert 'emp_sum_0_300_cluster' in pts.columns
    assert f'emp_sum_300_{_CR}_cluster' in pts.columns
    assert pts['emp_sum_0_300_cluster'].dtype == bool
    assert pts[f'emp_sum_300_{_CR}_cluster'].dtype == bool


def test_cluster_r_bands_keep_cols():
    grid, pts = _detect(r=[(0, 300), (300, _CR)], keep_cols=True)
    assert 'emp_sum_0_300' in pts.columns
    assert f'emp_sum_300_{_CR}' in pts.columns


def test_cluster_r_wbands():
    grid, pts = _detect(r=[(0, 300, 1.0), (300, _CR, 0.5)])
    assert 'emp_sum_wgt_cluster' in pts.columns
    assert pts['emp_sum_wgt_cluster'].dtype == bool


def test_cluster_r_wbands_keep_cols():
    grid, pts = _detect(r=[(0, 300, 1.0), (300, _CR, 0.5)], keep_cols=True)
    assert 'emp_sum_wgt' in pts.columns


# ===========================================================================
# I. detect_cluster_pts — multi-c
# ===========================================================================

def test_cluster_multi_c_single_r():
    grid, pts = _detect(r=_CR, c=['emp', 'pop'], keep_cols=True)
    assert f'emp_sum_{_CR}' in pts.columns
    assert f'pop_sum_{_CR}' in pts.columns
    assert f'emp_cluster_sum_{_CR}' in pts.columns
    assert f'pop_cluster_sum_{_CR}' in pts.columns


def test_cluster_multi_c_multi_r():
    grid, pts = _detect(r=[300, _CR], c=['emp', 'pop'], keep_cols=True)
    assert 'emp_sum_300' in pts.columns
    assert f'pop_sum_{_CR}' in pts.columns
    assert 'emp_sum_300_cluster' in pts.columns
    assert f'pop_sum_{_CR}_cluster' in pts.columns


# ===========================================================================
# I2. detect_cluster_pts / detect_cluster_cells — pts_target != pts
# Regression coverage for the combined-search refactor (originally surfaced
# as `KeyError: 'id_x'` when pts_target was a separate object from pts).
# ===========================================================================

def test_cluster_pts_with_pts_target():
    import aabpl
    pts = _make_cluster_pts()
    pts_target = pts.iloc[:10].copy().reset_index(drop=True)
    aabpl.detect_cluster_pts(
        pts=pts, pts_target=pts_target, crs=_CLUSTER_CRS, r=_CR, c='emp', stat='sum',
        x='x', y='y', null_distribution=500, k_th_percentile=95.0, random_seed=0,
        keep_cols=True, silent=True,
    )
    assert f'emp_sum_{_CR}' in pts.columns
    assert f'emp_cluster_sum_{_CR}' in pts.columns
    assert pts[f'emp_cluster_sum_{_CR}'].dtype == bool
    assert f'emp_sum_{_CR}' not in pts_target.columns


def test_cluster_cells_with_pts_target():
    import aabpl
    pts = _make_cluster_pts()
    pts_target = pts.iloc[:10].copy().reset_index(drop=True)
    grid = aabpl.detect_cluster_cells(
        pts=pts, pts_target=pts_target, crs=_CLUSTER_CRS, r=_CR, c=['emp'], stat='sum',
        x='x', y='y', null_distribution=500, random_seed=0,
        keep_cols=True, silent=True,
    )
    assert f'emp_sum_{_CR}' in pts.columns
    assert f'emp_cluster_sum_{_CR}' in pts.columns
    assert f'emp_sum_{_CR}' not in pts_target.columns
    assert grid.cell_row_name in pts.columns


# ===========================================================================
# J. detect_cluster_pts — stat variants
# ===========================================================================

@pytest.mark.parametrize('stat,abbr', [
    ('sum',   'sum'),
    ('count', 'cnt'),
    ('mean',  'avg'),
])
def test_cluster_stat_single_r(stat, abbr):
    grid, pts = _detect(r=_CR, c='emp', stat=stat, keep_cols=True)
    assert f'emp_{abbr}_{_CR}' in pts.columns, \
        f'Expected emp_{abbr}_{_CR}, got: {list(pts.columns)}'


def test_cluster_stat_sum_multi_r():
    grid, pts = _detect(r=[300, _CR], c='emp', stat='sum', keep_cols=True)
    assert 'emp_sum_300' in pts.columns
    assert 'emp_sum_300_cluster' in pts.columns


def test_cluster_stat_mean_multi_r():
    grid, pts = _detect(r=[300, _CR], c='emp', stat='mean', keep_cols=True)
    assert 'emp_avg_300' in pts.columns


def test_cluster_stat_count_multi_r():
    grid, pts = _detect(r=[300, _CR], c='emp', stat='count', keep_cols=True)
    assert 'emp_cnt_300' in pts.columns


# ===========================================================================
# K. detect_cluster_pts — regression: band boolean not overwritten
# ===========================================================================

def test_rand_dist_shows_all_bands():
    """k_th_percentiles must have one entry per aggregate col, not one per value col.
    Previously the zip in create_distribution_plot stopped at len(value_cols), silently
    dropping any extra band panels."""
    grid, _ = _detect(r=[(0, 200), (200, 300), (300, _CR)])
    result = grid._cluster_result
    n_agg = len(result['aggregate_cols'])
    n_k   = len(result['k_th_percentiles'])
    assert n_agg == 3, f'expected 3 aggregate cols for 3 bands, got {result["aggregate_cols"]}'
    assert n_k == n_agg, \
        f'k_th_percentiles has {n_k} entries but aggregate_cols has {n_agg} — ' \
        f'rand_dist would silently drop the last {n_agg - n_k} band panel(s)'


def test_band_cluster_bool_not_overwritten():
    grid, pts = _detect(r=[(0, 300), (300, _CR)])
    for col in ('emp_sum_0_300_cluster', f'emp_sum_300_{_CR}_cluster'):
        assert col in pts.columns
        assert pts[col].dtype == bool, f'{col} dtype={pts[col].dtype}, expected bool'


# ===========================================================================
# L. detect_cluster_pts — custom sample area (Polygon / MultiPolygon)
# ===========================================================================

def test_custom_study_area_polygon():
    import aabpl
    from shapely.geometry import Polygon, Point
    pts = _make_pts(2000, seed=7)
    poly = Polygon([(5000, 5000), (30000, 5000), (30000, 30000), (5000, 30000)])
    grid = aabpl.detect_cluster_pts(
        pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
        study_area=poly, null_distribution=1000, random_seed=0, silent=True,
        _dev=_SEARCH_DEV,
    )
    nd = grid.null_distribution
    outside = sum(not poly.covers(Point(x, y))
                  for x, y in zip(nd['x'].values, nd['y'].values))
    assert outside == 0, f'{outside} random points outside polygon'


def test_custom_study_area_smaller_than_point_cloud():
    """study_area that is SMALLER than the point cloud's extent -- some input
    points fall entirely outside it, unlike the other two study_area tests
    (both larger than / covering the whole point cloud). Confirms the
    library runs without error and that random null-distribution points are
    still confined to the (clipping) study area, not the full point extent."""
    import aabpl
    from shapely.geometry import Polygon, Point
    pts = _make_pts(2000, seed=11)  # spans [0,50_000] x [0,50_000]
    small_poly = Polygon([(20_000, 20_000), (25_000, 20_000),
                          (25_000, 25_000), (20_000, 25_000)])  # 5km x 5km corner
    n_outside_input = sum(not small_poly.covers(Point(px, py))
                          for px, py in zip(pts['x'].values, pts['y'].values))
    assert n_outside_input > 0, 'test setup: expected some input points outside the small study area'
    grid = aabpl.detect_cluster_pts(
        pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
        study_area=small_poly, null_distribution=500, random_seed=0, silent=True,
        _dev=_SEARCH_DEV,
    )
    nd = grid.null_distribution
    outside = sum(not small_poly.covers(Point(x, y))
                  for x, y in zip(nd['x'].values, nd['y'].values))
    assert outside == 0, f'{outside} random points outside the clipping study area'


def test_custom_study_area_multipolygon():
    import aabpl
    from shapely.geometry import Polygon, MultiPolygon, Point
    pts = _make_pts(2000, seed=7)
    poly_a = Polygon([(5000, 5000), (30000, 5000), (30000, 30000), (5000, 30000)])
    poly_b = Polygon([(32000, 32000), (48000, 32000), (48000, 48000), (32000, 48000)])
    multi = MultiPolygon([poly_a, poly_b])
    grid = aabpl.detect_cluster_pts(
        pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
        study_area=multi, null_distribution=1000, random_seed=0, silent=True,
        _dev=_SEARCH_DEV,
    )
    nd = grid.null_distribution
    outside = sum(not multi.covers(Point(x, y))
                  for x, y in zip(nd['x'].values, nd['y'].values))
    assert outside == 0, f'{outside} random points outside multipolygon'


# ===========================================================================
# M. guard: rand_dist / cluster_pts raise clear error on radius_search grid
# ===========================================================================

def test_rand_dist_guard_on_radius_search_grid():
    import aabpl
    pts = _make_cluster_pts()
    grid = aabpl.radius_search(pts=pts, crs=_CLUSTER_CRS, r=_CR,
                               c='emp', x='x', y='y', silent=True)
    with pytest.raises(RuntimeError, match='detect_cluster_pts.*detect_cluster_cells'):
        grid.plot.rand_dist()


def test_cluster_pts_guard_on_radius_search_grid():
    import aabpl
    pts = _make_cluster_pts()
    grid = aabpl.radius_search(pts=pts, crs=_CLUSTER_CRS, r=_CR,
                               c='emp', x='x', y='y', silent=True)
    with pytest.raises(RuntimeError, match='detect_cluster_pts.*detect_cluster_cells'):
        grid.plot.cluster_pts()


# ===========================================================================
# N. plot smoke tests (Agg backend, no file I/O)
# ===========================================================================

def test_plot_smoke():
    import aabpl
    import matplotlib.pyplot as plt
    pts = _make_pts()
    grid = aabpl.detect_cluster_cells(
        pts=pts, crs='', r=_R, c=['val'], x='x', y='y',
        stat='sum', null_distribution=500, random_seed=0,
        keep_cols=False, silent=True, _dev=_SEARCH_DEV,
    )
    for method in ('vars', 'clusters', 'cluster_pts', 'rand_dist'):
        try:
            getattr(grid.plot, method)(filename=None)
            plt.close('all')
        except Exception as e:
            raise AssertionError(f'grid.plot.{method}() raised: {e}')


# ===========================================================================
# O. save-method smoke tests (tiniest dataset that exercises detect_cluster_cells)
# ===========================================================================

def test_save_methods_smoke(tmp_path):
    import aabpl
    pts = _make_cluster_pts()
    grid = aabpl.detect_cluster_cells(
        pts=pts, crs=_CLUSTER_CRS, r=_CR, c=['emp'], x='x', y='y',
        stat='sum', null_distribution=500, random_seed=0,
        keep_cols=True, silent=True,
    )
    cluster_column = next(iter(grid.clustering.by_column))

    full_grid_path = tmp_path / 'full_grid'
    sparse_grid_path = tmp_path / 'sparse_grid'
    cluster_col_path = tmp_path / 'grid_clusters_col'
    all_clusters_path = tmp_path / 'grid_clusters'

    df_full = grid.save_full_grid(filename=str(full_grid_path), file_format='csv')
    assert (tmp_path / 'full_grid.csv').exists()
    assert len(df_full) > 0

    df_sparse = grid.save_sparse_grid(filename=str(sparse_grid_path), file_format='csv')
    assert (tmp_path / 'sparse_grid.csv').exists()
    assert len(df_sparse) > 0

    # NB: the 30-pt dataset may or may not produce any cell-level clusters
    # above the default percentile threshold -- that's a legitimate outcome,
    # not a bug, so these only assert the save call succeeds and writes a
    # well-formed (possibly empty) file.
    df_col = grid.save_cell_clusters_for_column(
        cluster_column=cluster_column, filename=str(cluster_col_path), file_format='csv',
    )
    assert (tmp_path / 'grid_clusters_col.csv').exists()
    assert list(df_col.columns) != []

    dfs = grid.save_cell_clusters(filename=str(all_clusters_path), file_format='csv')
    assert (tmp_path / 'grid_clusters.csv').exists()
    assert cluster_column in dfs.keys()


# ===========================================================================
# P. build_study_area — standalone study-area resolution (tiniest dataset)
# ===========================================================================

@pytest.mark.parametrize('spec', [
    'cells,min_pts=1',
    'cells,min_pts=1,buffer=100',
    'concave,concavity=0.5',
    'convex',
    'bounding_box',
    'grid',
    False,
])
def test_build_study_area_specs(spec):
    """build_study_area resolves every spec type to a valid polygon without
    requiring the caller to build a grid first."""
    import aabpl
    from shapely.geometry import Polygon, MultiPolygon
    pts = _make_cluster_pts()
    poly = aabpl.build_study_area(pts, r=_CR, crs=_CLUSTER_CRS, study_area=spec,
                                  x='x', y='y', silent=True)
    assert isinstance(poly, (Polygon, MultiPolygon))
    assert poly.area > 0


def test_build_study_area_full_pipeline():
    """Full pipeline: resolve a study area standalone, edit it (cut out a
    sub-region), then feed the edited polygon back into detect_cluster_cells
    via its own study_area= argument -- the intended manual-editing workflow."""
    import aabpl
    from shapely.geometry import Point
    pts = _make_cluster_pts()
    poly = aabpl.build_study_area(pts, r=_CR, crs=_CLUSTER_CRS,
                                  study_area='cells,min_pts=1', x='x', y='y', silent=True)
    assert poly.area > 0

    # Cut out a corner of the resolved polygon before reuse.
    cut_area = Point(poly.centroid).buffer(poly.area ** 0.5 * 0.1)
    edited_poly = poly.difference(cut_area)
    assert edited_poly.area < poly.area

    grid = aabpl.detect_cluster_cells(
        pts=pts, crs=_CLUSTER_CRS, r=_CR, c=['emp'], x='x', y='y',
        stat='sum', study_area=edited_poly,
        null_distribution=500, random_seed=0, silent=True,
    )
    nd = grid.null_distribution
    outside = sum(not edited_poly.covers(Point(px, py))
                  for px, py in zip(nd['x'].values, nd['y'].values))
    assert outside == 0, f'{outside} random points outside the edited study area'


def test_build_study_area_no_grid_argument_needed():
    """Regression guard: build_study_area must not require a grid= kwarg."""
    import aabpl
    import inspect
    assert 'grid' not in inspect.signature(aabpl.build_study_area).parameters
