"""Tests for CurateClient."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Generator
from unittest.mock import MagicMock, patch

import pytest

from radiens_core._generated import common_pb2
from radiens_core.curate_client import CurateClient
from radiens_core.models.dataset import (
    CARParams,
    HighLowPassParams,
    ProtocolSpec,
    RadiensFileType,
    SinkParams,
    SourceParams,
    TransformEdge,
    TransformNode,
    TransformNodeType,
)


@pytest.fixture
def mock_discovery() -> Generator[Any, None, None]:  # type: ignore[explicit-any]
    """Mock server discovery."""
    with patch("radiens_core._base_file_client.discover_radiens_addresses") as mock:
        mock.return_value = {
            "RADIENS_CORE": "localhost:50055",
            "RADIENS_LIFECYCLE": "localhost:50055",
        }
        yield mock


@pytest.fixture
def mock_auth() -> Generator[Any, None, None]:  # type: ignore[explicit-any]
    """Mock authentication interceptor."""
    with patch("radiens_core._base_client.AuthInterceptor") as mock:
        yield mock


def test_execute_protocol_progress(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test _execute_protocol with a mocked progress stream."""
    client = CurateClient()

    # Mock set_protocol_proto
    mock_set = MagicMock()

    # Mock apply_protocol_proto to return a stream of progress messages
    mock_apply = MagicMock()

    def progress_stream(*args: Any, **kwargs: Any) -> Generator[common_pb2.ApplyProtocolProgress, None, None]:  # type: ignore[explicit-any]
        yield common_pb2.ApplyProtocolProgress(fracComplete=0.2)
        yield common_pb2.ApplyProtocolProgress(fracComplete=0.5)
        yield common_pb2.ApplyProtocolProgress(fracComplete=1.0)

    mock_apply.return_value = progress_stream()

    with (
        patch("radiens_core.curate_client.set_protocol_proto", mock_set),
        patch("radiens_core.curate_client.apply_protocol_proto", mock_apply),
        patch("radiens_core.curate_client.tqdm") as mock_tqdm,
    ):
        # Mock tqdm instance
        pbar = mock_tqdm.return_value.__enter__.return_value

        nodes = [common_pb2.TransformNode(id="node1")]
        edges = [common_pb2.TransformEdge(id="edge1")]

        client._execute_protocol(nodes, edges, desc="Test Task")

        # Verify set_protocol was called
        mock_set.assert_called_once()
        protocol = mock_set.call_args[0][2]
        assert protocol.transformNodes[0].id == "node1"
        assert protocol.transformEdges[0].id == "edge1"

        # Verify apply_protocol was called
        mock_apply.assert_called_once()

        # Verify tqdm updates
        # Expected updates: 0.2*100=20, (0.5-0.2)*100=30, (1.0-0.5)*100=50
        assert pbar.update.call_count == 3
        calls = pbar.update.call_args_list
        assert calls[0][0][0] == pytest.approx(20.0)
        assert calls[1][0][0] == pytest.approx(30.0)
        assert calls[2][0][0] == pytest.approx(50.0)


def test_link_data_file(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test link_data_file method calls the correct service."""
    client = CurateClient()
    mock_meta = MagicMock()
    mock_meta.id = "test_ds"

    with patch("radiens_core._base_file_client.set_datasource", return_value=mock_meta) as mock_set_ds:
        meta = client.link_data_file("/path/to/test.xdat")

        assert meta == mock_meta
        assert client._datasets["test_ds"] == mock_meta
        mock_set_ds.assert_called_once()
        assert mock_set_ds.call_args[0][2] == "/path/to/test.xdat"


def test_slice_time(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test slice_time method builds correct graph and executes."""
    client = CurateClient()

    mock_input_meta = MagicMock()
    mock_input_meta.id = "input_ds"
    mock_input_meta.base_name = "test_recording"
    mock_input_meta.path = "/path/to"
    mock_input_meta.file_type = RadiensFileType.XDAT
    client._datasets["input_ds"] = mock_input_meta

    mock_output_meta = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output_meta),
    ):
        res = client.slice_time("input_ds", 10.5, 20.5, "output.xdat")

        assert res == mock_output_meta
        mock_exec.assert_called_once()
        args, kwargs = mock_exec.call_args
        nodes, edges = args
        assert "Slicing output.xdat" in kwargs["desc"]

        # Verify nodes: Source, Transform, Sink
        assert len(nodes) == 3
        assert nodes[0].type == common_pb2.SOURCE
        assert nodes[0].datasourceSinkParams.id == "input_ds"

        assert nodes[1].type == common_pb2.SLICE_TIME
        assert nodes[1].sliceTimeParams.timeStart == 10.5
        assert nodes[1].sliceTimeParams.timeEnd == 20.5

        assert nodes[2].type == common_pb2.SINK
        assert nodes[2].datasourceSinkParams.dsrcName == "output"

        # Verify edges
        assert len(edges) == 2
        assert edges[0].source == nodes[0].id
        assert edges[0].target == nodes[1].id
        assert edges[1].source == nodes[1].id
        assert edges[1].target == nodes[2].id


def test_downsample(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test downsample method builds correct graph and executes."""
    client = CurateClient()

    mock_input_meta = MagicMock()
    mock_input_meta.id = "input_ds"
    mock_input_meta.base_name = "test_recording"
    mock_input_meta.path = "/path/to"
    mock_input_meta.file_type = RadiensFileType.XDAT
    client._datasets["input_ds"] = mock_input_meta

    mock_output_meta = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output_meta),
    ):
        res = client.downsample("input_ds", 4, "downsampled.rhd")

        assert res == mock_output_meta
        mock_exec.assert_called_once()
        args, kwargs = mock_exec.call_args
        nodes, _edges = args
        assert "Downsampling downsampled.rhd" in kwargs["desc"]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.DOWNSAMPLE
        assert nodes[1].downsampleParams.sampleFactor == 4

        assert nodes[2].datasourceSinkParams.fileType == common_pb2.RHD


def _make_client_with_dataset(  # type: ignore[explicit-any]
    mock_discovery: Any,
    mock_auth: Any,
) -> tuple[CurateClient, MagicMock]:
    """Helper: create a CurateClient with a pre-linked mock dataset."""
    client = CurateClient()
    mock_input_meta = MagicMock()
    mock_input_meta.id = "input_ds"
    mock_input_meta.base_name = "test_recording"
    mock_input_meta.path = "/path/to"
    mock_input_meta.file_type = RadiensFileType.XDAT
    client._datasets["input_ds"] = mock_input_meta
    return client, mock_input_meta


def test_highpass(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test highpass builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.highpass("input_ds", 300.0, "hp.xdat")

        assert res == mock_output
        mock_exec.assert_called_once()
        nodes, _edges = mock_exec.call_args[0]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.HIGHPASS_FILTER
        assert nodes[1].highLowPassParams.frequency == 300.0
        assert not nodes[1].highLowPassParams.HasField("order")


def test_highpass_with_order(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test highpass passes filter order when specified."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol"),
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        client.highpass("input_ds", 300.0, "hp.xdat", order=4)

        nodes, _edges = client._execute_protocol.call_args[0]  # type: ignore[attr-defined]
        assert nodes[1].highLowPassParams.order == 4


def test_lowpass(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test lowpass builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.lowpass("input_ds", 6000.0, "lp.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert nodes[1].type == common_pb2.LOWPASS_FILTER
        assert nodes[1].highLowPassParams.frequency == 6000.0


def test_bandpass(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test bandpass builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.bandpass("input_ds", 300.0, 6000.0, "bp.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert nodes[1].type == common_pb2.BANDPASS_FILTER
        assert nodes[1].bandParams.lowFrequency == 300.0
        assert nodes[1].bandParams.highFrequency == 6000.0
        assert not nodes[1].bandParams.HasField("order")


def test_bandpass_with_order(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test bandpass passes filter order when specified."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)

    with (
        patch.object(client, "_execute_protocol"),
        patch.object(client, "link_data_file", return_value=MagicMock()),
    ):
        client.bandpass("input_ds", 300.0, 6000.0, "bp.xdat", order=6)

        nodes, _edges = client._execute_protocol.call_args[0]  # type: ignore[attr-defined]
        assert nodes[1].bandParams.order == 6


def test_bandstop(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test bandstop builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.bandstop("input_ds", 58.0, 62.0, "bs.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert nodes[1].type == common_pb2.BANDSTOP_FILTER
        assert nodes[1].bandParams.lowFrequency == 58.0
        assert nodes[1].bandParams.highFrequency == 62.0


def test_notch(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test notch builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.notch("input_ds", 60.0, 4.0, "notched.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert nodes[1].type == common_pb2.NOTCH_FILTER
        assert nodes[1].notchParams.notchFrequency == 60.0
        assert nodes[1].notchParams.notchBandwidth == 4.0
        assert not nodes[1].notchParams.HasField("order")


def test_slice_channels(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test slice_channels builds correct TransformNode."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.slice_channels("input_ds", [0, 2, 5, 10], "sliced_ch.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.SLICE_CHANNELS
        assert list(nodes[1].sliceChannelsParams.sysChanIdxs) == [0, 2, 5, 10]


def test_slice_channels_graph_structure(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test slice_channels produces correct Source -> Transform -> Sink graph."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=MagicMock()),
    ):
        client.slice_channels("input_ds", [0, 1], "out.xdat")

        nodes, edges = mock_exec.call_args[0]

        assert nodes[0].type == common_pb2.SOURCE
        assert nodes[0].datasourceSinkParams.id == "input_ds"
        assert nodes[2].type == common_pb2.SINK
        assert nodes[2].datasourceSinkParams.dsrcName == "out"

        assert len(edges) == 2
        assert edges[0].source == nodes[0].id
        assert edges[0].target == nodes[1].id
        assert edges[1].source == nodes[1].id
        assert edges[1].target == nodes[2].id


def test_car(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test car builds correct TransformNode and returns output metadata."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.car("input_ds", "car_out.xdat")

        assert res == mock_output
        mock_exec.assert_called_once()
        nodes, _edges = mock_exec.call_args[0]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.CAR_REF
        assert "CAR car_out.xdat" in mock_exec.call_args[1]["desc"]


def test_car_graph_structure(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test car produces correct Source -> CAR_REF -> Sink graph."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=MagicMock()),
    ):
        client.car("input_ds", "car_out.xdat")

        nodes, edges = mock_exec.call_args[0]

        assert nodes[0].type == common_pb2.SOURCE
        assert nodes[0].datasourceSinkParams.id == "input_ds"
        assert nodes[1].type == common_pb2.CAR_REF
        assert nodes[2].type == common_pb2.SINK
        assert nodes[2].datasourceSinkParams.dsrcName == "car_out"

        assert len(edges) == 2
        assert edges[0].source == nodes[0].id
        assert edges[0].target == nodes[1].id
        assert edges[1].source == nodes[1].id
        assert edges[1].target == nodes[2].id


def test_virtual_ref(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test virtual_ref builds correct TransformNode with refNtvChanIdx."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.virtual_ref("input_ds", ref_channel=3, output_path="vref_out.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.VIRTUAL_REF
        assert nodes[1].virtualRefParams.refNtvChanIdx == 3


def test_paired_ref(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test paired_ref builds correct TransformNode with both channel indices."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)
    mock_output = MagicMock()

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=mock_output),
    ):
        res = client.paired_ref("input_ds", ref_channel=1, target_channel=4, output_path="pref_out.xdat")

        assert res == mock_output
        nodes, _edges = mock_exec.call_args[0]

        assert len(nodes) == 3
        assert nodes[1].type == common_pb2.PAIRED_REF
        assert nodes[1].pairedRefParams.refNtvChanIdx == 1
        assert nodes[1].pairedRefParams.targetNtvChanIdx == 4


def test_paired_ref_channel_args(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test paired_ref correctly maps ref=2, target=5."""
    client, _ = _make_client_with_dataset(mock_discovery, mock_auth)

    with (
        patch.object(client, "_execute_protocol") as mock_exec,
        patch.object(client, "link_data_file", return_value=MagicMock()),
    ):
        client.paired_ref("input_ds", ref_channel=2, target_channel=5, output_path="pref_out.xdat")

        nodes, _edges = mock_exec.call_args[0]

        assert nodes[1].pairedRefParams.refNtvChanIdx == 2
        assert nodes[1].pairedRefParams.targetNtvChanIdx == 5


# --- Protocol Management Tests ---


def test_apply_protocol(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test apply_protocol consumes the progress stream."""
    client = CurateClient()

    def progress_stream(*args: Any, **kwargs: Any) -> Generator[common_pb2.ApplyProtocolProgress, None, None]:  # type: ignore[explicit-any]
        yield common_pb2.ApplyProtocolProgress(fracComplete=0.5)
        yield common_pb2.ApplyProtocolProgress(fracComplete=1.0)

    mock_apply = MagicMock(return_value=progress_stream())

    with (
        patch("radiens_core.curate_client.apply_protocol_proto", mock_apply),
        patch("radiens_core.curate_client.tqdm") as mock_tqdm,
    ):
        pbar = mock_tqdm.return_value.__enter__.return_value
        client.apply_protocol("my-protocol-id")

        mock_apply.assert_called_once()
        req = mock_apply.call_args[0][2]
        assert req.protocolID == "my-protocol-id"
        assert pbar.update.call_count >= 1


def test_get_protocol(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test get_protocol returns a ProtocolSpec."""
    client = CurateClient()

    mock_proto = common_pb2.Protocol(
        id="proto-abc",
        transformNodes=[
            common_pb2.TransformNode(id="n1"),
            common_pb2.TransformNode(id="n2"),
        ],
    )
    mock_get = MagicMock(return_value=mock_proto)

    with patch("radiens_core.curate_client.get_protocol_proto", mock_get):
        result = client.get_protocol("proto-abc")

    assert isinstance(result, ProtocolSpec)
    assert result.id == "proto-abc"
    assert result.node_count == 2
    mock_get.assert_called_once()
    assert mock_get.call_args[0][2] == "proto-abc"


def test_get_all_protocols(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test get_all_protocols returns a list of ProtocolSpec."""
    client = CurateClient()

    mock_reply = common_pb2.GetAllProtocolsReply(
        protocols=[
            common_pb2.Protocol(id="p1", transformNodes=[common_pb2.TransformNode(id="n1")]),
            common_pb2.Protocol(
                id="p2",
                transformNodes=[common_pb2.TransformNode(id="n2"), common_pb2.TransformNode(id="n3")],
            ),
        ]
    )
    mock_get_all = MagicMock(return_value=mock_reply)

    with patch("radiens_core.curate_client.get_all_protocols_proto", mock_get_all):
        result = client.get_all_protocols()

    assert len(result) == 2
    assert result[0].id == "p1"
    assert result[0].node_count == 1
    assert result[1].id == "p2"
    assert result[1].node_count == 2


# --- Bulk Transform Tests ---


def _make_bulk_sources(
    client: CurateClient,
) -> list[MagicMock]:
    """Register two mock datasets and return their mocks."""
    metas = []
    for i in range(2):
        m = MagicMock()
        m.id = f"ds_{i}"
        m.base_name = f"recording_{i}"
        m.path = f"/data/dir_{i}"
        m.file_type = RadiensFileType.XDAT
        client._datasets[m.id] = m
        metas.append(m)
    return metas


def test_bulk_highpass_node_types(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """Test bulk_highpass builds BULK_SOURCE/BULK_SINK nodes with correct params."""
    client = CurateClient()
    _make_bulk_sources(client)

    with (
        patch("radiens_core.curate_client.set_protocol_proto") as mock_set,
        patch("radiens_core.curate_client.apply_protocol_proto") as mock_apply,
        patch("radiens_core.curate_client.tqdm") as mock_tqdm,
    ):
        mock_set.return_value = common_pb2.Protocol(transformNodes=[])
        mock_tqdm.return_value.__enter__.return_value = MagicMock()

        def _empty_stream(*args: Any, **kwargs: Any) -> Generator[common_pb2.ApplyProtocolProgress, None, None]:  # type: ignore[explicit-any]
            yield common_pb2.ApplyProtocolProgress(fracComplete=1.0)

        mock_apply.return_value = _empty_stream()

        client.bulk_highpass(["ds_0", "ds_1"], 300.0, "/out", "_hp")

    # Verify nodes sent to set_protocol_proto
    protocol = mock_set.call_args[0][2]
    nodes = list(protocol.transformNodes)

    assert nodes[0].type == common_pb2.BULK_SOURCE
    src_params = list(nodes[0].bulkDataSourceParams.srcParams)
    assert len(src_params) == 2
    assert src_params[0].id == "ds_0"
    assert src_params[1].id == "ds_1"

    assert nodes[1].type == common_pb2.HIGHPASS_FILTER
    assert nodes[1].highLowPassParams.frequency == 300.0

    assert nodes[2].type == common_pb2.BULK_SINK
    assert nodes[2].bulkDataSinkParams.suffix == "_hp"
    assert nodes[2].bulkDataSinkParams.path == "/out"

    # Verify edges
    edges = list(protocol.transformEdges)
    assert len(edges) == 2
    assert edges[0].source == nodes[0].id
    assert edges[0].target == nodes[1].id
    assert edges[1].source == nodes[1].id
    assert edges[1].target == nodes[2].id


# --- set_protocol Tests ---


def test_set_protocol_returns_spec(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """set_protocol converts domain nodes/edges, submits to server, returns ProtocolSpec."""
    client = CurateClient()

    src = TransformNode(
        id="src",
        type=TransformNodeType.SOURCE,
        params=SourceParams(dataset_id="ds1", base_name="rec", path="/data", file_type=RadiensFileType.XDAT),
    )
    hp = TransformNode(
        id="hp",
        type=TransformNodeType.HIGHPASS_FILTER,
        params=HighLowPassParams(frequency=300.0),
    )
    snk = TransformNode(
        id="snk",
        type=TransformNodeType.SINK,
        params=SinkParams(base_name="out", path="/output", file_type=RadiensFileType.XDAT),
    )
    edges = [
        TransformEdge(id="e1", source="src", target="hp"),
        TransformEdge(id="e2", source="hp", target="snk"),
    ]

    mock_validated = common_pb2.Protocol(
        id="proto-abc",
        transformNodes=[
            common_pb2.TransformNode(id="src", invalid=False),
            common_pb2.TransformNode(id="hp", invalid=False),
            common_pb2.TransformNode(id="snk", invalid=False),
        ],
    )
    mock_set = MagicMock(return_value=mock_validated)

    with patch("radiens_core.curate_client.set_protocol_proto", mock_set):
        result = client.set_protocol([src, hp, snk], edges)

    assert isinstance(result, ProtocolSpec)
    assert result.id == "proto-abc"
    assert result.node_count == 3

    # Verify the proto sent to the server has the right structure
    sent_proto = mock_set.call_args[0][2]
    sent_nodes = list(sent_proto.transformNodes)
    assert len(sent_nodes) == 3
    assert sent_nodes[0].id == "src"
    assert sent_nodes[0].type == common_pb2.SOURCE
    assert sent_nodes[1].type == common_pb2.HIGHPASS_FILTER
    assert sent_nodes[1].highLowPassParams.frequency == 300.0
    assert sent_nodes[2].type == common_pb2.SINK

    sent_edges = list(sent_proto.transformEdges)
    assert len(sent_edges) == 2
    assert sent_edges[0].source == "src"
    assert sent_edges[0].target == "hp"


def test_set_protocol_raises_on_invalid_node(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """set_protocol raises RadiensError when the server marks a node invalid."""
    from radiens_core.exceptions import RadiensError

    client = CurateClient()

    node = TransformNode.car()
    mock_validated = common_pb2.Protocol(
        id="bad-proto",
        transformNodes=[
            common_pb2.TransformNode(id=node.id, invalid=True, errorMessage="missing source"),
        ],
    )
    mock_set = MagicMock(return_value=mock_validated)

    with (
        patch("radiens_core.curate_client.set_protocol_proto", mock_set),
        pytest.raises(RadiensError, match="missing source"),
    ):
        client.set_protocol([node], [])


def test_set_protocol_custom_id(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """set_protocol uses the caller-supplied protocol_id."""
    client = CurateClient()

    node = TransformNode(id="n1", type=TransformNodeType.CAR_REF, params=CARParams())
    mock_validated = common_pb2.Protocol(
        id="my-custom-id",
        transformNodes=[common_pb2.TransformNode(id="n1", invalid=False)],
    )
    mock_set = MagicMock(return_value=mock_validated)

    with patch("radiens_core.curate_client.set_protocol_proto", mock_set):
        result = client.set_protocol([node], [], protocol_id="my-custom-id")

    sent_proto = mock_set.call_args[0][2]
    assert sent_proto.id == "my-custom-id"
    assert result.id == "my-custom-id"


def test_set_protocol_auto_generates_id(mock_discovery: Any, mock_auth: Any) -> None:  # type: ignore[explicit-any]
    """set_protocol generates a UUID when protocol_id is omitted."""
    client = CurateClient()

    node = TransformNode(id="n1", type=TransformNodeType.CAR_REF, params=CARParams())
    mock_set = MagicMock(
        return_value=common_pb2.Protocol(
            id="server-id",
            transformNodes=[common_pb2.TransformNode(id="n1", invalid=False)],
        )
    )

    with patch("radiens_core.curate_client.set_protocol_proto", mock_set):
        client.set_protocol([node], [])

    sent_proto = mock_set.call_args[0][2]
    # A UUID was generated — must be non-empty and not a fixed string
    assert len(sent_proto.id) > 0
