"""fuzz_maps_procgen - Wave 2 target W7 (addresses #283).

HeightMap and DiscreteMap are the standardized abstract data containers that
every procgen system converts to/from. Fuzzing HeightMap/DiscreteMap methods
plus the one-directional conversions from NoiseSource/BSP into HeightMap
covers the entire procgen surface without having to fuzz each individual
procgen system.

No specific known-bug issues drive this target - it is new territory.

Contract: define fuzz_one_input(data: bytes) -> None. The C++ harness
(tests/fuzz/fuzz_common.cpp) calls this for every libFuzzer iteration.
Use ByteStream to consume bytes. Wrap work in try/except EXPECTED_EXCEPTIONS.
"""

import mcrfpy

from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS

# Keep dimensions small: procgen ops are expensive and libFuzzer needs
# high iter/sec to find bugs. Bigger maps also blow up memory on every
# iteration for no coverage benefit.
MIN_DIM = 2
MAX_DIM = 32

ALGOS = ("simplex", "perlin", "wavelet")
NOISE_MODES = ("flat", "fbm", "turbulence")
BSP_SELECTS = ("leaves", "all", "internal")

MAX_HEIGHTMAPS = 3
MAX_DISCRETEMAPS = 3
MAX_NOISES = 2
MAX_BSPS = 2
MAX_OPS = 30


def _pick_size(stream):
    return (
        stream.int_in_range(MIN_DIM, MAX_DIM),
        stream.int_in_range(MIN_DIM, MAX_DIM),
    )


def _pick_small_float(stream, lo=-5.0, hi=5.0):
    return stream.float_in_range(lo, hi)


def _pick_u8(stream):
    return stream.u8()


def _pick_coord(stream, bound=MAX_DIM * 2):
    # Allow occasional OOB to exercise bounds checking.
    return stream.int_in_range(-2, bound)


def _pick_hm(stream, hms):
    if not hms:
        return None
    return hms[stream.int_in_range(0, len(hms) - 1)]


def _pick_dm(stream, dms):
    if not dms:
        return None
    return dms[stream.int_in_range(0, len(dms) - 1)]


def _pick_noise(stream, nss):
    if not nss:
        return None
    return nss[stream.int_in_range(0, len(nss) - 1)]


def _pick_bsp(stream, bsps):
    if not bsps:
        return None
    return bsps[stream.int_in_range(0, len(bsps) - 1)]


def _make_heightmap(stream, hms):
    if len(hms) >= MAX_HEIGHTMAPS:
        hms.pop(0)
    size = _pick_size(stream)
    fill = _pick_small_float(stream, -2.0, 2.0)
    hms.append(mcrfpy.HeightMap(size=size, fill=fill))


def _make_discretemap(stream, dms):
    if len(dms) >= MAX_DISCRETEMAPS:
        dms.pop(0)
    size = _pick_size(stream)
    fill = _pick_u8(stream)
    dms.append(mcrfpy.DiscreteMap(size=size, fill=fill))


def _make_noise(stream, nss):
    if len(nss) >= MAX_NOISES:
        nss.pop(0)
    algo = ALGOS[stream.int_in_range(0, len(ALGOS) - 1)]
    # sample() requires dimensions==2. Use 2D most of the time, sometimes
    # try 1/3/4 to exercise get()/fbm() dimension validation paths.
    dims_roll = stream.u8() & 0x07
    if dims_roll < 6:
        dims = 2
    else:
        dims = stream.int_in_range(1, 4)
    hurst = stream.float_in_range(0.0, 1.0)
    lacunarity = stream.float_in_range(1.0, 4.0)
    seed = stream.u32()
    nss.append(mcrfpy.NoiseSource(
        dimensions=dims,
        algorithm=algo,
        hurst=hurst,
        lacunarity=lacunarity,
        seed=seed,
    ))


def _make_bsp(stream, bsps):
    if len(bsps) >= MAX_BSPS:
        bsps.pop(0)
    # BSP with (0,0) or (1,1) size may degenerate - guard via MIN_DIM.
    w = stream.int_in_range(MIN_DIM, MAX_DIM)
    h = stream.int_in_range(MIN_DIM, MAX_DIM)
    bsps.append(mcrfpy.BSP(pos=(0, 0), size=(w, h)))


# =============================================================================
# Dispatch ops
# =============================================================================

def _op_hm_scalar(stream, hms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    which = stream.u8() % 7
    if which == 0:
        hm.fill(_pick_small_float(stream))
    elif which == 1:
        hm.clear()
    elif which == 2:
        hm.add_constant(_pick_small_float(stream))
    elif which == 3:
        hm.scale(_pick_small_float(stream))
    elif which == 4:
        lo = _pick_small_float(stream)
        hi = _pick_small_float(stream)
        if lo > hi:
            lo, hi = hi, lo
        hm.clamp(lo, hi)
    elif which == 5:
        lo = _pick_small_float(stream)
        hi = _pick_small_float(stream)
        if lo > hi:
            lo, hi = hi, lo
        hm.normalize(lo, hi)
    elif which == 6:
        hm.inverse()


def _op_hm_terrain(stream, hms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    which = stream.u8() % 4
    if which == 0:
        cx = stream.int_in_range(-2, MAX_DIM + 2)
        cy = stream.int_in_range(-2, MAX_DIM + 2)
        radius = stream.float_in_range(0.1, 10.0)
        height = _pick_small_float(stream)
        hm.add_hill((cx, cy), radius, height)
    elif which == 1:
        cx = stream.int_in_range(-2, MAX_DIM + 2)
        cy = stream.int_in_range(-2, MAX_DIM + 2)
        radius = stream.float_in_range(0.1, 10.0)
        target = _pick_small_float(stream)
        hm.dig_hill((cx, cy), radius, target)
    elif which == 2:
        n_points = stream.int_in_range(1, 16)
        seed = stream.u32()
        hm.add_voronoi(n_points, seed=seed)
    elif which == 3:
        roughness = stream.float_in_range(0.0, 1.0)
        seed = stream.u32()
        hm.mid_point_displacement(roughness=roughness, seed=seed)


def _op_hm_smooth_erosion(stream, hms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    if stream.bool():
        iterations = stream.int_in_range(1, 4)
        hm.smooth(iterations=iterations)
    else:
        # Keep drop count small - rain_erosion is O(drops * steps).
        drops = stream.int_in_range(1, 32)
        erosion = stream.float_in_range(0.0, 0.5)
        sedim = stream.float_in_range(0.0, 0.5)
        seed = stream.u32()
        hm.rain_erosion(drops, erosion=erosion, sedimentation=sedim, seed=seed)


def _op_hm_binary(stream, hms):
    # Intentionally allow mismatched sizes. These must raise, not crash.
    if len(hms) < 2:
        return
    a = hms[stream.int_in_range(0, len(hms) - 1)]
    b = hms[stream.int_in_range(0, len(hms) - 1)]
    which = stream.u8() % 7
    if which == 0:
        a.add(b)
    elif which == 1:
        a.subtract(b)
    elif which == 2:
        a.multiply(b)
    elif which == 3:
        t = stream.float_in_range(0.0, 1.0)
        a.lerp(b, t)
    elif which == 4:
        a.copy_from(b)
    elif which == 5:
        a.max(b)
    elif which == 6:
        a.min(b)


def _op_hm_subscript(stream, hms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    x = _pick_coord(stream)
    y = _pick_coord(stream)
    if stream.bool():
        # Read
        _ = hm[x, y]
    else:
        # Write
        hm[x, y] = _pick_small_float(stream)


def _op_hm_query(stream, hms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    which = stream.u8() % 5
    if which == 0:
        hm.min_max()
    elif which == 1:
        lo = _pick_small_float(stream)
        hi = _pick_small_float(stream)
        if lo > hi:
            lo, hi = hi, lo
        hm.count_in_range((lo, hi))
    elif which == 2:
        x = _pick_coord(stream)
        y = _pick_coord(stream)
        hm.get(x, y)
    elif which == 3:
        x = stream.float_in_range(-2.0, MAX_DIM + 2.0)
        y = stream.float_in_range(-2.0, MAX_DIM + 2.0)
        hm.get_interpolated(x, y)
    elif which == 4:
        lo = _pick_small_float(stream)
        hi = _pick_small_float(stream)
        if lo > hi:
            lo, hi = hi, lo
        hm.threshold((lo, hi))


def _op_dm_scalar(stream, dms):
    dm = _pick_dm(stream, dms)
    if dm is None:
        return
    which = stream.u8() % 3
    if which == 0:
        dm.fill(_pick_u8(stream))
    elif which == 1:
        dm.clear()
    elif which == 2:
        dm.invert()


def _op_dm_get_set(stream, dms):
    dm = _pick_dm(stream, dms)
    if dm is None:
        return
    x = _pick_coord(stream)
    y = _pick_coord(stream)
    if stream.bool():
        _ = dm[x, y]
    else:
        dm[x, y] = _pick_u8(stream)


def _op_dm_query(stream, dms):
    dm = _pick_dm(stream, dms)
    if dm is None:
        return
    which = stream.u8() % 4
    if which == 0:
        dm.count(_pick_u8(stream))
    elif which == 1:
        lo = _pick_u8(stream)
        hi = _pick_u8(stream)
        if lo > hi:
            lo, hi = hi, lo
        dm.count_range(lo, hi)
    elif which == 2:
        dm.min_max()
    elif which == 3:
        dm.histogram()


def _op_dm_bitwise(stream, dms):
    if len(dms) < 2:
        return
    a = dms[stream.int_in_range(0, len(dms) - 1)]
    b = dms[stream.int_in_range(0, len(dms) - 1)]
    which = stream.u8() % 3
    if which == 0:
        a.bitwise_and(b)
    elif which == 1:
        a.bitwise_or(b)
    elif which == 2:
        a.bitwise_xor(b)


def _op_dm_to_bool(stream, dms):
    dm = _pick_dm(stream, dms)
    if dm is None:
        return
    which = stream.u8() % 3
    if which == 0:
        dm.bool(_pick_u8(stream))
    elif which == 1:
        # Random small set of ints
        n = stream.int_in_range(1, 4)
        values = set()
        for _ in range(n):
            values.add(_pick_u8(stream))
        dm.bool(values)
    elif which == 2:
        threshold = _pick_u8(stream)
        dm.bool(lambda v, t=threshold: v > t)


def _op_dm_copy_from(stream, dms):
    # Intentionally allow mismatched sizes - must raise not crash.
    if len(dms) < 2:
        return
    a = dms[stream.int_in_range(0, len(dms) - 1)]
    b = dms[stream.int_in_range(0, len(dms) - 1)]
    which = stream.u8() % 3
    if which == 0:
        a.copy_from(b)
    elif which == 1:
        a.max(b)
    elif which == 2:
        a.min(b)


def _op_hm_add_noise(stream, hms, nss):
    hm = _pick_hm(stream, hms)
    ns = _pick_noise(stream, nss)
    if hm is None or ns is None:
        return
    mode = NOISE_MODES[stream.int_in_range(0, len(NOISE_MODES) - 1)]
    octaves = stream.int_in_range(1, 6)
    scale = stream.float_in_range(-2.0, 2.0)
    if stream.bool():
        hm.add_noise(ns, mode=mode, octaves=octaves, scale=scale)
    else:
        hm.multiply_noise(ns, mode=mode, octaves=octaves, scale=scale)


def _op_hm_add_bsp(stream, hms, bsps):
    hm = _pick_hm(stream, hms)
    bsp = _pick_bsp(stream, bsps)
    if hm is None or bsp is None:
        return
    select = BSP_SELECTS[stream.int_in_range(0, len(BSP_SELECTS) - 1)]
    shrink = stream.int_in_range(0, 3)
    value = _pick_small_float(stream)
    if stream.bool():
        hm.add_bsp(bsp, select=select, shrink=shrink, value=value)
    else:
        hm.multiply_bsp(bsp, select=select, shrink=shrink, value=value)


def _op_noise_sample(stream, nss, hms):
    ns = _pick_noise(stream, nss)
    if ns is None:
        return
    size = _pick_size(stream)
    mode = NOISE_MODES[stream.int_in_range(0, len(NOISE_MODES) - 1)]
    octaves = stream.int_in_range(1, 6)
    # sample() requires dimensions==2; other dims will raise ValueError.
    sampled = ns.sample(size=size, mode=mode, octaves=octaves)
    if len(hms) >= MAX_HEIGHTMAPS:
        hms.pop(0)
    hms.append(sampled)


def _op_bsp_to_heightmap(stream, bsps, hms):
    bsp = _pick_bsp(stream, bsps)
    if bsp is None:
        return
    select = BSP_SELECTS[stream.int_in_range(0, len(BSP_SELECTS) - 1)]
    shrink = stream.int_in_range(0, 3)
    value = _pick_small_float(stream)
    hm = bsp.to_heightmap(select=select, shrink=shrink, value=value)
    if len(hms) >= MAX_HEIGHTMAPS:
        hms.pop(0)
    hms.append(hm)


def _op_dm_from_heightmap(stream, hms, dms):
    hm = _pick_hm(stream, hms)
    if hm is None:
        return
    # Build a random mapping. Occasionally produce malformed mappings to
    # exercise error paths.
    malformed = (stream.u8() & 0x0f) == 0
    if malformed:
        mapping = [("not", "a", "tuple"), 42]
    else:
        n_bands = stream.int_in_range(1, 5)
        mapping = []
        for _ in range(n_bands):
            lo = _pick_small_float(stream)
            hi = _pick_small_float(stream)
            if lo > hi:
                lo, hi = hi, lo
            val = _pick_u8(stream)
            mapping.append(((lo, hi), val))
    dm = mcrfpy.DiscreteMap.from_heightmap(hm, mapping)
    if len(dms) >= MAX_DISCRETEMAPS:
        dms.pop(0)
    dms.append(dm)


def _op_dm_to_heightmap(stream, dms, hms):
    dm = _pick_dm(stream, dms)
    if dm is None:
        return
    if stream.bool():
        # No mapping: direct uint8->float cast.
        hm = dm.to_heightmap()
    else:
        n = stream.int_in_range(1, 6)
        mapping = {}
        for _ in range(n):
            key = _pick_u8(stream)
            mapping[key] = _pick_small_float(stream)
        hm = dm.to_heightmap(mapping)
    if len(hms) >= MAX_HEIGHTMAPS:
        hms.pop(0)
    hms.append(hm)


def _op_bsp_split(stream, bsps):
    bsp = _pick_bsp(stream, bsps)
    if bsp is None:
        return
    which = stream.u8() % 3
    if which == 0:
        depth = stream.int_in_range(1, 6)
        min_w = stream.int_in_range(1, 8)
        min_h = stream.int_in_range(1, 8)
        max_ratio = stream.float_in_range(1.0, 4.0)
        seed = stream.u32()
        bsp.split_recursive(
            depth=depth,
            min_size=(min_w, min_h),
            max_ratio=max_ratio,
            seed=seed,
        )
    elif which == 1:
        horizontal = stream.bool()
        position = stream.int_in_range(0, MAX_DIM)
        bsp.split_once(horizontal=horizontal, position=position)
    elif which == 2:
        bsp.clear()


def _op_bsp_walk(stream, bsps):
    bsp = _pick_bsp(stream, bsps)
    if bsp is None:
        return
    # Shallow access on leaves - exercises BSPNode property getters and
    # the center() method (targets 1044-1051 in PyBSP.cpp).
    count = 0
    max_walk = stream.int_in_range(1, 8)
    try:
        for leaf in bsp.leaves():
            _ = leaf.pos
            _ = leaf.size
            _ = leaf.bounds
            _ = leaf.is_leaf
            _ = leaf.level
            leaf.center()
            count += 1
            if count >= max_walk:
                break
    except EXPECTED_EXCEPTIONS:
        pass


def _ordered_range(stream, lo=-3.0, hi=3.0):
    a = stream.float_in_range(lo, hi)
    b = stream.float_in_range(lo, hi)
    return (a, b) if a <= b else (b, a)


def _color_tuple(stream):
    return (stream.int_in_range(-20, 300), stream.int_in_range(-20, 300),
            stream.int_in_range(-20, 300), stream.int_in_range(-20, 300))


def _op_layer_apply(stream, hms):
    """Tier C (#312): ColorLayer/TileLayer terrain application from a HeightMap
    source -- apply_threshold / apply_gradient / apply_ranges. Half the time the
    source heightmap matches the layer dims (reaches the real mapping logic),
    otherwise a pooled, possibly mismatched map is used (must raise, not crash).
    """
    w = stream.int_in_range(MIN_DIM, MAX_DIM)
    h = stream.int_in_range(MIN_DIM, MAX_DIM)
    try:
        grid = mcrfpy.Grid(grid_size=(w, h))
        clayer = mcrfpy.ColorLayer(name="terrain", z_index=0)
        tlayer = mcrfpy.TileLayer(name="tiles", z_index=-1)
        grid.add_layer(clayer)
        grid.add_layer(tlayer)
    except EXPECTED_EXCEPTIONS:
        return

    if stream.bool() or not hms:
        src = mcrfpy.HeightMap(size=(w, h), fill=stream.float_in_range(-2.0, 2.0))
        try:
            src.add_voronoi(stream.int_in_range(1, 8), seed=stream.u32())
        except EXPECTED_EXCEPTIONS:
            pass
    else:
        src = hms[stream.int_in_range(0, len(hms) - 1)]

    which = stream.u8() % 5
    if which == 0:
        clayer.apply_threshold(src, _ordered_range(stream), _color_tuple(stream))
    elif which == 1:
        clayer.apply_gradient(src, _ordered_range(stream),
                              _color_tuple(stream), _color_tuple(stream))
    elif which == 2:
        n = stream.int_in_range(1, 4)
        ranges = [(_ordered_range(stream), _color_tuple(stream)) for _ in range(n)]
        clayer.apply_ranges(src, ranges)
    elif which == 3:
        tlayer.apply_threshold(src, _ordered_range(stream), stream.int_in_range(-5, 4096))
    else:
        n = stream.int_in_range(1, 4)
        ranges = [(_ordered_range(stream), stream.int_in_range(-5, 4096)) for _ in range(n)]
        tlayer.apply_ranges(src, ranges)


def _dispatch(op, stream, hms, dms, nss, bsps):
    # 24 distinct operations covering all four surfaces plus conversions, plus
    # one Tier C (#312) layer-application op.
    if op == 0:
        _make_heightmap(stream, hms)
    elif op == 1:
        _make_discretemap(stream, dms)
    elif op == 2:
        _make_noise(stream, nss)
    elif op == 3:
        _make_bsp(stream, bsps)
    elif op == 4:
        _op_hm_scalar(stream, hms)
    elif op == 5:
        _op_hm_terrain(stream, hms)
    elif op == 6:
        _op_hm_smooth_erosion(stream, hms)
    elif op == 7:
        _op_hm_binary(stream, hms)
    elif op == 8:
        _op_hm_subscript(stream, hms)
    elif op == 9:
        _op_hm_query(stream, hms)
    elif op == 10:
        _op_dm_scalar(stream, dms)
    elif op == 11:
        _op_dm_get_set(stream, dms)
    elif op == 12:
        _op_dm_query(stream, dms)
    elif op == 13:
        _op_dm_bitwise(stream, dms)
    elif op == 14:
        _op_dm_to_bool(stream, dms)
    elif op == 15:
        _op_dm_copy_from(stream, dms)
    elif op == 16:
        _op_hm_add_noise(stream, hms, nss)
    elif op == 17:
        _op_hm_add_bsp(stream, hms, bsps)
    elif op == 18:
        _op_noise_sample(stream, nss, hms)
    elif op == 19:
        _op_bsp_to_heightmap(stream, bsps, hms)
    elif op == 20:
        _op_dm_from_heightmap(stream, hms, dms)
    elif op == 21:
        _op_dm_to_heightmap(stream, dms, hms)
    elif op == 22:
        _op_bsp_split(stream, bsps)
    elif op == 23:
        _op_bsp_walk(stream, bsps)
    elif op == 24:
        _op_layer_apply(stream, hms)


_NUM_OPS = 25


def fuzz_one_input(data):
    stream = ByteStream(data)
    hms = []
    dms = []
    nss = []
    bsps = []
    try:
        # Prime each container with at least one object so the very first
        # iteration exercises conversions and binary ops, not just empty
        # noops. All these can still raise on pathological sizes, which
        # is fine.
        try:
            _make_heightmap(stream, hms)
        except EXPECTED_EXCEPTIONS:
            pass
        try:
            _make_discretemap(stream, dms)
        except EXPECTED_EXCEPTIONS:
            pass
        try:
            _make_noise(stream, nss)
        except EXPECTED_EXCEPTIONS:
            pass
        try:
            _make_bsp(stream, bsps)
        except EXPECTED_EXCEPTIONS:
            pass

        n_ops = stream.int_in_range(1, MAX_OPS)
        for _ in range(n_ops):
            if stream.remaining < 1:
                break
            op = stream.u8() % _NUM_OPS
            try:
                _dispatch(op, stream, hms, dms, nss, bsps)
            except EXPECTED_EXCEPTIONS:
                pass
    except EXPECTED_EXCEPTIONS:
        pass
