# legacy_test/core/legacy_env_builder.py
"""Parameterised LegacyCelerisLab environment builders for all scenes.

Each builder follows the exact legacy procedure:
  1. Create FlowField with correct config and viscosity
  2. Add objects in legacy order (scene-dependent)
  3. Stabilise (4*NX/U0 steps)
  4. Record target signals
  5. Add pinball (if not already present), stabilise
  6. Compute norm from zero-action FIFO
  7. Run bias-action FIFO, save DDF checkpoint
  8. Return (flow_field, target_states, norm, scene_config)

Scene geometry reference (all positions in lattice units, L0=20):
  - Dist cylinder: x=200, r=20
  - Karman/Steady/Vortex pinball: front=600, rear=626, y_span=15
  - Karman/Steady/Vortex sensors: x=800, y_span=40
  - Illusion pinball: front=380, rear=406, y_span=15
  - Illusion sensors: x=600, y_span=40
  - Illusion target cylinder: x=400, r varies
"""

from __future__ import annotations

import os
import sys
from collections import deque
from typing import Any, Dict, Optional, Tuple

import numpy as np

_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
if _REPO not in sys.path:
    sys.path.insert(0, _REPO)

from LegacyCelerisLab import FlowField  # noqa: E402
from LegacyCelerisLab import utils as legacy_utils  # noqa: E402

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CONFIG_DIR = os.path.join(_REPO, "configs", "legacy_configs")
U0 = 0.01
L0 = 20.0
DATA_TYPE = np.float32
FIFO_LEN = 150
CONV_LEN = 30
SENSOR_RADIUS = L0 / 4.0   # 5
PINBALL_RADIUS = L0 / 2.0  # 10


def _nu_from_re(re_code: float) -> float:
    """Viscosity from code Reynolds number (ref length = 2*D = 40)."""
    return U0 * 40.0 / re_code


def _center_y(ff: FlowField) -> float:
    return (ff.FIELD_SHAPE[1] - 1) / 2.0


def _stabilize(ff: FlowField, n_obj: int) -> None:
    steps = int(4 * ff.FIELD_SHAPE[0] / U0)
    ff.run(steps, np.zeros(n_obj, dtype=DATA_TYPE))


def _compute_karman_norm(fifo: np.ndarray) -> Dict[str, Any]:
    """Standard norm: force_norm_fact = 6*max(|forces|), sensors 5*max deviation."""
    temp = np.asarray(fifo, dtype=DATA_TYPE)
    force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
    sens_dev = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
    sens_norm = np.zeros(6, dtype=DATA_TYPE)
    for i in range(6):
        sens_norm[i] = 5.0 * float(np.max(np.abs(temp[:, i] - sens_dev[i])))
    return {
        "force_norm_fact": force_norm_fact,
        "sens_deviation": sens_dev.tolist(),
        "sens_norm_fact": sens_norm.tolist(),
    }


# ---------------------------------------------------------------------------
# Karman Cloak (dist-cyl + 3 sensors + 3 pinball = 7 objects)
# ---------------------------------------------------------------------------

def build_karman_cloak(
    device_id: int = 0,
    re_code: float = 100.0,
    *,
    action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
    action_scale: float = 8.0,
    sample_interval: int = 800,
) -> Dict[str, Any]:
    """Build Karman cloak environment with LegacyCelerisLab.

    Object order: dist_cyl(0), sensor0(1), sensor1(2), sensor2(3),
                   front(4), top(5), bottom(6).

    Returns:
        dict with flow_field, target_states, norm, config.
    """
    viscosity = _nu_from_re(re_code)
    cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
    field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
    field_cfg = field_cfg._replace(viscosity=float(viscosity))

    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy = _center_y(ff)
    NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]

    # Phase 1: dist-cyl + sensors
    ff.add_cylinder((10.0 * L0, cy, 0.0), 1.0 * L0)  # dist_cyl, id=0
    for y_off in [2.0, 0.0, -2.0]:
        ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)  # id=1,2,3

    assert ff.obs.size // 2 == 4, "Expected 4 objects after sensors"

    _stabilize(ff, 4)

    # Record target (sensor signals only, no pinball)
    target = np.empty((0, 6), dtype=DATA_TYPE)
    for _ in range(FIFO_LEN):
        ff.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
        target = np.vstack((target, ff.obs.copy()[2:8]))

    # Phase 2: Add pinball
    ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)               # front, id=4
    ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)   # top, id=5
    ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)   # bottom, id=6

    n_total = ff.obs.size // 2
    assert n_total == 7, f"Expected 7 objects, got {n_total}"

    _stabilize(ff, 7)

    # Checkpoint DDF
    ff.get_ddf()
    ff.save_ddf()

    # Zero-action norm collection
    fifo = deque(maxlen=FIFO_LEN)
    for _ in range(FIFO_LEN):
        ff.run(sample_interval, np.zeros(7, dtype=DATA_TYPE))
        fifo.append(ff.obs.copy()[2:14])
    norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))

    # Bias-action FIFO
    ff.apply_ddf()
    bias_arr = np.zeros(7, dtype=DATA_TYPE)
    bias_arr[4] = float((0.0 * action_scale + action_bias[0]) * U0)  # front
    bias_arr[5] = float((0.0 * action_scale + action_bias[1]) * U0)  # top
    bias_arr[6] = float((0.0 * action_scale + action_bias[2]) * U0)  # bottom

    fifo.clear()
    for _ in range(FIFO_LEN):
        ff.run(sample_interval, bias_arr)
        fifo.append(ff.obs.copy()[2:14])
    save_states = np.array(list(fifo), dtype=DATA_TYPE)
    ff.apply_ddf()

    norm["save_states"] = save_states
    norm["action_bias"] = list(action_bias)
    norm["n_obj_total"] = 7

    config = {
        "device_id": device_id,
        "viscosity": viscosity,
        "re_code": re_code,
        "u0": U0,
        "sample_interval": sample_interval,
        "fifo_len": FIFO_LEN,
        "conv_len": CONV_LEN,
        "nx": NX,
        "ny": NY,
        "n_obj_total": 7,
        "action_scale": action_scale,
        "action_bias": list(action_bias),
        "obs_slice": (2, 14),
        "s_dim": 12,
    }

    return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}


# ---------------------------------------------------------------------------
# Steady Cloak (3 sensors + 3 pinball = 6 objects, no dist-cyl)
# ---------------------------------------------------------------------------

def build_steady_cloak(
    device_id: int = 0,
    re_code: float = 100.0,
    *,
    action_bias: Tuple[float, float, float] = (0.0, -5.1, 5.1),
) -> Dict[str, Any]:
    """Build steady cloaking environment (clean inflow, pinball only).

    Object order: sensor0(0), sensor1(1), sensor2(2), front(3), top(4), bottom(5).
    """
    viscosity = _nu_from_re(re_code)
    cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
    field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
    field_cfg = field_cfg._replace(viscosity=float(viscosity))

    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy = _center_y(ff)
    NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]

    # Sensors + pinball
    for y_off in [2.0, 0.0, -2.0]:
        ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)  # id=0,1,2
    ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)               # front, id=3
    ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)   # top, id=4
    ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)   # bottom, id=5

    n_total = ff.obs.size // 2
    assert n_total == 6, f"Expected 6 objects, got {n_total}"

    _stabilize(ff, 6)

    # Record target: sensors-only (no pinball, no dist-cyl) -> clean channel
    # We need a separate FlowField for this
    ff2 = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy2 = _center_y(ff2)
    for y_off in [2.0, 0.0, -2.0]:
        ff2.add_sensor((40.0 * L0, cy2 + y_off * L0, 0.0), SENSOR_RADIUS)

    n_sens_only = ff2.obs.size // 2
    assert n_sens_only == 3

    _stabilize(ff2, 3)

    target = np.empty((0, 6), dtype=DATA_TYPE)
    for _ in range(FIFO_LEN):
        ff2.run(800, np.zeros(3, dtype=DATA_TYPE))
        target = np.vstack((target, ff2.obs.copy()[0:6]))
    del ff2

    # Checkpoint DDF on pinball env
    ff.get_ddf()
    ff.save_ddf()

    # Norm
    fifo = deque(maxlen=FIFO_LEN)
    for _ in range(FIFO_LEN):
        ff.run(800, np.zeros(6, dtype=DATA_TYPE))
        fifo.append(ff.obs.copy()[0:12])
    temp = np.array(fifo, dtype=DATA_TYPE)
    force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
    sens_dev = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
    sens_norm = np.zeros(6, dtype=DATA_TYPE)
    for i in range(6):
        sens_norm[i] = 5.0 * float(np.max(np.abs(temp[:, i] - sens_dev[i])))

    # Bias FIFO
    ff.apply_ddf()
    bias_arr = np.zeros(6, dtype=DATA_TYPE)
    bias_arr[3] = float(action_bias[0] * U0)  # front
    bias_arr[4] = float(action_bias[1] * U0)  # top
    bias_arr[5] = float(action_bias[2] * U0)  # bottom

    fifo.clear()
    for _ in range(FIFO_LEN):
        ff.run(800, bias_arr)
        fifo.append(ff.obs.copy()[0:12])
    save_states = np.array(list(fifo), dtype=DATA_TYPE)
    ff.apply_ddf()

    norm = {
        "force_norm_fact": force_norm_fact,
        "sens_deviation": sens_dev.tolist(),
        "sens_norm_fact": sens_norm.tolist(),
        "save_states": save_states,
        "action_bias": list(action_bias),
        "n_obj_total": 6,
    }

    config = {
        "device_id": device_id,
        "viscosity": viscosity,
        "re_code": re_code,
        "u0": U0,
        "sample_interval": 800,
        "fifo_len": FIFO_LEN,
        "conv_len": CONV_LEN,
        "nx": NX,
        "ny": NY,
        "n_obj_total": 6,
        "action_scale": 8.0,
        "action_bias": list(action_bias),
        "obs_slice": (0, 12),
        "s_dim": 12,
    }

    return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}


# ---------------------------------------------------------------------------
# Illusion (target cylinder + 3 sensors at illusion positions, then pinball + sensors)
# ---------------------------------------------------------------------------

def build_illusion(
    device_id: int = 0,
    re_code: float = 100.0,
    *,
    target_diameter_L: float = 1.0,
    sample_interval: int = 600,
    action_bias: Tuple[float, float, float] = (0.0, -2.0, 2.0),
) -> Dict[str, Any]:
    """Build illusion environment.

    Phase 1 (target): target cylinder at x=20*L0 + 3 sensors at x=30*L0.
    Phase 2 (pinball): 3 sensors at x=30*L0 + pinball at 19/20.3*L0.

    Object order (pinball phase): sensor0(0), sensor1(1), sensor2(2),
                                   front(3), top(4), bottom(5).
    """
    viscosity = _nu_from_re(re_code)
    cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
    field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
    field_cfg = field_cfg._replace(viscosity=float(viscosity))

    # Phase 1: Target cylinder + sensors
    ff_target = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy = _center_y(ff_target)
    ff_target.add_cylinder((20.0 * L0, cy, 0.0), target_diameter_L * L0)  # id=0
    for y_off in [2.0, 0.0, -2.0]:
        ff_target.add_sensor((30.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)  # id=1,2,3

    n_target = ff_target.obs.size // 2
    assert n_target == 4

    _stabilize(ff_target, 4)

    # Record target (8 channels: cyl_force[2] + sensors[6])
    target_states = np.empty((0, 8), dtype=DATA_TYPE)
    for _ in range(FIFO_LEN):
        ff_target.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
        target_states = np.vstack((target_states, ff_target.obs.copy()[0:8]))

    # Harmonics analysis (FFT)
    from .dtw_metrics import analyze_harmonics
    target_harmonics = analyze_harmonics(target_states, n_harmonics=5)

    del ff_target

    # Phase 2: Pinball + sensors
    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy2 = _center_y(ff)
    NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]

    for y_off in [2.0, 0.0, -2.0]:
        ff.add_sensor((30.0 * L0, cy2 + y_off * L0, 0.0), SENSOR_RADIUS)  # id=0,1,2
    ff.add_cylinder((19.0 * L0, cy2, 0.0), PINBALL_RADIUS)                 # front, id=3
    ff.add_cylinder((20.3 * L0, cy2 + 0.75 * L0, 0.0), PINBALL_RADIUS)     # top, id=4
    ff.add_cylinder((20.3 * L0, cy2 - 0.75 * L0, 0.0), PINBALL_RADIUS)     # bottom, id=5

    n_total = ff.obs.size // 2
    assert n_total == 6, f"Expected 6 objects, got {n_total}"

    _stabilize(ff, 6)

    ff.get_ddf()
    ff.save_ddf()

    # Norm
    fifo = deque(maxlen=FIFO_LEN)
    for _ in range(FIFO_LEN):
        ff.run(sample_interval, np.zeros(6, dtype=DATA_TYPE))
        fifo.append(ff.obs.copy()[0:12])
    norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))

    # Bias FIFO (init bias = [0, -1, 1] * U0, different from DRL bias)
    ff.apply_ddf()
    init_bias = (0.0, -1.0, 1.0)
    bias_arr = np.zeros(6, dtype=DATA_TYPE)
    bias_arr[3] = float(init_bias[0] * U0)
    bias_arr[4] = float(init_bias[1] * U0)
    bias_arr[5] = float(init_bias[2] * U0)

    fifo.clear()
    for _ in range(FIFO_LEN):
        ff.run(sample_interval, bias_arr)
        fifo.append(ff.obs.copy()[0:12])
    save_states = np.array(list(fifo), dtype=DATA_TYPE)
    ff.apply_ddf()

    norm["save_states"] = save_states
    norm["action_bias"] = list(action_bias)
    norm["n_obj_total"] = 6

    config = {
        "device_id": device_id,
        "viscosity": viscosity,
        "re_code": re_code,
        "u0": U0,
        "sample_interval": sample_interval,
        "fifo_len": FIFO_LEN,
        "conv_len": 36,
        "nx": NX,
        "ny": NY,
        "n_obj_total": 6,
        "action_scale": 8.0,
        "action_bias": list(action_bias),
        "obs_slice": (0, 12),
        "s_dim": 14,
        "target_diameter_L": target_diameter_L,
    }

    return {
        "flow_field": ff,
        "target_states": target_states,
        "target_harmonics": target_harmonics,
        "norm": norm,
        "config": config,
    }


# ---------------------------------------------------------------------------
# Vortex (sensors only for target, then pinball + vortex for control)
# ---------------------------------------------------------------------------

def build_vortex(
    device_id: int = 0,
    re_code: float = 100.0,
    *,
    vortex_type: str = "lamb",
    action_scale: float = 4.0,
    action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
) -> Dict[str, Any]:
    """Build vortex cloaking environment.

    Target phase: vortex at x=10*L0 + 3 sensors.
    Pinball phase: vortex at x=15*L0 + pinball + 3 sensors.

    Object order (pinball phase): sensor0(0), sensor1(1), sensor2(2),
                                   front(3), top(4), bottom(5).

    MAX_STEPS = 150 (transient event).
    """
    viscosity = _nu_from_re(re_code)
    cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
    field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
    field_cfg = field_cfg._replace(viscosity=float(viscosity))

    vortex_strength = 0.5 * U0 if vortex_type == "lamb" else 0.03 * U0

    # Phase 1: Sensors-only env -> record clean channel -> add vortex -> record target
    ff_sensors = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy_s = _center_y(ff_sensors)
    for y_off in [2.0, 0.0, -2.0]:
        ff_sensors.add_sensor((40.0 * L0, cy_s + y_off * L0, 0.0), SENSOR_RADIUS)

    n_sens = ff_sensors.obs.size // 2
    assert n_sens == 3

    _stabilize(ff_sensors, 3)

    # Record clean channel baseline
    ff_sensors.get_ddf()
    ff_sensors.save_ddf()

    # Add vortex at x=10*L0 and record target
    ff_sensors.add_vortex(
        (10.0 * L0, cy_s, 0.0),
        2.0 * L0,
        vortex_strength,
        0.0,
        vortex_type,
    )

    target_states = np.empty((0, 6), dtype=DATA_TYPE)
    for _ in range(FIFO_LEN):
        ff_sensors.run(800, np.zeros(3, dtype=DATA_TYPE))
        target_states = np.vstack((target_states, ff_sensors.obs.copy()[0:6]))

    del ff_sensors

    # Phase 2: Pinball + sensors + vortex
    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy = _center_y(ff)
    NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]

    for y_off in [2.0, 0.0, -2.0]:
        ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)  # id=0,1,2
    ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)               # front, id=3
    ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)   # top, id=4
    ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)   # bottom, id=5

    n_total = ff.obs.size // 2
    assert n_total == 6, f"Expected 6 objects, got {n_total}"

    _stabilize(ff, 6)

    ff.get_ddf()
    ff.save_ddf()

    # Norm
    fifo = deque(maxlen=FIFO_LEN)
    for _ in range(FIFO_LEN):
        ff.run(800, np.zeros(6, dtype=DATA_TYPE))
        fifo.append(ff.obs.copy()[0:12])
    norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))

    # Bias FIFO
    ff.apply_ddf()
    bias_arr = np.zeros(6, dtype=DATA_TYPE)
    bias_arr[3] = float(action_bias[0] * U0)
    bias_arr[4] = float(action_bias[1] * U0)
    bias_arr[5] = float(action_bias[2] * U0)

    fifo.clear()
    for _ in range(FIFO_LEN):
        ff.run(800, bias_arr)
        fifo.append(ff.obs.copy()[0:12])
    save_states = np.array(list(fifo), dtype=DATA_TYPE)
    ff.apply_ddf()

    norm["save_states"] = save_states
    norm["action_bias"] = list(action_bias)
    norm["n_obj_total"] = 6
    norm["vortex_type"] = vortex_type
    norm["vortex_strength"] = vortex_strength

    config = {
        "device_id": device_id,
        "viscosity": viscosity,
        "re_code": re_code,
        "u0": U0,
        "sample_interval": 800,
        "fifo_len": FIFO_LEN,
        "conv_len": CONV_LEN,
        "nx": NX,
        "ny": NY,
        "n_obj_total": 6,
        "action_scale": action_scale,
        "action_bias": list(action_bias),
        "obs_slice": (0, 12),
        "s_dim": 12,
        "max_steps": 150,
        "vortex_type": vortex_type,
    }

    return {"flow_field": ff, "target_states": target_states, "norm": norm, "config": config}


# ---------------------------------------------------------------------------
# Erase (sensors(0-2) + dist-cyl(r=0.75L, id=3) + pinball(4-6) = 7 objects)
# ---------------------------------------------------------------------------

def build_erase(
    device_id: int = 0,
    re_code: float = 100.0,
    *,
    action_bias: Tuple[float, float, float] = (0.0, -8.0, 8.0),
) -> Dict[str, Any]:
    """Build erase environment (cancel upstream disturbance to clean flow).

    Object order (different from Karman!): sensor0(0), sensor1(1), sensor2(2),
    dist_cyl(3, r=0.75*L0), front(4), top(5), bottom(6).

    Target = clean inflow mean (static, not periodic).
    """
    viscosity = _nu_from_re(re_code)
    cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
    field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
    field_cfg = field_cfg._replace(viscosity=float(viscosity))

    # Phase 1: Clean channel target (sensors only)
    ff_clean = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy_c = _center_y(ff_clean)
    for y_off in [2.0, 0.0, -2.0]:
        ff_clean.add_sensor((40.0 * L0, cy_c + y_off * L0, 0.0), SENSOR_RADIUS)
    n_clean = ff_clean.obs.size // 2
    assert n_clean == 3

    _stabilize(ff_clean, 3)

    target = np.empty((0, 6), dtype=DATA_TYPE)
    for _ in range(FIFO_LEN):
        ff_clean.run(600, np.zeros(3, dtype=DATA_TYPE))
        target = np.vstack((target, ff_clean.obs.copy()[0:6]))
    # Target = mean (steady, not periodic)
    target_mean = np.mean(target, axis=0, dtype=DATA_TYPE)
    del ff_clean

    # Phase 2: Full erase env
    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    cy = _center_y(ff)
    NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]

    for y_off in [2.0, 0.0, -2.0]:
        ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)  # id=0,1,2
    ff.add_cylinder((10.0 * L0, cy, 0.0), 0.75 * L0)  # dist_cyl, r=0.75L, id=3
    ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)               # front, id=4
    ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)   # top, id=5
    ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)   # bottom, id=6

    n_total = ff.obs.size // 2
    assert n_total == 7, f"Expected 7 objects, got {n_total}"

    _stabilize(ff, 7)

    ff.get_ddf()
    ff.save_ddf()

    # Norm (erase-specific: full obs[0:14], force_norm uses pinball only)
    fifo = deque(maxlen=FIFO_LEN)
    for _ in range(FIFO_LEN):
        ff.run(600, np.zeros(7, dtype=DATA_TYPE))
        fifo.append(ff.obs.copy()[0:14])

    temp = np.array(fifo, dtype=DATA_TYPE)
    force_norm_fact = 100.0 * float(np.max(np.abs(temp[:, 8:14])))
    sens_dev = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
    sens_norm = np.zeros(6, dtype=DATA_TYPE)
    for i in range(6):
        sens_norm[i] = 10.0 * float(np.max(np.abs(temp[:, i] - sens_dev[i])))

    # Bias FIFO
    ff.apply_ddf()
    bias_arr = np.zeros(7, dtype=DATA_TYPE)
    bias_arr[4] = float(action_bias[0] * U0)
    bias_arr[5] = float(action_bias[1] * U0)
    bias_arr[6] = float(action_bias[2] * U0)

    fifo.clear()
    for _ in range(FIFO_LEN):
        ff.run(600, bias_arr)
        fifo.append(ff.obs.copy()[0:14])
    save_states = np.array(list(fifo), dtype=DATA_TYPE)
    ff.apply_ddf()

    norm = {
        "force_norm_fact": force_norm_fact,
        "sens_deviation": sens_dev.tolist(),
        "sens_norm_fact": sens_norm.tolist(),
        "save_states": save_states,
        "action_bias": list(action_bias),
        "n_obj_total": 7,
        "target_mean": target_mean.tolist(),
    }

    config = {
        "device_id": device_id,
        "viscosity": viscosity,
        "re_code": re_code,
        "u0": U0,
        "sample_interval": 600,
        "fifo_len": FIFO_LEN,
        "conv_len": 36,
        "nx": NX,
        "ny": NY,
        "n_obj_total": 7,
        "action_scale": 8.0,
        "action_bias": list(action_bias),
        "obs_slice": (0, 14),
        "s_dim": 12,
    }

    return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
