"""Python DSP simulator for dsp-graph signal graphs.

Runs a per-sample Python loop that mirrors the C++ code generated by
compile.py, enabling prototyping, unit-testing, and correctness verification
without compilation.

Usage::

    from gen_dsp.graph.simulate import simulate, SimState, SimResult

Requires numpy (install via ``pip install gen-dsp[sim]``).
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from typing import Any

try:
    import numpy as np
    from numpy.typing import NDArray
except ImportError as exc:
    raise ImportError(
        "numpy is required for simulation. Install with: pip install gen-dsp[sim]"
    ) from exc

from gen_dsp.graph.models import (
    SVF,
    ADSR,
    Accum,
    Allpass,
    BinOp,
    Biquad,
    Buffer,
    BufRead,
    BufSize,
    BufWrite,
    Change,
    Clamp,
    Compare,
    Constant,
    Counter,
    Cycle,
    DCBlock,
    DelayLine,
    DelayRead,
    DelayWrite,
    Delta,
    Elapsed,
    Fold,
    GateOut,
    GateRoute,
    Graph,
    History,
    Latch,
    Lookup,
    Mix,
    MulAccum,
    NamedConstant,
    Node,
    Noise,
    OnePole,
    Pass,
    Peek,
    Phasor,
    PulseOsc,
    RateDiv,
    SampleHold,
    SampleRate,
    SawOsc,
    Scale,
    Select,
    Selector,
    SinOsc,
    Slide,
    Smoothstep,
    SmoothParam,
    Splat,
    TriOsc,
    UnaryOp,
    Wave,
    Wrap,
)
from gen_dsp.graph.compile import _NAMED_CONSTANT_VALUES
from gen_dsp.graph.subgraph import expand_subgraphs
from gen_dsp.graph.toposort import toposort
from gen_dsp.graph.validate import validate_graph


class SimState:
    """Holds all mutable state for a simulated DSP graph."""

    def __init__(self, graph: Graph, sample_rate: float = 0.0) -> None:
        graph = expand_subgraphs(graph)
        errors = validate_graph(graph)
        if errors:
            raise ValueError("Invalid graph: " + "; ".join(errors))

        self._graph = graph
        self.sr = sample_rate if sample_rate > 0.0 else graph.sample_rate
        self._sorted_nodes = toposort(graph)
        self._params: dict[str, float] = {p.name: p.default for p in graph.params}
        self._state: dict[str, Any] = {}
        self._init_state()

    def _init_state(self) -> None:
        """Initialize node state, mirroring compile.py:_emit_state_init."""
        for node in self._sorted_nodes:
            nid = node.id
            if isinstance(node, History):
                self._state[nid] = node.init
            elif isinstance(node, DelayLine):
                self._state[f"{nid}.buf"] = np.zeros(node.max_samples, dtype=np.float32)
                self._state[f"{nid}.len"] = node.max_samples
                self._state[f"{nid}.wr"] = 0
            elif isinstance(node, Phasor):
                self._state[f"{nid}.phase"] = 0.0
            elif isinstance(node, Noise):
                self._state[f"{nid}.seed"] = np.uint32(123456789)
            elif isinstance(node, (Delta, Change)):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, (Biquad, SVF)):
                self._state[f"{nid}.s1"] = 0.0
                self._state[f"{nid}.s2"] = 0.0
            elif isinstance(node, OnePole):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, (DCBlock, Allpass)):
                self._state[f"{nid}.xprev"] = 0.0
                self._state[f"{nid}.yprev"] = 0.0
            elif isinstance(node, (SinOsc, TriOsc, SawOsc, PulseOsc)):
                self._state[f"{nid}.phase"] = 0.0
            elif isinstance(node, (SampleHold, Latch)):
                self._state[f"{nid}.held"] = 0.0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Accum):
                self._state[f"{nid}.sum"] = 0.0
            elif isinstance(node, Counter):
                self._state[f"{nid}.count"] = 0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Elapsed):
                self._state[f"{nid}.count"] = 0
            elif isinstance(node, MulAccum):
                self._state[f"{nid}.prod"] = 1.0
            elif isinstance(node, RateDiv):
                self._state[f"{nid}.count"] = 0
                self._state[f"{nid}.held"] = 0.0
            elif isinstance(node, SmoothParam):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, Slide):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, ADSR):
                self._state[f"{nid}.phase"] = 0
                self._state[f"{nid}.output"] = 0.0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Peek):
                self._state[f"{nid}.value"] = 0.0
            elif isinstance(node, Buffer):
                buf = np.zeros(node.size, dtype=np.float32)
                if node.fill == "sine":
                    buf[:] = np.sin(
                        2.0 * np.pi * np.arange(node.size, dtype=np.float32) / node.size
                    ).astype(np.float32)
                self._state[f"{nid}.buf"] = buf
                self._state[f"{nid}.len"] = node.size

    def reset(self) -> None:
        """Reset all state to initial values, mirroring compile.py:_emit_state_reset."""
        self._params = {p.name: p.default for p in self._graph.params}
        for node in self._sorted_nodes:
            nid = node.id
            if isinstance(node, History):
                self._state[nid] = node.init
            elif isinstance(node, DelayLine):
                buf = self._state[f"{nid}.buf"]
                buf[:] = 0.0
                self._state[f"{nid}.wr"] = 0
            elif isinstance(node, (Phasor, SinOsc, TriOsc, SawOsc, PulseOsc)):
                self._state[f"{nid}.phase"] = 0.0
            elif isinstance(node, Noise):
                self._state[f"{nid}.seed"] = np.uint32(123456789)
            elif isinstance(node, (Delta, Change)):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, (Biquad, SVF)):
                self._state[f"{nid}.s1"] = 0.0
                self._state[f"{nid}.s2"] = 0.0
            elif isinstance(node, OnePole):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, (DCBlock, Allpass)):
                self._state[f"{nid}.xprev"] = 0.0
                self._state[f"{nid}.yprev"] = 0.0
            elif isinstance(node, (SampleHold, Latch)):
                self._state[f"{nid}.held"] = 0.0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Accum):
                self._state[f"{nid}.sum"] = 0.0
            elif isinstance(node, Counter):
                self._state[f"{nid}.count"] = 0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Elapsed):
                self._state[f"{nid}.count"] = 0
            elif isinstance(node, MulAccum):
                self._state[f"{nid}.prod"] = 1.0
            elif isinstance(node, RateDiv):
                self._state[f"{nid}.count"] = 0
                self._state[f"{nid}.held"] = 0.0
            elif isinstance(node, SmoothParam):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, Slide):
                self._state[f"{nid}.prev"] = 0.0
            elif isinstance(node, ADSR):
                self._state[f"{nid}.phase"] = 0
                self._state[f"{nid}.output"] = 0.0
                self._state[f"{nid}.ptrig"] = 0.0
            elif isinstance(node, Peek):
                self._state[f"{nid}.value"] = 0.0
            elif isinstance(node, Buffer):
                buf = self._state[f"{nid}.buf"]
                if node.fill == "sine":
                    buf[:] = np.sin(
                        2.0 * np.pi * np.arange(node.size, dtype=np.float32) / node.size
                    ).astype(np.float32)
                else:
                    buf[:] = 0.0

    def set_param(self, name: str, value: float) -> None:
        """Set a parameter value. Raises KeyError if name is unknown."""
        if name not in self._params:
            raise KeyError(f"Unknown param: '{name}'")
        self._params[name] = value

    def get_param(self, name: str) -> float:
        """Get current parameter value. Raises KeyError if name is unknown."""
        if name not in self._params:
            raise KeyError(f"Unknown param: '{name}'")
        return self._params[name]

    def set_buffer(self, buffer_id: str, data: NDArray[np.float32]) -> None:
        """Set buffer contents. Data is truncated/zero-padded to buffer size."""
        key = f"{buffer_id}.buf"
        if key not in self._state:
            raise KeyError(f"Unknown buffer: '{buffer_id}'")
        buf: NDArray[np.float32] = self._state[key]
        copy_len = min(len(data), len(buf))
        buf[:copy_len] = data[:copy_len]
        buf[copy_len:] = 0.0

    def get_buffer(self, buffer_id: str) -> NDArray[np.float32]:
        """Get a copy of buffer contents."""
        key = f"{buffer_id}.buf"
        if key not in self._state:
            raise KeyError(f"Unknown buffer: '{buffer_id}'")
        buf: NDArray[np.float32] = self._state[key]
        return buf.copy()

    def get_peek(self, peek_id: str) -> float:
        """Get the last value captured by a Peek node."""
        key = f"{peek_id}.value"
        if key not in self._state:
            raise KeyError(f"Unknown peek: '{peek_id}'")
        return float(self._state[key])


@dataclass
class SimResult:
    """Result of a simulation run."""

    outputs: dict[str, NDArray[np.float32]] = field(default_factory=dict)
    state: SimState = field(repr=False, default=None)  # type: ignore[assignment]


def simulate(
    graph: Graph,
    inputs: dict[str, NDArray[np.float32]] | None = None,
    n_samples: int = 0,
    params: dict[str, float] | None = None,
    state: SimState | None = None,
    sample_rate: float = 0.0,
) -> SimResult:
    """Simulate a DSP graph in Python, returning output arrays and state.

    Args:
        graph: The DSP graph to simulate.
        inputs: Dict mapping audio input IDs to float32 arrays. All arrays
            must have the same length. May be None for generators.
        n_samples: Number of samples to process. Inferred from inputs if 0.
            Required for generators (no inputs).
        params: Optional param overrides (name -> value).
        state: Optional SimState to reuse across calls. Created if None.
        sample_rate: Sample rate override. Uses graph.sample_rate if 0.

    Returns:
        SimResult with output arrays and the (possibly new) SimState.

    Raises:
        ValueError: On invalid graph, mismatched input shapes, etc.
    """
    if state is None:
        state = SimState(graph, sample_rate)

    # Apply param overrides
    if params:
        for name, value in params.items():
            state.set_param(name, value)

    # Determine n_samples
    input_ids = {inp.id for inp in state._graph.inputs}
    if inputs:
        lengths = set()
        for iid, arr in inputs.items():
            if iid not in input_ids:
                raise ValueError(f"Unknown input: '{iid}'")
            lengths.add(len(arr))
        if len(lengths) > 1:
            raise ValueError(f"Input arrays have mismatched lengths: {lengths}")
        inferred = lengths.pop() if lengths else 0
        if n_samples == 0:
            n_samples = inferred
        elif n_samples != inferred:
            raise ValueError(
                f"n_samples={n_samples} does not match input length {inferred}"
            )
    elif n_samples == 0:
        raise ValueError("n_samples required when no inputs are provided")

    # Validate all required inputs are present
    if input_ids and (not inputs or set(inputs.keys()) != input_ids):
        provided = set(inputs.keys()) if inputs else set()
        missing = input_ids - provided
        if missing:
            raise ValueError(f"Missing inputs: {missing}")

    # Allocate output arrays
    output_arrays: dict[str, NDArray[np.float32]] = {}
    for out in state._graph.outputs:
        output_arrays[out.id] = np.zeros(n_samples, dtype=np.float32)

    # Pre-build lookup structures
    param_names = {p.name for p in state._graph.params}
    sorted_nodes = state._sorted_nodes

    # Classify control-rate nodes
    ctrl_interval = state._graph.control_interval
    ctrl_set = set(state._graph.control_nodes) if ctrl_interval > 0 else set()
    # Split sorted nodes into control-rate and audio-rate
    ctrl_sorted = [n for n in sorted_nodes if n.id in ctrl_set]
    audio_sorted = [n for n in sorted_nodes if n.id not in ctrl_set]

    # Persistent vals dict for holding control-rate values across samples
    held_vals: dict[str, float] = {}

    # Sample loop
    for i in range(n_samples):
        vals: dict[str, float] = {}

        # Load input samples
        if inputs:
            for iid, arr in inputs.items():
                vals[iid] = float(arr[i])

        # Load param values
        for pname, pval in state._params.items():
            vals[pname] = pval

        # Load sr
        vals["sr"] = state.sr

        # Pre-load History values
        for node in sorted_nodes:
            if isinstance(node, History):
                vals[node.id] = float(state._state[node.id])

        # Control-rate gating
        is_ctrl_boundary = ctrl_interval > 0 and ctrl_set and i % ctrl_interval == 0

        # Inject held control-rate values for non-boundary samples only
        if ctrl_interval > 0 and ctrl_set and not is_ctrl_boundary:
            vals.update(held_vals)

        # Control-rate: compute only at block boundaries
        if is_ctrl_boundary:
            ctrl_history: list[History] = []
            for node in ctrl_sorted:
                _compute_node(node, vals, state, input_ids, param_names, ctrl_history)
            # Deferred control-rate history write-backs
            for h in ctrl_history:
                state._state[h.id] = _resolve_ref(h.input, vals, input_ids, param_names)
            # Capture control-rate values for holding
            for node in ctrl_sorted:
                if node.id in vals:
                    held_vals[node.id] = vals[node.id]

        # Audio-rate: every sample
        audio_history: list[History] = []
        for node in audio_sorted:
            _compute_node(node, vals, state, input_ids, param_names, audio_history)

        # Deferred audio-rate history write-backs
        for h in audio_history:
            state._state[h.id] = _resolve_ref(h.input, vals, input_ids, param_names)

        # Write outputs
        for out in state._graph.outputs:
            output_arrays[out.id][i] = np.float32(vals[out.source])

    return SimResult(outputs=output_arrays, state=state)


def _resolve_ref(
    ref: str | float,
    vals: dict[str, float],
    input_ids: set[str],
    param_names: set[str],
) -> float:
    """Resolve a Ref to a float value."""
    if isinstance(ref, float):
        return ref
    return vals[ref]


def _compute_node(
    node: Node,
    vals: dict[str, float],
    state: SimState,
    input_ids: set[str],
    param_names: set[str],
    history_nodes: list[History],
) -> None:
    """Compute a single node, updating vals and state in-place.

    Directly mirrors compile.py:_emit_node_compute.
    """
    nid = node.id

    def ref(r: str | float) -> float:
        return _resolve_ref(r, vals, input_ids, param_names)

    if isinstance(node, BinOp):
        a, b = ref(node.a), ref(node.b)
        if node.op == "add":
            vals[nid] = a + b
        elif node.op == "sub":
            vals[nid] = a - b
        elif node.op == "mul":
            vals[nid] = a * b
        elif node.op == "div":
            vals[nid] = a / b if b != 0.0 else 0.0
        elif node.op == "min":
            vals[nid] = min(a, b)
        elif node.op == "max":
            vals[nid] = max(a, b)
        elif node.op == "mod":
            vals[nid] = math.fmod(a, b) if b != 0.0 else 0.0
        elif node.op == "pow":
            vals[nid] = math.pow(a, b)
        elif node.op == "atan2":
            vals[nid] = math.atan2(a, b)
        elif node.op == "hypot":
            vals[nid] = math.hypot(a, b)
        elif node.op == "absdiff":
            vals[nid] = abs(a - b)
        elif node.op == "step":
            vals[nid] = 1.0 if a >= b else 0.0
        elif node.op == "and":
            vals[nid] = 1.0 if (a != 0.0 and b != 0.0) else 0.0
        elif node.op == "or":
            vals[nid] = 1.0 if (a != 0.0 or b != 0.0) else 0.0
        elif node.op == "xor":
            vals[nid] = 1.0 if ((a != 0.0) != (b != 0.0)) else 0.0
        elif node.op == "rsub":
            vals[nid] = b - a
        elif node.op == "rdiv":
            vals[nid] = b / a if a != 0.0 else 0.0
        elif node.op == "rmod":
            vals[nid] = math.fmod(b, a) if a != 0.0 else 0.0
        elif node.op == "gtp":
            vals[nid] = a if a > b else 0.0
        elif node.op == "ltp":
            vals[nid] = a if a < b else 0.0
        elif node.op == "gtep":
            vals[nid] = a if a >= b else 0.0
        elif node.op == "ltep":
            vals[nid] = a if a <= b else 0.0
        elif node.op == "eqp":
            vals[nid] = a if a == b else 0.0
        elif node.op == "neqp":
            vals[nid] = a if a != b else 0.0
        elif node.op == "fastpow":
            vals[nid] = math.pow(a, b)

    elif isinstance(node, UnaryOp):
        a = ref(node.a)
        if node.op == "neg":
            vals[nid] = -a
        elif node.op == "sign":
            vals[nid] = 1.0 if a > 0.0 else (-1.0 if a < 0.0 else 0.0)
        elif node.op == "sin":
            vals[nid] = math.sin(a)
        elif node.op == "cos":
            vals[nid] = math.cos(a)
        elif node.op == "tanh":
            vals[nid] = math.tanh(a)
        elif node.op == "exp":
            vals[nid] = math.exp(a)
        elif node.op == "log":
            vals[nid] = math.log(a) if a > 0.0 else float("-inf")
        elif node.op == "abs":
            vals[nid] = abs(a)
        elif node.op == "sqrt":
            vals[nid] = math.sqrt(a) if a >= 0.0 else 0.0
        elif node.op == "floor":
            vals[nid] = math.floor(a)
        elif node.op == "ceil":
            vals[nid] = math.ceil(a)
        elif node.op == "round":
            vals[nid] = float(round(a))
        elif node.op == "atan":
            vals[nid] = math.atan(a)
        elif node.op == "asin":
            vals[nid] = math.asin(max(-1.0, min(1.0, a)))
        elif node.op == "acos":
            vals[nid] = math.acos(max(-1.0, min(1.0, a)))
        elif node.op == "tan":
            vals[nid] = math.tan(a)
        elif node.op == "sinh":
            vals[nid] = math.sinh(a)
        elif node.op == "cosh":
            vals[nid] = math.cosh(a)
        elif node.op == "asinh":
            vals[nid] = math.asinh(a)
        elif node.op == "acosh":
            vals[nid] = math.acosh(a) if a >= 1.0 else 0.0
        elif node.op == "atanh":
            vals[nid] = math.atanh(a) if -1.0 < a < 1.0 else 0.0
        elif node.op == "exp2":
            vals[nid] = math.pow(2.0, a)
        elif node.op == "log2":
            vals[nid] = math.log2(a) if a > 0.0 else float("-inf")
        elif node.op == "log10":
            vals[nid] = math.log10(a) if a > 0.0 else float("-inf")
        elif node.op == "fract":
            vals[nid] = a - math.floor(a)
        elif node.op == "trunc":
            vals[nid] = math.trunc(a)
        elif node.op == "not":
            vals[nid] = 1.0 if a == 0.0 else 0.0
        elif node.op == "bool":
            vals[nid] = 0.0 if a == 0.0 else 1.0
        elif node.op == "mtof":
            vals[nid] = 440.0 * math.pow(2.0, (a - 69.0) / 12.0)
        elif node.op == "ftom":
            vals[nid] = 69.0 + 12.0 * math.log2(max(a, 1e-10) / 440.0)
        elif node.op == "atodb":
            vals[nid] = 20.0 * math.log10(max(a, 1e-10))
        elif node.op == "dbtoa":
            vals[nid] = math.pow(10.0, a / 20.0)
        elif node.op == "phasewrap":
            twopi = 2.0 * math.pi
            vals[nid] = a - twopi * math.floor(a / twopi + 0.5)
        elif node.op == "degrees":
            vals[nid] = a * (180.0 / math.pi)
        elif node.op == "radians":
            vals[nid] = a * (math.pi / 180.0)
        elif node.op == "mstosamps":
            vals[nid] = a * state.sr / 1000.0
        elif node.op == "sampstoms":
            vals[nid] = a * 1000.0 / state.sr
        elif node.op == "t60":
            vals[nid] = (
                math.exp(-6.9078 / (a * state.sr)) if a * state.sr != 0.0 else 0.0
            )
        elif node.op == "t60time":
            vals[nid] = (
                -6.9078 / (math.log(a) * state.sr)
                if a > 0.0 and a != 1.0 and state.sr != 0.0
                else 0.0
            )
        elif node.op == "fixdenorm":
            vals[nid] = 0.0 if abs(a) < 1e-18 else a
        elif node.op == "fixnan":
            vals[nid] = 0.0 if math.isnan(a) else a
        elif node.op == "isdenorm":
            vals[nid] = 1.0 if (abs(a) < 1e-18 and a != 0.0) else 0.0
        elif node.op == "isnan":
            vals[nid] = 1.0 if math.isnan(a) else 0.0
        elif node.op == "fastsin":
            vals[nid] = math.sin(a)
        elif node.op == "fastcos":
            vals[nid] = math.cos(a)
        elif node.op == "fasttan":
            vals[nid] = math.tan(a)
        elif node.op == "fastexp":
            vals[nid] = math.exp(a)

    elif isinstance(node, Clamp):
        a, lo, hi = ref(node.a), ref(node.lo), ref(node.hi)
        vals[nid] = min(max(a, lo), hi)

    elif isinstance(node, Constant):
        vals[nid] = node.value

    elif isinstance(node, History):
        # Value already pre-loaded; track for deferred write-back
        history_nodes.append(node)

    elif isinstance(node, DelayLine):
        # State-only, no per-sample compute
        pass

    elif isinstance(node, DelayRead):
        dl = node.delay
        tap = ref(node.tap)
        buf: NDArray[np.float32] = state._state[f"{dl}.buf"]
        length: int = state._state[f"{dl}.len"]
        wr: int = state._state[f"{dl}.wr"]

        if node.interp == "none":
            pos = (wr - int(tap)) % length
            vals[nid] = float(buf[pos])
        elif node.interp == "linear":
            vals[nid] = _interp_linear_delay(tap, buf, length, wr)
        elif node.interp == "cubic":
            vals[nid] = _interp_cubic_delay(tap, buf, length, wr)

    elif isinstance(node, DelayWrite):
        dl = node.delay
        val = ref(node.value)
        buf = state._state[f"{dl}.buf"]
        wr = state._state[f"{dl}.wr"]
        length = state._state[f"{dl}.len"]
        buf[wr] = np.float32(val)
        state._state[f"{dl}.wr"] = (wr + 1) % length

    elif isinstance(node, Phasor):
        freq = ref(node.freq)
        phase = state._state[f"{nid}.phase"]
        vals[nid] = phase
        phase += freq / state.sr
        if phase >= 1.0:
            phase -= 1.0
        state._state[f"{nid}.phase"] = phase

    elif isinstance(node, Noise):
        seed = state._state[f"{nid}.seed"]
        with np.errstate(over="ignore"):
            seed = np.uint32(
                np.uint32(seed) * np.uint32(1664525) + np.uint32(1013904223)
            )
        state._state[f"{nid}.seed"] = seed
        vals[nid] = float(np.int32(seed)) / 2147483648.0

    elif isinstance(node, Compare):
        a, b = ref(node.a), ref(node.b)
        if node.op == "gt":
            vals[nid] = 1.0 if a > b else 0.0
        elif node.op == "lt":
            vals[nid] = 1.0 if a < b else 0.0
        elif node.op == "gte":
            vals[nid] = 1.0 if a >= b else 0.0
        elif node.op == "lte":
            vals[nid] = 1.0 if a <= b else 0.0
        elif node.op == "eq":
            vals[nid] = 1.0 if a == b else 0.0
        elif node.op == "neq":
            vals[nid] = 1.0 if a != b else 0.0

    elif isinstance(node, Select):
        cond = ref(node.cond)
        vals[nid] = ref(node.a) if cond > 0.0 else ref(node.b)

    elif isinstance(node, Wrap):
        a, lo, hi = ref(node.a), ref(node.lo), ref(node.hi)
        rng = hi - lo
        raw = math.fmod(a - lo, rng)
        if raw < 0.0:
            raw += rng
        vals[nid] = lo + raw

    elif isinstance(node, Fold):
        a, lo, hi = ref(node.a), ref(node.lo), ref(node.hi)
        rng = hi - lo
        t = math.fmod(a - lo, 2.0 * rng)
        if t < 0.0:
            t += 2.0 * rng
        vals[nid] = lo + t if t <= rng else hi - (t - rng)

    elif isinstance(node, Mix):
        a_v, b_v, t_v = ref(node.a), ref(node.b), ref(node.t)
        vals[nid] = a_v + (b_v - a_v) * t_v

    elif isinstance(node, Delta):
        a = ref(node.a)
        prev = state._state[f"{nid}.prev"]
        vals[nid] = a - prev
        state._state[f"{nid}.prev"] = a

    elif isinstance(node, Change):
        a = ref(node.a)
        prev = state._state[f"{nid}.prev"]
        vals[nid] = 1.0 if a != prev else 0.0
        state._state[f"{nid}.prev"] = a

    elif isinstance(node, Biquad):
        x = ref(node.a)
        b0, b1, b2 = ref(node.b0), ref(node.b1), ref(node.b2)
        a1, a2 = ref(node.a1), ref(node.a2)
        s1 = state._state[f"{nid}.s1"]
        s2 = state._state[f"{nid}.s2"]
        y = b0 * x + s1
        state._state[f"{nid}.s1"] = b1 * x - a1 * y + s2
        state._state[f"{nid}.s2"] = b2 * x - a2 * y
        vals[nid] = y

    elif isinstance(node, SVF):
        x = ref(node.a)
        freq = ref(node.freq)
        q = ref(node.q)
        sr = state.sr
        s1 = state._state[f"{nid}.s1"]
        s2 = state._state[f"{nid}.s2"]
        g = math.tan(3.14159265 * freq / sr)
        k = 1.0 / q
        a1 = 1.0 / (1.0 + g * (g + k))
        a2 = g * a1
        a3 = g * a2
        v3 = x - s2
        v1 = a1 * s1 + a2 * v3
        v2 = s2 + a2 * s1 + a3 * v3
        state._state[f"{nid}.s1"] = 2.0 * v1 - s1
        state._state[f"{nid}.s2"] = 2.0 * v2 - s2
        mode = node.mode
        if mode == "lp":
            vals[nid] = v2
        elif mode == "hp":
            vals[nid] = x - k * v1 - v2
        elif mode == "bp":
            vals[nid] = v1
        elif mode == "notch":
            vals[nid] = x - k * v1

    elif isinstance(node, OnePole):
        a = ref(node.a)
        c = ref(node.coeff)
        prev = state._state[f"{nid}.prev"]
        y = c * a + (1.0 - c) * prev
        state._state[f"{nid}.prev"] = y
        vals[nid] = y

    elif isinstance(node, DCBlock):
        x = ref(node.a)
        xprev = state._state[f"{nid}.xprev"]
        yprev = state._state[f"{nid}.yprev"]
        y = x - xprev + 0.995 * yprev
        state._state[f"{nid}.xprev"] = x
        state._state[f"{nid}.yprev"] = y
        vals[nid] = y

    elif isinstance(node, Allpass):
        x = ref(node.a)
        c = ref(node.coeff)
        xprev = state._state[f"{nid}.xprev"]
        yprev = state._state[f"{nid}.yprev"]
        y = c * (x - yprev) + xprev
        state._state[f"{nid}.xprev"] = x
        state._state[f"{nid}.yprev"] = y
        vals[nid] = y

    elif isinstance(node, SinOsc):
        freq = ref(node.freq)
        phase = state._state[f"{nid}.phase"]
        vals[nid] = math.sin(6.28318530 * phase)
        phase += freq / state.sr
        if phase >= 1.0:
            phase -= 1.0
        state._state[f"{nid}.phase"] = phase

    elif isinstance(node, TriOsc):
        freq = ref(node.freq)
        phase = state._state[f"{nid}.phase"]
        vals[nid] = 4.0 * abs(phase - 0.5) - 1.0
        phase += freq / state.sr
        if phase >= 1.0:
            phase -= 1.0
        state._state[f"{nid}.phase"] = phase

    elif isinstance(node, SawOsc):
        freq = ref(node.freq)
        phase = state._state[f"{nid}.phase"]
        vals[nid] = 2.0 * phase - 1.0
        phase += freq / state.sr
        if phase >= 1.0:
            phase -= 1.0
        state._state[f"{nid}.phase"] = phase

    elif isinstance(node, PulseOsc):
        freq = ref(node.freq)
        width = ref(node.width)
        phase = state._state[f"{nid}.phase"]
        vals[nid] = 1.0 if phase < width else -1.0
        phase += freq / state.sr
        if phase >= 1.0:
            phase -= 1.0
        state._state[f"{nid}.phase"] = phase

    elif isinstance(node, SampleHold):
        a = ref(node.a)
        t = ref(node.trig)
        ptrig = state._state[f"{nid}.ptrig"]
        held = state._state[f"{nid}.held"]
        if (ptrig <= 0.0 and t > 0.0) or (ptrig > 0.0 and t <= 0.0):
            held = a
        state._state[f"{nid}.ptrig"] = t
        state._state[f"{nid}.held"] = held
        vals[nid] = held

    elif isinstance(node, Latch):
        a = ref(node.a)
        t = ref(node.trig)
        ptrig = state._state[f"{nid}.ptrig"]
        held = state._state[f"{nid}.held"]
        if ptrig <= 0.0 and t > 0.0:
            held = a
        state._state[f"{nid}.ptrig"] = t
        state._state[f"{nid}.held"] = held
        vals[nid] = held

    elif isinstance(node, Accum):
        incr = ref(node.incr)
        reset = ref(node.reset)
        s = state._state[f"{nid}.sum"]
        if reset > 0.0:
            s = 0.0
        s += incr
        state._state[f"{nid}.sum"] = s
        vals[nid] = s

    elif isinstance(node, Counter):
        t = ref(node.trig)
        mx = ref(node.max)
        ptrig = state._state[f"{nid}.ptrig"]
        count = state._state[f"{nid}.count"]
        if ptrig <= 0.0 and t > 0.0:
            count += 1
            if count >= int(mx):
                count = 0
        state._state[f"{nid}.ptrig"] = t
        state._state[f"{nid}.count"] = count
        vals[nid] = float(count)

    elif isinstance(node, Elapsed):
        count = state._state[f"{nid}.count"]
        vals[nid] = float(count)
        state._state[f"{nid}.count"] = count + 1

    elif isinstance(node, MulAccum):
        incr = ref(node.incr)
        reset = ref(node.reset)
        prod = state._state[f"{nid}.prod"]
        if reset > 0.0:
            prod = 1.0
        prod *= incr
        state._state[f"{nid}.prod"] = prod
        vals[nid] = prod

    elif isinstance(node, Buffer):
        # State-only, no per-sample compute
        pass

    elif isinstance(node, BufRead):
        buf_id = node.buffer
        idx = ref(node.index)
        buf = state._state[f"{buf_id}.buf"]
        buf_len: int = state._state[f"{buf_id}.len"]
        if node.interp == "none":
            ii = int(idx)
            ii = max(0, min(ii, buf_len - 1))
            vals[nid] = float(buf[ii])
        elif node.interp == "linear":
            vals[nid] = _interp_linear_buf(idx, buf, buf_len)
        elif node.interp == "cubic":
            vals[nid] = _interp_cubic_buf(idx, buf, buf_len)

    elif isinstance(node, BufWrite):
        buf_id = node.buffer
        idx = ref(node.index)
        val = ref(node.value)
        buf = state._state[f"{buf_id}.buf"]
        buf_len = state._state[f"{buf_id}.len"]
        ii = int(idx)
        if 0 <= ii < buf_len:
            buf[ii] = np.float32(val)

    elif isinstance(node, Splat):
        buf_id = node.buffer
        idx = ref(node.index)
        val = ref(node.value)
        buf = state._state[f"{buf_id}.buf"]
        buf_len = state._state[f"{buf_id}.len"]
        ii = int(idx)
        if 0 <= ii < buf_len:
            buf[ii] += np.float32(val)

    elif isinstance(node, BufSize):
        buf_id = node.buffer
        vals[nid] = float(state._state[f"{buf_id}.len"])

    elif isinstance(node, Cycle):
        buf_id = node.buffer
        phase = ref(node.phase)
        buf = state._state[f"{buf_id}.buf"]
        buf_len = state._state[f"{buf_id}.len"]
        p = phase - math.floor(phase)
        fidx = p * buf_len
        i0 = int(fidx) % buf_len
        frac = fidx - math.floor(fidx)
        i1 = (i0 + 1) % buf_len
        vals[nid] = float(buf[i0]) + frac * (float(buf[i1]) - float(buf[i0]))

    elif isinstance(node, Wave):
        buf_id = node.buffer
        phase = ref(node.phase)
        buf = state._state[f"{buf_id}.buf"]
        blen: int = state._state[f"{buf_id}.len"]
        norm = (phase + 1.0) * 0.5
        fidx = norm * (blen - 1)
        fidx = max(0.0, min(fidx, float(blen - 1)))
        i0 = int(fidx)
        frac = fidx - float(i0)
        i1 = min(i0 + 1, blen - 1)
        vals[nid] = float(buf[i0]) + frac * (float(buf[i1]) - float(buf[i0]))

    elif isinstance(node, Lookup):
        buf_id = node.buffer
        idx = ref(node.index)
        buf = state._state[f"{buf_id}.buf"]
        llen: int = state._state[f"{buf_id}.len"]
        ci = max(0.0, min(idx, 1.0))
        fidx = ci * (llen - 1)
        i0 = int(fidx)
        frac = fidx - float(i0)
        i1 = min(i0 + 1, llen - 1)
        vals[nid] = float(buf[i0]) + frac * (float(buf[i1]) - float(buf[i0]))

    elif isinstance(node, RateDiv):
        a = ref(node.a)
        divisor = ref(node.divisor)
        count = state._state[f"{nid}.count"]
        held = state._state[f"{nid}.held"]
        if count == 0:
            held = a
        count += 1
        if count >= int(divisor):
            count = 0
        state._state[f"{nid}.count"] = count
        state._state[f"{nid}.held"] = held
        vals[nid] = held

    elif isinstance(node, Scale):
        a = ref(node.a)
        in_lo = ref(node.in_lo)
        in_hi = ref(node.in_hi)
        out_lo = ref(node.out_lo)
        out_hi = ref(node.out_hi)
        in_range = in_hi - in_lo
        out_range = out_hi - out_lo
        vals[nid] = (
            out_lo + (a - in_lo) / in_range * out_range if in_range != 0.0 else out_lo
        )

    elif isinstance(node, SmoothParam):
        a = ref(node.a)
        c = ref(node.coeff)
        prev = state._state[f"{nid}.prev"]
        y = (1.0 - c) * a + c * prev
        state._state[f"{nid}.prev"] = y
        vals[nid] = y

    elif isinstance(node, Slide):
        a = ref(node.a)
        up = ref(node.up)
        down = ref(node.down)
        prev = state._state[f"{nid}.prev"]
        s = up if a > prev else down
        s = s if s > 1.0 else 1.0
        y = prev + (a - prev) / s
        state._state[f"{nid}.prev"] = y
        vals[nid] = y

    elif isinstance(node, ADSR):
        gate_val = ref(node.gate)
        attack_ms = ref(node.attack)
        decay_ms = ref(node.decay)
        sustain_lvl = ref(node.sustain)
        release_ms = ref(node.release)
        phase = state._state[f"{nid}.phase"]
        output = state._state[f"{nid}.output"]
        ptrig = state._state[f"{nid}.ptrig"]
        sr = state.sr
        # Edge detection: gate on
        if gate_val > 0.0 and ptrig <= 0.0:
            phase = 1  # ATTACK
        # Edge detection: gate off
        if gate_val <= 0.0 and ptrig > 0.0:
            phase = 4  # RELEASE
        ptrig = gate_val
        # Attack
        if phase == 1:
            a_samps = max(attack_ms * sr * 0.001, 1.0)
            output += 1.0 / a_samps
            if output >= 1.0:
                output = 1.0
                phase = 2
        # Decay
        if phase == 2:
            d_samps = max(decay_ms * sr * 0.001, 1.0)
            output -= (1.0 - sustain_lvl) / d_samps
            if output <= sustain_lvl:
                output = sustain_lvl
                phase = 3
        # Sustain
        if phase == 3:
            output = sustain_lvl
        # Release
        if phase == 4:
            r_samps = max(release_ms * sr * 0.001, 1.0)
            output -= 1.0 / r_samps
            if output <= 0.0:
                output = 0.0
                phase = 0
        state._state[f"{nid}.phase"] = phase
        state._state[f"{nid}.output"] = output
        state._state[f"{nid}.ptrig"] = ptrig
        vals[nid] = output

    elif isinstance(node, Peek):
        a = ref(node.a)
        vals[nid] = a
        state._state[f"{nid}.value"] = a

    elif isinstance(node, Pass):
        vals[nid] = ref(node.a)

    elif isinstance(node, NamedConstant):
        vals[nid] = _NAMED_CONSTANT_VALUES[node.op]

    elif isinstance(node, SampleRate):
        vals[nid] = state.sr

    elif isinstance(node, Smoothstep):
        a = ref(node.a)
        e0 = ref(node.edge0)
        e1 = ref(node.edge1)
        rng = e1 - e0
        t = min(max((a - e0) / rng if rng != 0.0 else 0.0, 0.0), 1.0)
        vals[nid] = t * t * (3.0 - 2.0 * t)

    elif isinstance(node, GateRoute):
        idx = int(ref(node.index))
        idx = max(0, min(idx, node.count))
        vals[f"{nid}._idx"] = idx
        vals[f"{nid}._val"] = ref(node.a)

    elif isinstance(node, GateOut):
        idx = vals[f"{node.gate}._idx"]
        val = vals[f"{node.gate}._val"]
        vals[nid] = val if idx == node.channel else 0.0

    elif isinstance(node, Selector):
        idx = int(ref(node.index))
        idx = max(0, min(idx, len(node.inputs)))
        vals[nid] = ref(node.inputs[idx - 1]) if idx > 0 else 0.0


# ---------------------------------------------------------------------------
# Delay interpolation helpers
# ---------------------------------------------------------------------------


def _interp_linear_delay(
    tap: float,
    buf: NDArray[np.float32],
    length: int,
    wr: int,
) -> float:
    itap = int(tap)
    frac = tap - float(itap)
    i0 = (wr - itap) % length
    i1 = (wr - itap - 1) % length
    s0 = float(buf[i0])
    s1 = float(buf[i1])
    return s0 + frac * (s1 - s0)


def _interp_cubic_delay(
    tap: float,
    buf: NDArray[np.float32],
    length: int,
    wr: int,
) -> float:
    itap = int(tap)
    frac = tap - float(itap)
    i0 = (wr - itap) % length
    im1 = (i0 + 1) % length
    i1 = (wr - itap - 1) % length
    i2 = (wr - itap - 2) % length
    ym1 = float(buf[im1])
    y0 = float(buf[i0])
    y1 = float(buf[i1])
    y2 = float(buf[i2])
    c0 = y0
    c1 = 0.5 * (y1 - ym1)
    c2 = ym1 - 2.5 * y0 + 2.0 * y1 - 0.5 * y2
    c3 = 0.5 * (y2 - ym1) + 1.5 * (y0 - y1)
    return ((c3 * frac + c2) * frac + c1) * frac + c0


# ---------------------------------------------------------------------------
# Buffer interpolation helpers
# ---------------------------------------------------------------------------


def _clamp_buf_idx(idx: int, buf_len: int) -> int:
    if idx < 0:
        return 0
    if idx >= buf_len:
        return buf_len - 1
    return idx


def _interp_linear_buf(
    idx: float,
    buf: NDArray[np.float32],
    buf_len: int,
) -> float:
    i0 = int(idx)
    frac = idx - float(i0)
    i1 = i0 + 1
    i0 = _clamp_buf_idx(i0, buf_len)
    i1 = _clamp_buf_idx(i1, buf_len)
    s0 = float(buf[i0])
    s1 = float(buf[i1])
    return s0 + frac * (s1 - s0)


def _interp_cubic_buf(
    idx: float,
    buf: NDArray[np.float32],
    buf_len: int,
) -> float:
    i0 = int(idx)
    frac = idx - float(i0)
    im1 = _clamp_buf_idx(i0 - 1, buf_len)
    i0c = _clamp_buf_idx(i0, buf_len)
    i1 = _clamp_buf_idx(i0 + 1, buf_len)
    i2 = _clamp_buf_idx(i0 + 2, buf_len)
    ym1 = float(buf[im1])
    y0 = float(buf[i0c])
    y1 = float(buf[i1])
    y2 = float(buf[i2])
    c0 = y0
    c1 = 0.5 * (y1 - ym1)
    c2 = ym1 - 2.5 * y0 + 2.0 * y1 - 0.5 * y2
    c3 = 0.5 * (y2 - ym1) + 1.5 * (y0 - y1)
    return ((c3 * frac + c2) * frac + c1) * frac + c0
