"""AllegoClient — real-time data acquisition client."""

from __future__ import annotations

import logging
import warnings
from typing import TYPE_CHECKING, cast

from pydantic import ConfigDict, validate_call

from radiens_core._base_client import BaseClient
from radiens_core._constants import (
    ALLEGO_SERVICE_DEFAULTS,
    PRIMARY_CACHE_STREAM_GROUP_ID,
)
from radiens_core._converters import validate_proto
from radiens_core._converters._channels import channel_metadata_from_proto
from radiens_core._converters._core_config import build_update_core_config_proto, core_config_from_proto
from radiens_core._converters._dac import (
    analog_out_register_from_proto,
    dac_highpass_to_proto,
    set_dac_gain_to_proto,
    set_dac_off_to_proto,
    set_dac_stream_to_proto,
)
from radiens_core._converters._dio import (
    digital_out_register_from_proto,
    set_dio_events_to_proto,
    set_dio_gated_to_proto,
    set_dio_manual_to_proto,
    set_dio_pulse_to_proto,
)
from radiens_core._converters._dsp import (
    domain_to_proto_dsp_params,
    proto_to_domain_dsp_group,
)
from radiens_core._converters._impedance import impedance_from_proto, impedance_request_to_proto
from radiens_core._converters._kpi import (
    build_bundle_req,
    kpi_metrics_result_from_proto,
    kpi_status_from_proto,
)
from radiens_core._converters._record_state import (
    build_start_recording_proto,
    build_stop_recording_proto,
    record_state_request_to_proto,
)
from radiens_core._converters._recording_config import recording_config_from_proto, recording_config_to_proto
from radiens_core._converters._restart import build_restart_proto
from radiens_core._converters._signals import signal_arrays_from_hd_snapshot
from radiens_core._converters._sinaps import (
    build_flash_sinaps_proto,
    build_load_all_mosi_proto,
    build_read_wire_out_proto,
    sinaps_status_registers_from_proto,
    wire_out_value_from_proto,
)
from radiens_core._converters._status import backbone_status_from_proto
from radiens_core._converters._stim import stim_params_from_proto, stim_params_to_proto
from radiens_core._converters._stim_step import build_stim_step_proto, stim_step_state_from_proto
from radiens_core._converters._stream_state import (
    build_start_streaming_proto,
    build_stop_streaming_proto,
    stream_state_request_to_proto,
)
from radiens_core._converters._trigger_control import (
    build_disable_all_triggers_proto,
    build_disable_trigger_proto,
    build_enable_trigger_proto,
    build_set_trigger_state_proto,
    build_stim_trigger_proto,
    build_stim_trigger_toggle_proto,
    manual_stim_trigger_request_to_proto,
    manual_stim_trigger_toggle_request_to_proto,
    trigger_state_from_proto,
)
from radiens_core._generated import common_pb2
from radiens_core._services._allego_core import (
    flash_sinaps_proto,
    get_config_proto,
    get_dac_reg_proto,
    get_dio_reg_proto,
    get_dsp_group_proto,
    get_intan_impedance_proto,
    get_recording_config_proto,
    get_signal_group_proto,
    get_sinaps_status_registers_proto,
    get_status_proto,
    get_stim_params_proto,
    get_stim_step_proto,
    get_trigger_state_proto,
    healthcheck_proto,
    load_all_mosi_proto,
    manual_stim_trigger_proto,
    manual_stim_trigger_toggle_proto,
    read_wire_out_proto,
    restart_proto,
    scan_ports_proto,
    set_config_core_proto,
    set_dac_gain_proto,
    set_dac_highpass_proto,
    set_dac_off_proto,
    set_dac_stream_proto,
    set_dio_events_proto,
    set_dio_gated_proto,
    set_dio_manual_proto,
    set_dio_pulse_proto,
    set_dsp_group_proto,
    set_record_state_proto,
    set_recording_config_proto,
    set_stim_params_proto,
    set_stim_step_proto,
    set_stream_state_proto,
    set_trigger_state_proto,
    transmit_mosi_proto,
)
from radiens_core._services._allego_kpi import (
    get_kpi_status_allego_proto,
    kpi_get_metrics_allego_proto,
    set_kpi_packet_dur_allego_proto,
    set_kpi_update_period_proto,
)
from radiens_core._services._allego_pcache import get_hd_snapshot_proto
from radiens_core._typing_helpers import validated
from radiens_core.exceptions import RadiensError
from radiens_core.models.common import ServiceEndpoint
from radiens_core.models.dac import DACHighPass
from radiens_core.models.dataset import FlexTimeRange, TimeRangeSpec, TrsMode
from radiens_core.models.impedance import Impedance
from radiens_core.models.kpi import KpiMetricId, KpiMetricsResult, KpiStatus
from radiens_core.models.ports import Port
from radiens_core.models.signals import FlexSignalSpec, SignalArrays, SignalSpec, SignalType
from radiens_core.models.triggers import TriggerChannel

if TYPE_CHECKING:
    from radiens_core.models.channels import ChannelMetadata
    from radiens_core.models.core_config import CableDelay, CoreConfig, SetCoreConfigRequest
    from radiens_core.models.dac import AnalogOutRegister
    from radiens_core.models.dio import DigitalOutRegister
    from radiens_core.models.dsp import DSPGroup, DSPParams, FilterStage
    from radiens_core.models.status import (
        BackboneMode,
        BackboneStatus,
        RecordingConfig,
        RecordStateRequest,
        SinapsStatusRegisters,
        StreamStateRequest,
    )
    from radiens_core.models.stim import StimKeypressIndex, StimParams, StimStep, StimStepState
    from radiens_core.models.triggers import (
        ManualStimTriggerRequest,
        ManualStimTriggerToggleRequest,
        TriggerState,
    )

_log = logging.getLogger(__name__)


def _time_range_floats(tr_spec: TimeRangeSpec) -> list[float]:
    """Convert a TimeRangeSpec to [start, end_or_nan] for HDSnapshotRequest.timeRange.

    FROM_HEAD: [duration_sec, nan] — server reads back ``duration`` seconds from cache head.
    SUBSET:    [start_sec, end_sec].
    TO_HEAD:   [start_sec, nan].
    """
    if tr_spec.mode == TrsMode.SUBSET:
        return [tr_spec.start, tr_spec.end if tr_spec.end is not None else float("nan")]
    return [tr_spec.start, float("nan")]


class AllegoClient(BaseClient):
    """Client for real-time data acquisition and streaming.

    Auto-discovers running servers.  Falls back to well-known allego
    ports if server discovery is unavailable (no radiensserver running).

    Usage::

        with AllegoClient() as client:
            params = client.get_stim_params()
    """

    def __init__(self) -> None:
        addresses = self._discover()
        core_addr = addresses[ServiceEndpoint.ALLEGO_CORE]
        super().__init__(
            addresses=addresses,
            auth_server_address=core_addr,
            fallback_service=ServiceEndpoint.ALLEGO_CORE,
        )

    @staticmethod
    def _discover() -> dict[str, str]:
        return dict(ALLEGO_SERVICE_DEFAULTS)

    # --- Internal helpers ---

    @property
    def _core_address(self) -> str:
        return self._server_address(ServiceEndpoint.ALLEGO_CORE)

    @property
    def _pcache_address(self) -> str:
        return self._server_address(ServiceEndpoint.ALLEGO_PCACHE)

    @property
    def _kpi_address(self) -> str:
        return self._server_address(ServiceEndpoint.ALLEGO_KPI)

    # --- Public API ---

    def get_stim_params(self) -> dict[int, StimParams]:
        """Get stimulation parameters for all configured channels.

        Returns:
            Stimulation parameters keyed by system channel index (ntvAbsKeyIdx).

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = get_stim_params_proto(self._pool, self._core_address)
        return stim_params_from_proto(response)

    def get_status(self) -> BackboneStatus:
        """Get comprehensive system status.

        Returns:
            Current streaming status, recording status, port information,
            connection state, and GPIO channel counts.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = get_status_proto(self._pool, self._core_address)
        return backbone_status_from_proto(response)

    def get_dsp_group(self, stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID) -> DSPGroup:
        """Get current DSP filter configurations.

        Args:
            stream_group_id: The stream group to query (default ``"default"``).

        Returns:
            The current hardware and software filter parameters.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = get_dsp_group_proto(self._pool, self._core_address, stream_group_id)
        return proto_to_domain_dsp_group(response)

    def set_dsp_group(
        self,
        *,
        stage: FilterStage,
        params: list[DSPParams],
        stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID,
    ) -> None:
        """Update DSP filter configuration for a specific stage.

        The server applies per-stage partial updates rather than replacing the full
        `DSPGroup`. Use `get_dsp_group()` to read the current state before making
        targeted changes.

        Args:
            stage: The filter stage to update (e.g. ``FilterStage.STAGE1``).
            params: Filter parameters for this stage.
            stream_group_id: The stream group to update (default ``"default"``).
        """
        req = common_pb2.SetDSPGroupRequest(
            streamGroupId=stream_group_id,
            stage=cast("common_pb2.FilterStage.ValueType", stage.value),
            dspParams=[domain_to_proto_dsp_params(p) for p in params],
        )
        validate_proto(req)
        set_dsp_group_proto(self._pool, self._core_address, req)

    def get_channel_metadata(self) -> ChannelMetadata:
        """Get current channel metadata for all connected channels.

        Returns:
            Complete channel metadata including probe geometry, signal types,
            channel indices, and site positions for all channels.

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_signal_group_proto(self._pool, self._core_address)
        return channel_metadata_from_proto(response)

    @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
    def get_signals(
        self,
        time_range: FlexTimeRange,
        signals: FlexSignalSpec | None = None,
    ) -> SignalArrays:
        """Retrieve signal data from the primary live cache.

        Args:
            time_range: Time range spec. Most common: ``TimeRangeSpec.lookback(5.0)`` or
                shorthand ``[duration, float('nan')]``. Also accepts ``[start, end]``
                for absolute SUBSET requests.
            signals: Channel selection — list of amp indices, ``SignalSpec.amp([...])``,
                or dict for multi-type. ``None`` returns all amplifier channels.

        Returns:
            Signal data as numpy arrays (channels x samples).

        Raises:
            RadiensError: On communication failure or server error.
        """
        tr_spec = validated(time_range, TimeRangeSpec)
        sig_spec = validated(signals, SignalSpec) if signals is not None else SignalSpec.all_amp()

        channel_meta = self.get_channel_metadata()
        sig_select = sig_spec.to_sig_select(channel_meta)

        def _sel(idxs: list[int]) -> common_pb2.HDSnapshotRequest.SelectedSignals | None:
            return common_pb2.HDSnapshotRequest.SelectedSignals(ntvChanIdxs=idxs) if idxs else None

        req = common_pb2.HDSnapshotRequest(
            streamGroupId=PRIMARY_CACHE_STREAM_GROUP_ID,
            timeRange=_time_range_floats(tr_spec),
            priSignals=_sel(sig_select.amp.tolist()),
            auxSignals=_sel(sig_select.gpio_ain.tolist()),
            dinSignals=_sel(sig_select.gpio_din.tolist()),
            doutSignals=_sel(sig_select.gpio_dout.tolist()),
        )

        response = get_hd_snapshot_proto(self._pool, self._pcache_address, req)
        return signal_arrays_from_hd_snapshot(response)

    def start_streaming(self) -> None:
        """Start data streaming from the hardware.

        Raises:
            RadiensError: If the stream state change fails.
        """
        response = set_stream_state_proto(self._pool, self._core_address, build_start_streaming_proto())
        if response.errMsg:
            raise RadiensError(f"Failed to start streaming: {response.errMsg}")

    def stop_streaming(self) -> None:
        """Stop data streaming from the hardware.

        Raises:
            RadiensError: If the stream state change fails.
        """
        response = set_stream_state_proto(self._pool, self._core_address, build_stop_streaming_proto())
        if response.errMsg:
            raise RadiensError(f"Failed to stop streaming: {response.errMsg}")

    def set_stream_state(self, request: StreamStateRequest) -> None:
        """Set data streaming state.

        Deprecated:
            Use `start_streaming()` or `stop_streaming()` instead.

        Args:
            request: Stream state configuration specifying the desired mode

        Raises:
            RadiensError: If the stream state change fails
        """
        warnings.warn(
            "set_stream_state() is deprecated. Use start_streaming() or stop_streaming() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        proto_request = stream_state_request_to_proto(request)
        response = set_stream_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Failed to set stream state: {response.errMsg}")

    def healthcheck(self, service: ServiceEndpoint = ServiceEndpoint.ALLEGO_LIFECYCLE) -> None:
        """Perform lightweight system health verification.

        Args:
            service: Which gRPC service to probe. Defaults to ``ALLEGO_LIFECYCLE``;
                ``ALLEGO_CORE`` is also supported.

        Raises:
            RadiensError: If the health check fails or the server is unresponsive.
        """
        response = healthcheck_proto(self._pool, self._server_address(service), service=service)
        if response.errMsg:
            raise RadiensError(f"Health check failed: {response.errMsg}")

    def start_recording(self, *, port: Port | None = None) -> None:
        """Start recording data to disk.

        Args:
            port: Optional port to start recording on. If omitted, applies system-level control.

        Raises:
            RadiensError: If the record state change fails.
        """
        response = set_record_state_proto(self._pool, self._core_address, build_start_recording_proto(port))
        if response.errMsg:
            raise RadiensError(f"Failed to start recording: {response.errMsg}")

    def stop_recording(self, *, port: Port | None = None) -> None:
        """Stop recording data to disk.

        Args:
            port: Optional port to stop recording on. If omitted, applies system-level control.

        Raises:
            RadiensError: If the record state change fails.
        """
        response = set_record_state_proto(self._pool, self._core_address, build_stop_recording_proto(port))
        if response.errMsg:
            raise RadiensError(f"Failed to stop recording: {response.errMsg}")

    def set_record_state(self, request: RecordStateRequest) -> None:
        """Set data recording state.

        Deprecated:
            Use `start_recording()` or `stop_recording()` instead.

        Args:
            request: Record state configuration specifying the desired mode and optional port

        Raises:
            RadiensError: If the record state change fails
        """
        warnings.warn(
            "set_record_state() is deprecated. Use start_recording() or stop_recording() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        proto_request = record_state_request_to_proto(request)
        response = set_record_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Failed to set record state: {response.errMsg}")

    def restart(self, *, mode: BackboneMode) -> None:
        """Restart the system with the specified backbone mode.

        Args:
            mode: The backbone hardware mode to restart with.

        Raises:
            RadiensError: If the restart fails.
        """
        response = restart_proto(self._pool, self._core_address, build_restart_proto(mode))
        if response.errMsg:
            raise RadiensError(f"System restart failed: {response.errMsg}")

    def set_stim_params(self, params: dict[int, StimParams]) -> None:
        """Update stimulation parameters for specified channels.

        Args:
            params: Map from system channel indices to their new stimulation parameters.
                   Each StimParams defines waveform shape, timing, amplitudes, trigger
                   settings, pulse trains, and recovery parameters for that channel.

        Raises:
            RadiensError: On communication failure or server error

        Note:
            The underlying gRPC API sets parameters for one channel at a time.
            This method calls SetStimParams once per channel in the provided dictionary.

        Usage:
            Update stimulation parameters for multiple channels::

                # Define stimulation parameters
                stim_params = StimParams(
                    stim_shape=StimShape.BIPHASIC,
                    stim_polarity=StimPolarity.CATHODIC_FIRST,
                    first_phase_duration=100.0,  # 100 µs
                    second_phase_duration=100.0,  # 100 µs
                    interphase_delay=50.0,  # 50 µs
                    first_phase_amplitude=100.0,  # 100 µA
                    second_phase_amplitude=-100.0,  # 100 µA (opposite polarity)
                    baseline_voltage=0.0,
                    trigger_edge_or_level=StimTriggerEdge.EDGE,
                    trigger_high_or_low=StimTriggerLevel.HIGH,
                    enabled=True,
                    post_trigger_delay=1000.0,  # 1 ms = 1000 µs
                    pulse_or_train=StimPulseMode.SINGLE_PULSE,
                    number_of_stim_pulses=1,
                    pulse_train_period=20000.0,  # 20 ms = 50 Hz
                    refractory_period=10000.0,  # 10 ms
                    pre_stim_amp_settle=1000.0,  # 1 ms
                    post_stim_amp_settle=1000.0,  # 1 ms
                    maintain_amp_settle=False,
                    enable_amp_settle=True,
                    post_stim_charge_recov_on=1000.0,  # 1 ms
                    post_stim_charge_recov_off=0.0,
                    enable_charge_recovery=True,
                    trigger_source_is_keypress=False,
                    trigger_source_idx=0,
                    stim_sys_chan_idx=42,
                )

                # Update channels 42 and 43
                client.set_stim_params({
                    42: stim_params,
                    43: stim_params.model_copy(update={'first_phase_amplitude': 150.0}),
                })
        """

        if not params:
            msg = "params cannot be empty - must specify at least one channel"
            raise RadiensError(msg)

        # SetStimParams works on one channel at a time
        # Validate and process each channel
        for sys_chan_idx, stim_params in params.items():
            # Ensure the sys_chan_idx matches the one in the StimParams
            if stim_params.stim_sys_chan_idx != sys_chan_idx:
                # Create updated stim_params with correct sys_chan_idx
                updated_params = stim_params.model_copy(update={"stim_sys_chan_idx": sys_chan_idx})
            else:
                updated_params = stim_params

            proto_request = stim_params_to_proto(updated_params)
            response = set_stim_params_proto(self._pool, self._core_address, proto_request)

            # Check for errors in the response
            if response.errMsg:
                raise RadiensError(f"Set stimulation parameters failed for channel {sys_chan_idx}: {response.errMsg}")

    def enable_trigger(self, *, channel: TriggerChannel, port: Port | None = None) -> None:
        """Enable a digital trigger channel to start or stop recording.

        When enabled, a voltage transition on *channel* will automatically
        start or stop recording on the hardware.

        Args:
            channel: The digital input/output channel to use as a trigger.
                Must not be ``TriggerChannel.T_NONE`` — use
                :meth:`disable_all_triggers` to disable all triggers at once.
            port: Hardware port the channel belongs to. Only meaningful when
                the hardware is configured for split-ports mode
                (``SplitFilesByPort``). In standard single-port mode the
                server ignores this value and controls recording across all
                ports, so ``None`` is the correct default.

        Raises:
            ValueError: If ``channel`` is ``TriggerChannel.T_NONE``.
            RadiensError: On communication failure or server error.
        """
        if channel is TriggerChannel.T_NONE:
            raise ValueError(
                "T_NONE is not a valid trigger channel. Use disable_all_triggers() to disable all triggers."
            )
        proto_request = build_enable_trigger_proto(channel, port)
        response = set_trigger_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Enable trigger failed: {response.errMsg}")

    def disable_trigger(self, *, channel: TriggerChannel, port: Port | None = None) -> None:
        """Disable a specific digital trigger channel.

        Args:
            channel: The digital input/output channel to disable.
                Must not be ``TriggerChannel.T_NONE`` — use
                :meth:`disable_all_triggers` to disable all triggers at once.
            port: Hardware port the channel belongs to. Only meaningful in
                split-ports mode. See :meth:`enable_trigger` for details.

        Raises:
            ValueError: If ``channel`` is ``TriggerChannel.T_NONE``.
            RadiensError: On communication failure or server error.
        """
        if channel is TriggerChannel.T_NONE:
            raise ValueError(
                "T_NONE is not a valid trigger channel. Use disable_all_triggers() to disable all triggers."
            )
        proto_request = build_disable_trigger_proto(channel, port)
        response = set_trigger_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Disable trigger failed: {response.errMsg}")

    def disable_all_triggers(self) -> None:
        """Disable all trigger channels simultaneously.

        Sends the ``T_NONE`` sentinel to the hardware, which instructs it to
        clear all active trigger channel assignments.

        Raises:
            RadiensError: On communication failure or server error.
        """
        proto_request = build_disable_all_triggers_proto()
        response = set_trigger_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Disable all triggers failed: {response.errMsg}")

    def set_trigger_state(self, *, channel: TriggerChannel, enabled: bool, port: Port) -> None:
        """Set trigger channel state for stimulation control.

        .. deprecated::
            Use :meth:`enable_trigger`, :meth:`disable_trigger`, or
            :meth:`disable_all_triggers` instead.

        Args:
            channel: Trigger channel identifier.
            enabled: Whether the channel should be enabled.
            port: Hardware port identifier.

        Raises:
            RadiensError: On communication failure or server error.
        """
        warnings.warn(
            "set_trigger_state() is deprecated. Use enable_trigger(), disable_trigger(), "
            "or disable_all_triggers() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        proto_request = build_set_trigger_state_proto(channel=channel, enabled=enabled, port=port)
        response = set_trigger_state_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Set trigger state failed: {response.errMsg}")

    def get_trigger_state(self) -> TriggerState:
        """Get current trigger channel configuration.

        Returns:
            Current trigger state showing enabled channels and ports

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_trigger_state_proto(self._pool, self._core_address)
        return trigger_state_from_proto(response)

    def set_stim_trigger(self, *, trigger: StimKeypressIndex, on: bool) -> None:
        """Set the state of a stimulation keypress trigger.

        Args:
            trigger: The keypress trigger index (1-8).
            on: True to activate the trigger, False to deactivate.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = manual_stim_trigger_proto(self._pool, self._core_address, build_stim_trigger_proto(trigger, on))
        if response.errMsg:
            raise RadiensError(f"Set stim trigger failed: {response.errMsg}")

    def toggle_stim_trigger(self, *, trigger: StimKeypressIndex) -> None:
        """Toggle a stimulation keypress trigger.

        Args:
            trigger: The keypress trigger index (1-8) to toggle.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = manual_stim_trigger_toggle_proto(
            self._pool, self._core_address, build_stim_trigger_toggle_proto(trigger)
        )
        if response.errMsg:
            raise RadiensError(f"Toggle stim trigger failed: {response.errMsg}")

    def manual_stim_trigger(self, request: ManualStimTriggerRequest) -> None:
        """Manually trigger stimulation.

        Deprecated:
            Use `set_stim_trigger()` instead.

        Args:
            request: Manual trigger configuration

        Raises:
            RadiensError: On communication failure or server error
        """
        warnings.warn(
            "manual_stim_trigger() is deprecated. Use set_stim_trigger() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        proto_request = manual_stim_trigger_request_to_proto(request)
        response = manual_stim_trigger_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Manual stim trigger failed: {response.errMsg}")

    def manual_stim_trigger_toggle(self, request: ManualStimTriggerToggleRequest) -> None:
        """Toggle manual stimulation trigger.

        Deprecated:
            Use `toggle_stim_trigger()` instead.

        Args:
            request: Manual trigger toggle configuration

        Raises:
            RadiensError: On communication failure or server error
        """
        warnings.warn(
            "manual_stim_trigger_toggle() is deprecated. Use toggle_stim_trigger() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        proto_request = manual_stim_trigger_toggle_request_to_proto(request)
        response = manual_stim_trigger_toggle_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Manual stim trigger toggle failed: {response.errMsg}")

    def set_stim_step(self, *, step: StimStep) -> None:
        """Set the stimulation step size.

        Args:
            step: The stimulation step size to set.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = set_stim_step_proto(self._pool, self._core_address, build_stim_step_proto(step))
        if response.errMsg:
            raise RadiensError(f"Set stim step failed: {response.errMsg}")

    def get_stim_step(self) -> StimStepState:
        """Get current stimulation step size setting.

        Returns:
            Current stimulation step configuration

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_stim_step_proto(self._pool, self._core_address)
        return stim_step_state_from_proto(response)

    def update_recording_config(self, config: RecordingConfig) -> None:
        """Update recording configuration settings.

        The server applies the provided config as a partial update — only the
        fields present in ``config`` are changed. Retrieve the current config
        with ``get_recording_config()``, modify the fields you need, and pass
        it back here.

        Args:
            config: Recording configuration specifying file paths, naming,
                and recording type (continuous/spikes/both).

        Raises:
            RadiensError: On communication failure or server error.

        Example:
            Update the output directory while keeping other settings::

                config = client.get_recording_config()
                config = config.model_copy(update={"base_file_path": "/new/path"})
                client.update_recording_config(config)
        """
        proto_request = recording_config_to_proto(config)
        response = set_recording_config_proto(self._pool, self._core_address, proto_request)
        if response.errMsg:
            raise RadiensError(f"Failed to update recording config: {response.errMsg}")

    def set_recording_config(self, config: RecordingConfig) -> None:
        """Set recording configuration settings.

        Deprecated:
            Use ``update_recording_config()`` instead.

        Args:
            config: Recording configuration.

        Raises:
            RadiensError: On communication failure or server error.
        """
        warnings.warn(
            "set_recording_config() is deprecated. Use update_recording_config() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.update_recording_config(config)

    def get_core_config(self) -> CoreConfig:
        """Get core hardware configuration settings.

        Returns:
            Current core hardware configuration including sampling frequency,
            loop duration, and cable delays.

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_config_proto(self._pool, self._core_address)
        return core_config_from_proto(response)

    def update_core_config(
        self,
        *,
        samp_freq: float | None = None,
        loop_dur: float | None = None,
        pcache_persistence: float | None = None,
        cable_delay: CableDelay | None = None,
    ) -> None:
        """Update core hardware configuration settings.

        All parameters are optional — only the fields you supply are changed.
        Fields left as ``None`` are not sent to the server and remain unchanged.

        Args:
            samp_freq: Target sampling frequency in Hz.
            loop_dur: Target stream loop duration in milliseconds.
            pcache_persistence: PCache persistence duration.
            cable_delay: Cable delay configuration for a specific port.

        Raises:
            RadiensError: On communication failure or server error.
        """
        proto_req = build_update_core_config_proto(
            samp_freq=samp_freq,
            loop_dur=loop_dur,
            pcache_persistence=pcache_persistence,
            cable_delay=cable_delay,
        )
        response = set_config_core_proto(self._pool, self._core_address, proto_req)
        if response.errMsg:
            raise RadiensError(f"Failed to update core config: {response.errMsg}")

    def set_core_config(self, request: SetCoreConfigRequest) -> None:
        """Update core hardware configuration settings.

        Deprecated:
            Use ``update_core_config()`` instead.

        Args:
            request: Configuration parameters to update.

        Raises:
            RadiensError: On communication failure or server error.
        """
        warnings.warn(
            "set_core_config() is deprecated. Use update_core_config() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.update_core_config(
            samp_freq=request.samp_freq,
            loop_dur=request.loop_dur,
            pcache_persistence=request.pcache_persistence,
            cable_delay=request.cable_delay,
        )

    def get_recording_config(self) -> RecordingConfig:
        """Get current recording configuration settings.

        Returns:
            Current recording configuration including file paths, naming,
            and recording type settings

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_recording_config_proto(self._pool, self._core_address)
        return recording_config_from_proto(response)

    # ===================================================================
    # Hardware Diagnostics
    # ===================================================================

    @validate_call
    def get_intan_impedance(self, port: Port) -> Impedance:
        """Measure impedance for all channels on a specific port.

        Args:
            port: The hardware port to measure (e.g., Port.A)

        Returns:
            Impedance measurements for all channels on the specified port.

        Raises:
            RadiensError: On communication failure or server error
        """
        proto_req = impedance_request_to_proto(port)
        response = get_intan_impedance_proto(self._pool, self._core_address, proto_req)
        return impedance_from_proto(response)

    def scan_ports(self) -> ChannelMetadata:
        """Scan all hardware ports to determine connected headstages and probes.

        Returns:
            Updated channel metadata based on the newly discovered hardware.

        Raises:
            RadiensError: On communication failure or server error
        """
        response = scan_ports_proto(self._pool, self._core_address)
        return channel_metadata_from_proto(response)

    def get_dac_reg(self) -> AnalogOutRegister:
        """Get current DAC (Analog Output) register settings.

        Returns:
            The complete DAC register state, including gain, high-pass
            filter configuration, and channel mappings.

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_dac_reg_proto(self._pool, self._core_address)
        return analog_out_register_from_proto(response)

    @validate_call
    def set_dac_gain(self, gain: int) -> None:
        """Set DAC (Analog Output) gain.

        Args:
            gain: The gain value to set.

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dac_gain_to_proto(gain)
        response = set_dac_gain_proto(self._pool, self._core_address, request)
        validate_proto(response)

    @validate_call
    def set_dac_stream(self, dac_idx: int, ntv_chan_idx: int) -> None:
        """Map a source channel to a DAC (Analog Output) channel.

        Args:
            dac_idx: The DAC channel index to configure.
            ntv_chan_idx: The source channel index to output.

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dac_stream_to_proto(dac_idx, ntv_chan_idx, PRIMARY_CACHE_STREAM_GROUP_ID)
        response = set_dac_stream_proto(self._pool, self._core_address, request)
        validate_proto(response)

    def set_dac_off(self) -> None:
        """Turn off all DAC (Analog Output) streams.

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dac_off_to_proto(PRIMARY_CACHE_STREAM_GROUP_ID)
        response = set_dac_off_proto(self._pool, self._core_address, request)
        validate_proto(response)

    @validate_call
    def set_dac_highpass(self, enable: bool, cutoff_freq: float) -> None:
        """Configure DAC (Analog Output) high-pass filter.

        Args:
            enable: Whether to enable the high-pass filter.
            cutoff_freq: The cutoff frequency in Hz.

        Raises:
            RadiensError: On communication failure or server error
        """
        request = dac_highpass_to_proto(DACHighPass(enable=enable, cutoff_freq=cutoff_freq))
        response = set_dac_highpass_proto(self._pool, self._core_address, request)
        validate_proto(response)

    # ===================================================================
    # Digital Output (DIO) Control
    # ===================================================================

    def get_dio_reg(self) -> DigitalOutRegister:
        """Get current DIO (Digital Output) register settings.

        Returns:
            The complete DIO register state, including current mode and
            per-channel configurations.

        Raises:
            RadiensError: On communication failure or server error
        """
        response = get_dio_reg_proto(self._pool, self._core_address)
        return digital_out_register_from_proto(response)

    @validate_call
    def set_dio_events(self, chan_idx: int, threshold: float, polarity: bool) -> None:
        """Configure DIO channel to trigger on analog threshold crossings.

        Args:
            chan_idx: Native channel index.
            threshold: Event threshold in microvolts (μV).
            polarity: Event polarity (True for positive-going, False for negative-going).

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dio_events_to_proto(chan_idx, threshold, polarity)
        response = set_dio_events_proto(self._pool, self._core_address, request)
        validate_proto(response)

    @validate_call
    def set_dio_manual(self, chan_idx: int, state: bool) -> None:
        """Set DIO channel state manually.

        Note:
            The DIO mode must be set to MANUAL for this to take effect.

        Args:
            chan_idx: Native channel index.
            state: Desired state (True for high, False for low).

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dio_manual_to_proto(chan_idx, state)
        response = set_dio_manual_proto(self._pool, self._core_address, request)
        validate_proto(response)

    @validate_call
    def set_dio_gated(self, polarity: bool) -> None:
        """Set DIO gated mode polarity.

        Args:
            polarity: Gated polarity (True for high-active, False for low-active).

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dio_gated_to_proto(polarity)
        response = set_dio_gated_proto(self._pool, self._core_address, request)
        validate_proto(response)

    @validate_call
    def set_dio_pulse(self, interval_sec: float, duration_sec: float, chan_idx: int = -1) -> None:
        """Configure DIO channel(s) for pulse mode.

        Args:
            interval_sec: Pulse interval in seconds.
            duration_sec: Pulse duration in seconds.
            chan_idx: Native channel index (-1 for all channels).

        Raises:
            RadiensError: On communication failure or server error
        """
        request = set_dio_pulse_to_proto(interval_sec, duration_sec, chan_idx)
        response = set_dio_pulse_proto(self._pool, self._core_address, request)
        validate_proto(response)

    # ===================================================================
    # KPI Metrics
    # ===================================================================

    @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
    def get_kpi_metrics(
        self,
        metrics: list[KpiMetricId],
        channel_indices: list[int] | None = None,
        time_range: FlexTimeRange | None = None,
        signal_type: SignalType = SignalType.AMP,
        stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID,
    ) -> KpiMetricsResult:
        """Retrieve computed KPI signal metrics from the live cache.

        Args:
            metrics: List of KPI metric identifiers to compute.
            channel_indices: Native channel indices (ntvIdxs) to query.
                ``None`` returns metrics for all channels.
            time_range: Time range spec. Use ``[duration, float('nan')]`` for a lookback
                window or ``[start, end]`` for an absolute range.
                ``None`` defaults to a 5-second lookback window.
            signal_type: Signal type to query (default ``AMP``).
            stream_group_id: Stream group to query (default ``"default"``).

        Returns:
            Metric values as a ``(n_channels, n_metrics)`` numpy array
            with row/column labels.

        Raises:
            RadiensError: On communication failure or server error.
        """
        tr_spec = validated(time_range, TimeRangeSpec) if time_range is not None else TimeRangeSpec.lookback(5.0)
        chan_list = channel_indices if channel_indices is not None else []
        req = common_pb2.KpiMetricsReq(
            streamGroupId=stream_group_id,
            stype=cast("common_pb2.SignalType.ValueType", signal_type.value),
            arg=build_bundle_req(chan_list, _time_range_floats(tr_spec), metrics),
        )
        response = kpi_get_metrics_allego_proto(self._pool, self._kpi_address, req)
        return kpi_metrics_result_from_proto(response)

    def get_kpi_status(
        self,
        stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID,
    ) -> KpiStatus:
        """Get the current KPI cache status.

        Args:
            stream_group_id: Stream group to query (default ``"default"``).

        Returns:
            Number of packets in cache, packet duration, update period,
            memory usage, and tracking state.

        Raises:
            RadiensError: On communication failure or server error.
        """
        req = common_pb2.GetKpiStatusRequest(streamGroupId=stream_group_id)
        response = get_kpi_status_allego_proto(self._pool, self._kpi_address, req)
        return kpi_status_from_proto(response)

    def set_kpi_packet_dur(
        self,
        packet_dur: float,
        stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID,
    ) -> None:
        """Set the KPI packet duration (integration window).

        Args:
            packet_dur: Duration of each KPI packet in seconds.
            stream_group_id: Stream group to configure (default ``"default"``).

        Raises:
            RadiensError: On communication failure or server error.
        """
        req = common_pb2.SetKpiParamRequest(streamGroupId=stream_group_id, param=packet_dur)
        response = set_kpi_packet_dur_allego_proto(self._pool, self._kpi_address, req)
        if response.errMsg:
            raise RadiensError(f"Failed to set KPI packet duration: {response.errMsg}")

    def set_kpi_update_period(
        self,
        update_period: float,
        stream_group_id: str = PRIMARY_CACHE_STREAM_GROUP_ID,
    ) -> None:
        """Set how often the KPI cache is refreshed from the live signal stream.

        Args:
            update_period: Update interval in seconds.
            stream_group_id: Stream group to configure (default ``"default"``).

        Raises:
            RadiensError: On communication failure or server error.
        """
        req = common_pb2.SetKpiParamRequest(streamGroupId=stream_group_id, param=update_period)
        response = set_kpi_update_period_proto(self._pool, self._kpi_address, req)
        if response.errMsg:
            raise RadiensError(f"Failed to set KPI update period: {response.errMsg}")

    # ===================================================================
    # Sinaps
    # ===================================================================

    def load_all_mosi(self, register_vals: list[int]) -> None:
        """Load all MOSI register values into the Sinaps probe.

        Args:
            register_vals: List of 32-bit register values to load.

        Raises:
            RadiensError: On communication failure or server error.
        """
        request = build_load_all_mosi_proto(register_vals)
        response = load_all_mosi_proto(self._pool, self._core_address, request)
        validate_proto(response)

    def transmit_mosi(self) -> None:
        """Transmit the loaded MOSI registers to the Sinaps probe.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = transmit_mosi_proto(self._pool, self._core_address)
        validate_proto(response)

    def read_wire_out(self, wire_out_address: int) -> int:
        """Read a wire-out register value from the Sinaps FPGA.

        Args:
            wire_out_address: Address of the wire-out register to read.

        Returns:
            Integer value read from the specified wire-out register.

        Raises:
            RadiensError: On communication failure or server error.
        """
        request = build_read_wire_out_proto(wire_out_address)
        response = read_wire_out_proto(self._pool, self._core_address, request)
        return wire_out_value_from_proto(response)

    def get_sinaps_status_registers(self) -> SinapsStatusRegisters:
        """Get the current Sinaps probe status registers.

        Returns:
            Current status registers including calibration state, active pixel
            counts per shank, reference voltages, error flags, and probe
            identification information.

        Raises:
            RadiensError: On communication failure or server error.
        """
        response = get_sinaps_status_registers_proto(self._pool, self._core_address)
        return sinaps_status_registers_from_proto(response)

    def flash_sinaps(self, bitfile: str) -> None:
        """Flash new firmware to the Sinaps probe FPGA.

        Args:
            bitfile: Path to the FPGA bitfile to flash.

        Raises:
            RadiensError: On communication failure or server error.
        """
        request = build_flash_sinaps_proto(bitfile)
        response = flash_sinaps_proto(self._pool, self._core_address, request)
        validate_proto(response)
