"""CurateClient — data curation client."""

from __future__ import annotations

import os
import uuid
from typing import TYPE_CHECKING, ClassVar

from tqdm import tqdm

from radiens_core._base_file_client import BaseFileClient
from radiens_core._converters._dataset import (
    file_type_to_proto,
    guess_file_type,
    protocol_spec_from_proto,
    protocol_spec_list_from_proto,
    protocol_to_proto,
)
from radiens_core._generated import common_pb2
from radiens_core._services._radiens_core import (
    apply_protocol_proto,
    get_all_protocols_proto,
    get_protocol_proto,
    set_protocol_proto,
)
from radiens_core.exceptions import RadiensError
from radiens_core.models.common import ClientType, ServiceEndpoint

if TYPE_CHECKING:
    from collections.abc import Sequence
    from pathlib import Path

    from radiens_core.models.dataset import (
        DatasetMetadata,
        FlexDataset,
        ProtocolSpec,
        RadiensFileType,
        TransformEdge,
        TransformNode,
    )


class CurateClient(BaseFileClient):
    """Client for data curation operations.

    Auto-discovers a running ``radiensserver`` on the local machine.

    Usage::

        with CurateClient() as client:
            meta = client.link_data_file("/path/to/recording.xdat")
            new_meta = client.slice_time(meta, 0, 10, "sliced.xdat")
    """

    _client_type: ClassVar[ClientType] = ClientType.CURATE

    # --- Internal helpers ---

    def _build_linear_protocol(
        self,
        input_dataset: DatasetMetadata,
        transform_node: common_pb2.TransformNode,
        output_path: str | Path,
    ) -> tuple[list[common_pb2.TransformNode], list[common_pb2.TransformEdge]]:
        """Build a standard 3-node linear protocol: Source -> Transform -> Sink."""
        output_path_str = str(output_path)
        base_name = os.path.splitext(os.path.basename(output_path_str))[0]
        dir_path = os.path.dirname(output_path_str) or "."

        source_id = str(uuid.uuid4())
        sink_id = str(uuid.uuid4())

        # NOTE: 'invalid' is a 'required' field in the proto definition.
        # We must set it to False to pass client-side serialization.
        source_node = common_pb2.TransformNode(
            id=source_id,
            type=common_pb2.SOURCE,
            invalid=False,
            datasourceSinkParams=common_pb2.DataSourceSinkTransformParams(
                id=input_dataset.id,
                dsrcName=input_dataset.base_name,
                path=input_dataset.path,
                fileType=file_type_to_proto(input_dataset.file_type),
            ),
        )

        sink_node = common_pb2.TransformNode(
            id=sink_id,
            type=common_pb2.SINK,
            invalid=False,
            datasourceSinkParams=common_pb2.DataSourceSinkTransformParams(
                dsrcName=base_name,
                path=dir_path,
                fileType=file_type_to_proto(guess_file_type(output_path_str)),
            ),
        )

        transform_node.invalid = False

        edges = [
            common_pb2.TransformEdge(id=str(uuid.uuid4()), source=source_id, target=transform_node.id),
            common_pb2.TransformEdge(id=str(uuid.uuid4()), source=transform_node.id, target=sink_id),
        ]

        return [source_node, transform_node, sink_node], edges

    def _execute_protocol(
        self,
        nodes: list[common_pb2.TransformNode],
        edges: list[common_pb2.TransformEdge],
        desc: str = "Processing",
    ) -> None:
        """Submit and execute a protocol with a progress bar."""
        protocol_id = str(uuid.uuid4())
        protocol = common_pb2.Protocol(
            id=protocol_id,
            transformNodes=nodes,
            transformEdges=edges,
        )

        addr = self._server_address(ServiceEndpoint.RADIENS_CORE)
        validated_protocol = set_protocol_proto(self._pool, addr, protocol)

        # Check for validation errors from the server
        for node in validated_protocol.transformNodes:
            if node.invalid:
                raise RadiensError(f"Curation task failed validation at node '{node.id}': {node.errorMessage}")

        req = common_pb2.ProtocolRequest(protocolID=protocol_id)
        stream = apply_protocol_proto(self._pool, addr, req)

        with tqdm(total=100, desc=desc, unit="%") as pbar:
            last_frac = 0.0
            for progress in stream:
                # progress.fracComplete is 0.0 to 1.0
                inc = (progress.fracComplete - last_frac) * 100
                if inc > 0:
                    pbar.update(inc)
                    last_frac = progress.fracComplete
            # Ensure it reaches 100% if the stream finished successfully
            if last_frac < 1.0:
                pbar.update((1.0 - last_frac) * 100)

    # --- Public API ---

    def slice_time(
        self,
        dataset: FlexDataset,
        start_sec: float,
        end_sec: float,
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Slice a dataset in time.

        Args:
            dataset: Dataset to slice (metadata or ID).
            start_sec: Start time in seconds.
            end_sec: End time in seconds.
            output_path: Where to save the result.

        Returns:
            Metadata for the new (sliced) dataset.
        """
        meta = self._resolve_dataset(dataset)

        transform_id = str(uuid.uuid4())
        transform_node = common_pb2.TransformNode(
            id=transform_id,
            type=common_pb2.SLICE_TIME,
            invalid=False,
            sliceTimeParams=common_pb2.SliceTimeTransformParams(
                timeStart=start_sec,
                timeEnd=end_sec,
            ),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Slicing {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def downsample(
        self,
        dataset: FlexDataset,
        factor: int,
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Downsample a dataset.

        Args:
            dataset: Dataset to downsample (metadata or ID).
            factor: Downsampling factor (e.g., 2, 4, 10).
            output_path: Where to save the result.

        Returns:
            Metadata for the new (downsampled) dataset.
        """
        meta = self._resolve_dataset(dataset)

        transform_id = str(uuid.uuid4())
        transform_node = common_pb2.TransformNode(
            id=transform_id,
            type=common_pb2.DOWNSAMPLE,
            invalid=False,
            downsampleParams=common_pb2.DownsampleTransformParams(
                sampleFactor=factor,
            ),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Downsampling {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def highpass(
        self,
        dataset: FlexDataset,
        frequency: float,
        output_path: str | Path,
        *,
        order: int | None = None,
    ) -> DatasetMetadata:
        """Apply a high-pass filter to a dataset.

        Args:
            dataset: Dataset to filter (metadata or ID).
            frequency: Cutoff frequency in Hz.
            output_path: Where to save the result.
            order: Filter order. If ``None``, the server default is used.

        Returns:
            Metadata for the new (filtered) dataset.
        """
        meta = self._resolve_dataset(dataset)

        params = common_pb2.HighLowPassTransformParams(frequency=frequency)
        if order is not None:
            params.order = order

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.HIGHPASS_FILTER,
            invalid=False,
            highLowPassParams=params,
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Highpass {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def lowpass(
        self,
        dataset: FlexDataset,
        frequency: float,
        output_path: str | Path,
        *,
        order: int | None = None,
    ) -> DatasetMetadata:
        """Apply a low-pass filter to a dataset.

        Args:
            dataset: Dataset to filter (metadata or ID).
            frequency: Cutoff frequency in Hz.
            output_path: Where to save the result.
            order: Filter order. If ``None``, the server default is used.

        Returns:
            Metadata for the new (filtered) dataset.
        """
        meta = self._resolve_dataset(dataset)

        params = common_pb2.HighLowPassTransformParams(frequency=frequency)
        if order is not None:
            params.order = order

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.LOWPASS_FILTER,
            invalid=False,
            highLowPassParams=params,
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Lowpass {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def bandpass(
        self,
        dataset: FlexDataset,
        low_freq: float,
        high_freq: float,
        output_path: str | Path,
        *,
        order: int | None = None,
    ) -> DatasetMetadata:
        """Apply a band-pass filter to a dataset.

        Args:
            dataset: Dataset to filter (metadata or ID).
            low_freq: Lower cutoff frequency in Hz.
            high_freq: Upper cutoff frequency in Hz.
            output_path: Where to save the result.
            order: Filter order. If ``None``, the server default is used.

        Returns:
            Metadata for the new (filtered) dataset.
        """
        meta = self._resolve_dataset(dataset)

        params = common_pb2.BandTransformParams(lowFrequency=low_freq, highFrequency=high_freq)
        if order is not None:
            params.order = order

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.BANDPASS_FILTER,
            invalid=False,
            bandParams=params,
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Bandpass {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def bandstop(
        self,
        dataset: FlexDataset,
        low_freq: float,
        high_freq: float,
        output_path: str | Path,
        *,
        order: int | None = None,
    ) -> DatasetMetadata:
        """Apply a band-stop (band-reject) filter to a dataset.

        Args:
            dataset: Dataset to filter (metadata or ID).
            low_freq: Lower cutoff frequency in Hz.
            high_freq: Upper cutoff frequency in Hz.
            output_path: Where to save the result.
            order: Filter order. If ``None``, the server default is used.

        Returns:
            Metadata for the new (filtered) dataset.
        """
        meta = self._resolve_dataset(dataset)

        params = common_pb2.BandTransformParams(lowFrequency=low_freq, highFrequency=high_freq)
        if order is not None:
            params.order = order

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.BANDSTOP_FILTER,
            invalid=False,
            bandParams=params,
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Bandstop {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def notch(
        self,
        dataset: FlexDataset,
        frequency: float,
        bandwidth: float,
        output_path: str | Path,
        *,
        order: int | None = None,
    ) -> DatasetMetadata:
        """Apply a notch filter to a dataset.

        Args:
            dataset: Dataset to filter (metadata or ID).
            frequency: Center frequency to reject, in Hz (e.g., 60.0 for line noise).
            bandwidth: Width of the rejection band in Hz.
            output_path: Where to save the result.
            order: Filter order. If ``None``, the server default is used.

        Returns:
            Metadata for the new (filtered) dataset.
        """
        meta = self._resolve_dataset(dataset)

        params = common_pb2.NotchTransformParams(notchFrequency=frequency, notchBandwidth=bandwidth)
        if order is not None:
            params.order = order

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.NOTCH_FILTER,
            invalid=False,
            notchParams=params,
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Notch {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def car(
        self,
        dataset: FlexDataset,
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Apply Common Average Reference (CAR) re-referencing to a dataset.

        Subtracts the mean signal across all channels from each channel.
        This is the most widely used reference scheme in LFP and spike analysis.

        Args:
            dataset: Dataset to re-reference (metadata or ID).
            output_path: Where to save the result.

        Returns:
            Metadata for the new (re-referenced) dataset.
        """
        meta = self._resolve_dataset(dataset)

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.CAR_REF,
            invalid=False,
            carParams=common_pb2.CARTransformParams(),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"CAR {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def virtual_ref(
        self,
        dataset: FlexDataset,
        ref_channel: int,
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Apply virtual reference re-referencing to a dataset.

        References all channels against a single nominated electrode.

        Args:
            dataset: Dataset to re-reference (metadata or ID).
            ref_channel: Native channel index of the reference electrode.
            output_path: Where to save the result.

        Returns:
            Metadata for the new (re-referenced) dataset.
        """
        meta = self._resolve_dataset(dataset)

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.VIRTUAL_REF,
            invalid=False,
            virtualRefParams=common_pb2.VirtualRefTransformParams(refNtvChanIdx=ref_channel),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"VirtualRef {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def paired_ref(
        self,
        dataset: FlexDataset,
        ref_channel: int,
        target_channel: int,
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Apply paired re-referencing between two specific channels.

        References one target channel against one reference channel.

        Args:
            dataset: Dataset to re-reference (metadata or ID).
            ref_channel: Native channel index of the reference electrode.
            target_channel: Native channel index of the channel to re-reference.
            output_path: Where to save the result.

        Returns:
            Metadata for the new (re-referenced) dataset.
        """
        meta = self._resolve_dataset(dataset)

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.PAIRED_REF,
            invalid=False,
            pairedRefParams=common_pb2.PairedRefTransformParams(
                refNtvChanIdx=ref_channel,
                targetNtvChanIdx=target_channel,
            ),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"PairedRef {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    def slice_channels(
        self,
        dataset: FlexDataset,
        channels: Sequence[int],
        output_path: str | Path,
    ) -> DatasetMetadata:
        """Select a subset of channels from a dataset.

        Args:
            dataset: Dataset to slice (metadata or ID).
            channels: System-wide channel indices to keep.
            output_path: Where to save the result.

        Returns:
            Metadata for the new dataset containing only the selected channels.
        """
        meta = self._resolve_dataset(dataset)

        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.SLICE_CHANNELS,
            invalid=False,
            sliceChannelsParams=common_pb2.SliceChannelsTransformParams(
                sysChanIdxs=channels,
            ),
        )

        nodes, edges = self._build_linear_protocol(meta, transform_node, output_path)
        self._execute_protocol(nodes, edges, desc=f"Slicing channels {os.path.basename(str(output_path))}")

        return self.link_data_file(output_path)

    # --- Protocol Management ---

    def apply_protocol(self, protocol_id: str) -> None:
        """Re-apply a previously registered protocol by ID.

        Args:
            protocol_id: The ID of a protocol already registered with the server
                (e.g., returned from a previous transform call).
        """
        addr = self._server_address(ServiceEndpoint.RADIENS_CORE)
        req = common_pb2.ProtocolRequest(protocolID=protocol_id)
        stream = apply_protocol_proto(self._pool, addr, req)

        with tqdm(total=100, desc="Applying protocol", unit="%") as pbar:
            last_frac = 0.0
            for progress in stream:
                inc = (progress.fracComplete - last_frac) * 100
                if inc > 0:
                    pbar.update(inc)
                    last_frac = progress.fracComplete
            if last_frac < 1.0:
                pbar.update((1.0 - last_frac) * 100)

    def get_protocol(self, protocol_id: str) -> ProtocolSpec:
        """Retrieve a saved protocol by ID.

        Args:
            protocol_id: ID of the protocol to retrieve.

        Returns:
            Minimal summary of the protocol.
        """
        addr = self._server_address(ServiceEndpoint.RADIENS_CORE)
        proto = get_protocol_proto(self._pool, addr, protocol_id)
        return protocol_spec_from_proto(proto)

    def get_all_protocols(self) -> list[ProtocolSpec]:
        """List all saved protocols on the server.

        Returns:
            Summary of every protocol currently registered.
        """
        addr = self._server_address(ServiceEndpoint.RADIENS_CORE)
        reply = get_all_protocols_proto(self._pool, addr)
        return protocol_spec_list_from_proto(reply)

    def set_protocol(
        self,
        nodes: list[TransformNode],
        edges: list[TransformEdge],
        *,
        protocol_id: str | None = None,
    ) -> ProtocolSpec:
        """Register a custom protocol with the server.

        Build a directed acyclic graph of transform nodes and register
        it for later execution with `apply_protocol()`. The server
        validates the graph and returns a summary.

        Use `TransformNode` factory methods to construct nodes, and
        `TransformEdge` to wire them together::

            src  = TransformNode.source(meta)
            hp   = TransformNode.highpass(300.0)
            car  = TransformNode.car()
            snk  = TransformNode.sink("result", "/output", RadiensFileType.XDAT)

            edges = [
                TransformEdge(source=src.id, target=hp.id),
                TransformEdge(source=hp.id, target=car.id),
                TransformEdge(source=car.id, target=snk.id),
            ]

            spec = client.set_protocol([src, hp, car, snk], edges)
            client.apply_protocol(spec.id)

        Args:
            nodes: Ordered list of transform nodes forming the graph.
            edges: Directed edges connecting the nodes by ID.
            protocol_id: Optional protocol ID. A UUID is generated if not provided.

        Returns:
            Summary of the validated (server-registered) protocol.

        Raises:
            RadiensError: If the server marks one or more nodes as invalid.
        """
        addr = self._server_address(ServiceEndpoint.RADIENS_CORE)
        pid = protocol_id or str(uuid.uuid4())
        proto = protocol_to_proto(pid, nodes, edges)
        validated = set_protocol_proto(self._pool, addr, proto)

        for node in validated.transformNodes:
            if node.invalid:
                raise RadiensError(f"Protocol validation failed at node '{node.id}': {node.errorMessage}")

        return protocol_spec_from_proto(validated)

    # --- Bulk Transform Helpers ---

    def _build_bulk_protocol(
        self,
        input_datasets: list[DatasetMetadata],
        transform_node: common_pb2.TransformNode,
        output_dir: str | Path,
        output_suffix: str,
        output_file_type: RadiensFileType,
    ) -> tuple[list[common_pb2.TransformNode], list[common_pb2.TransformEdge]]:
        """Build a bulk 3-node protocol: BulkSource -> Transform -> BulkSink."""
        output_dir_str = str(output_dir)

        bulk_source_id = str(uuid.uuid4())
        bulk_sink_id = str(uuid.uuid4())

        bulk_source_node = common_pb2.TransformNode(
            id=bulk_source_id,
            type=common_pb2.BULK_SOURCE,
            invalid=False,
            bulkDataSourceParams=common_pb2.BulkDataSourceTransformParams(
                srcParams=[
                    common_pb2.DataSourceSinkTransformParams(
                        id=m.id,
                        dsrcName=m.base_name,
                        path=m.path,
                        fileType=file_type_to_proto(m.file_type),
                    )
                    for m in input_datasets
                ]
            ),
        )

        bulk_sink_node = common_pb2.TransformNode(
            id=bulk_sink_id,
            type=common_pb2.BULK_SINK,
            invalid=False,
            bulkDataSinkParams=common_pb2.BulkDataSinkTransformParams(
                suffix=output_suffix,
                path=output_dir_str,
                fileType=file_type_to_proto(output_file_type),
            ),
        )

        transform_node.invalid = False

        edges = [
            common_pb2.TransformEdge(id=str(uuid.uuid4()), source=bulk_source_id, target=transform_node.id),
            common_pb2.TransformEdge(id=str(uuid.uuid4()), source=transform_node.id, target=bulk_sink_id),
        ]

        return [bulk_source_node, transform_node, bulk_sink_node], edges

    def _resolve_bulk_sources(self, sources: Sequence[FlexDataset]) -> list[DatasetMetadata]:
        """Resolve a sequence of FlexDataset to a list of DatasetMetadata."""
        return [self._resolve_dataset(src) for src in sources]

    # --- Bulk Transforms ---

    def bulk_highpass(
        self,
        sources: Sequence[FlexDataset],
        frequency: float,
        output_dir: str | Path,
        output_suffix: str,
        *,
        order: int | None = None,
    ) -> None:
        """Apply high-pass filter to multiple datasets at once.

        Args:
            sources: Datasets to filter (metadata or IDs).
            frequency: Cutoff frequency in Hz.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
            order: Filter order. If ``None``, the server default is used.
        """
        metas = self._resolve_bulk_sources(sources)
        params = common_pb2.HighLowPassTransformParams(frequency=frequency)
        if order is not None:
            params.order = order
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.HIGHPASS_FILTER,
            invalid=False,
            highLowPassParams=params,
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk highpass")

    def bulk_lowpass(
        self,
        sources: Sequence[FlexDataset],
        frequency: float,
        output_dir: str | Path,
        output_suffix: str,
        *,
        order: int | None = None,
    ) -> None:
        """Apply low-pass filter to multiple datasets at once.

        Args:
            sources: Datasets to filter (metadata or IDs).
            frequency: Cutoff frequency in Hz.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
            order: Filter order. If ``None``, the server default is used.
        """
        metas = self._resolve_bulk_sources(sources)
        params = common_pb2.HighLowPassTransformParams(frequency=frequency)
        if order is not None:
            params.order = order
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.LOWPASS_FILTER,
            invalid=False,
            highLowPassParams=params,
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk lowpass")

    def bulk_bandpass(
        self,
        sources: Sequence[FlexDataset],
        low_freq: float,
        high_freq: float,
        output_dir: str | Path,
        output_suffix: str,
        *,
        order: int | None = None,
    ) -> None:
        """Apply band-pass filter to multiple datasets at once.

        Args:
            sources: Datasets to filter (metadata or IDs).
            low_freq: Lower cutoff frequency in Hz.
            high_freq: Upper cutoff frequency in Hz.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
            order: Filter order. If ``None``, the server default is used.
        """
        metas = self._resolve_bulk_sources(sources)
        params = common_pb2.BandTransformParams(lowFrequency=low_freq, highFrequency=high_freq)
        if order is not None:
            params.order = order
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.BANDPASS_FILTER,
            invalid=False,
            bandParams=params,
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk bandpass")

    def bulk_bandstop(
        self,
        sources: Sequence[FlexDataset],
        low_freq: float,
        high_freq: float,
        output_dir: str | Path,
        output_suffix: str,
        *,
        order: int | None = None,
    ) -> None:
        """Apply band-stop filter to multiple datasets at once.

        Args:
            sources: Datasets to filter (metadata or IDs).
            low_freq: Lower cutoff frequency in Hz.
            high_freq: Upper cutoff frequency in Hz.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
            order: Filter order. If ``None``, the server default is used.
        """
        metas = self._resolve_bulk_sources(sources)
        params = common_pb2.BandTransformParams(lowFrequency=low_freq, highFrequency=high_freq)
        if order is not None:
            params.order = order
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.BANDSTOP_FILTER,
            invalid=False,
            bandParams=params,
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk bandstop")

    def bulk_notch(
        self,
        sources: Sequence[FlexDataset],
        notch_freq: float,
        bandwidth: float,
        output_dir: str | Path,
        output_suffix: str,
        *,
        order: int | None = None,
    ) -> None:
        """Apply notch filter to multiple datasets at once.

        Args:
            sources: Datasets to filter (metadata or IDs).
            notch_freq: Center frequency to reject in Hz.
            bandwidth: Width of the rejection band in Hz.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
            order: Filter order. If ``None``, the server default is used.
        """
        metas = self._resolve_bulk_sources(sources)
        params = common_pb2.NotchTransformParams(notchFrequency=notch_freq, notchBandwidth=bandwidth)
        if order is not None:
            params.order = order
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.NOTCH_FILTER,
            invalid=False,
            notchParams=params,
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk notch")

    def bulk_downsample(
        self,
        sources: Sequence[FlexDataset],
        factor: int,
        output_dir: str | Path,
        output_suffix: str,
    ) -> None:
        """Downsample multiple datasets at once.

        Args:
            sources: Datasets to downsample (metadata or IDs).
            factor: Downsampling factor (e.g., 2, 4, 10).
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
        """
        metas = self._resolve_bulk_sources(sources)
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.DOWNSAMPLE,
            invalid=False,
            downsampleParams=common_pb2.DownsampleTransformParams(sampleFactor=factor),
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk downsample")

    def bulk_slice_time(
        self,
        sources: Sequence[FlexDataset],
        start_sec: float,
        end_sec: float,
        output_dir: str | Path,
        output_suffix: str,
    ) -> None:
        """Slice multiple datasets in time at once.

        Args:
            sources: Datasets to slice (metadata or IDs).
            start_sec: Start time in seconds.
            end_sec: End time in seconds.
            output_dir: Directory where output files will be written.
            output_suffix: Suffix appended to each output file's base name.
        """
        metas = self._resolve_bulk_sources(sources)
        transform_node = common_pb2.TransformNode(
            id=str(uuid.uuid4()),
            type=common_pb2.SLICE_TIME,
            invalid=False,
            sliceTimeParams=common_pb2.SliceTimeTransformParams(timeStart=start_sec, timeEnd=end_sec),
        )
        nodes, edges = self._build_bulk_protocol(metas, transform_node, output_dir, output_suffix, metas[0].file_type)
        self._execute_protocol(nodes, edges, desc="Bulk slice time")
