from dataclasses import replace
import sys
import threading

import pytest

from a2a_agent.config import AgentConfig
from a2a_agent.config import DeviceRole
from a2a_agent.config import ShareConfig
from a2a_agent.config import load_config
from a2a_agent.config import save_config
from a2a_agent.protocol import Message
from a2a_agent.protocol import Task
from a2a_agent import runtime
from a2a_agent import topics
from a2a_agent.runtime import _cache_task_share_config
from a2a_agent.runtime import resolve_device_name
from a2a_agent.runtime import send_task


@pytest.fixture(autouse=True)
def _disable_single_instance_guard(monkeypatch) -> None:
    """Keep existing MQTT-loop tests isolated from the real default A2A_HOME."""

    monkeypatch.setenv("A2A_SINGLE_INSTANCE", "0")


def test_resolve_device_name_accepts_alias() -> None:
    config = AgentConfig(
        devices={"windows-cursor": DeviceRole(role="cursor")},
        aliases={"회사 PC": "windows-cursor"},
    )

    assert resolve_device_name(config, "회사 PC") == "windows-cursor"


def test_resolve_device_name_accepts_registered_device() -> None:
    config = AgentConfig(
        devices={"windows-pc": DeviceRole(role="opencode")},
        aliases={"데스크탑 PC": "windows-pc"},
    )

    assert resolve_device_name(config, "windows-pc") == "windows-pc"


def test_resolve_device_name_rejects_unknown_when_devices_are_registered() -> None:
    config = AgentConfig(
        devices={"windows-pc": DeviceRole(role="opencode")},
    )

    with pytest.raises(RuntimeError, match="unknown device or alias"):
        resolve_device_name(config, "회사 PC")


def test_send_task_includes_commander_share(monkeypatch) -> None:
    share = ShareConfig(
        host="nas.example.com",
        port=5001,
        user="a2a-worker",
        password="secret",
        folder="/a2a-share",
        insecure=True,
    )
    config = AgentConfig(
        room="team",
        device_name="commander",
        role="commander",
        share=share,
    )
    fake_client = FakeMqttClient()

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "MqttClient", lambda client_id: fake_client)
    monkeypatch.setattr(runtime.time, "sleep", lambda seconds: None)

    result = send_task("worker-a", "hello")

    request = Task.from_json(fake_client.request_payload)
    assert result.status == "completed"
    assert request.share == share.to_dict()


@pytest.mark.parametrize(
    "control",
    [
        "install-autostart",
        "restart-worker",
        "set-executor:opencode",
        "set-cursor-key:test-cursor-api-key-789",
    ],
)
def test_send_control_task_resolves_alias_and_omits_prompt_and_share(
    monkeypatch,
    control: str,
) -> None:
    config = AgentConfig(
        room="team",
        device_name="commander",
        role="commander",
        devices={"worker-a": DeviceRole(role="worker")},
        aliases={"데스크탑 PC": "worker-a"},
        share=ShareConfig(
            host="nas.example.com",
            port=5001,
            user="a2a-worker",
            password="secret",
            folder="/a2a-share",
            insecure=True,
        ),
    )
    fake_client = FakeMqttClient()

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "MqttClient", lambda client_id: fake_client)
    monkeypatch.setattr(runtime.time, "sleep", lambda seconds: None)

    result = send_task(
        "데스크탑 PC",
        "",
        control=control,
    )

    request = Task.from_json(fake_client.request_payload)
    assert result.status == "completed"
    assert request.to == "worker-a"
    assert request.control == control
    assert request.messages == []
    assert request.share is None


def test_send_kill_stale_workers_waits_for_owner_after_old_worker_response(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="commander",
        role="commander",
        devices={"worker-a": DeviceRole(role="worker")},
    )
    fake_client = FakeStaleWorkerRaceMqttClient()

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "MqttClient", lambda client_id: fake_client)
    monkeypatch.setattr(runtime.time, "sleep", lambda seconds: None)

    result = send_task("worker-a", "", control="kill-stale-workers")

    assert result.status == "completed"
    assert result.text.startswith("control completed: kill-stale-workers")


def test_worker_caches_task_share_to_config(monkeypatch, tmp_path) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    share = {
        "host": "nas.example.com",
        "port": 5001,
        "user": "a2a-worker",
        "password": "secret",
        "folder": "/a2a-share",
        "insecure": True,
    }
    config = AgentConfig(room="team", device_name="worker-a", role="worker")
    task = Task(
        id="task-001",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[Message(role="user", parts=[{"type": "text", "text": "hello"}])],
        share=share,
    )

    updated = _cache_task_share_config(config, task)

    expected = ShareConfig.from_dict(share)
    assert updated.share == expected
    assert load_config(tmp_path / "config.yaml").share == expected


def test_worker_releases_single_instance_claim_when_startup_fails(monkeypatch, tmp_path) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    claim = runtime.worker_instance.WorkerInstanceClaim(
        path=tmp_path / "worker.pid",
        pid=101,
        enabled=True,
    )
    released: list[object] = []

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_a2a_home", lambda: tmp_path)
    monkeypatch.setattr(runtime.worker_instance, "claim_worker_instance", lambda home: claim)
    monkeypatch.setattr(
        runtime.worker_instance,
        "release_worker_instance",
        lambda released_claim: released.append(released_claim),
    )
    monkeypatch.setattr(
        runtime,
        "_connected_client",
        lambda *args: (_ for _ in ()).throw(RuntimeError("broker unavailable")),
    )

    with pytest.raises(RuntimeError, match="broker unavailable"):
        runtime.run_worker()

    assert released == [claim]


def test_worker_callback_enqueues_for_daemon_processing_thread(monkeypatch, tmp_path) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    fake_client = FakeWorkerMqttClient()
    stop = threading.Event()
    execute_started = threading.Event()
    release_execute = threading.Event()
    callback_returned = threading.Event()
    thread_details: dict[str, object] = {}
    worker_errors: list[BaseException] = []

    def fake_download(config: AgentConfig, task: Task):
        thread_details["download_ident"] = threading.get_ident()
        return [], []

    def fake_execute(prompt: str, config: AgentConfig):
        current = threading.current_thread()
        thread_details["execute_ident"] = current.ident
        thread_details["execute_name"] = current.name
        thread_details["execute_daemon"] = current.daemon
        execute_started.set()
        if not release_execute.wait(5):
            return runtime.ExecutorResult(ok=False, text="test release timed out")
        return runtime.ExecutorResult(ok=True, text="ok")

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_connected_client", lambda *args: fake_client)
    monkeypatch.setattr(runtime, "_install_signal_handlers", lambda event: None)
    monkeypatch.setattr(runtime, "start_runtime_auto_update_monitor", lambda event: None)
    monkeypatch.setattr(runtime, "_download_input_files", fake_download)
    monkeypatch.setattr(runtime, "_task_out_dir", lambda task_id: tmp_path / task_id)
    monkeypatch.setattr(runtime, "_upload_output_files", lambda *args: [])
    monkeypatch.setattr(runtime, "execute_text", fake_execute)

    def run() -> None:
        try:
            runtime.run_worker(stop)
        except BaseException as exc:
            worker_errors.append(exc)

    worker_thread = threading.Thread(target=run, name="worker-run-test")
    worker_thread.start()
    callback_thread: threading.Thread | None = None
    try:
        assert fake_client.request_subscribed.wait(2)

        task = Task(
            id="task-queued",
            from_="commander",
            to="worker-a",
            status="pending",
            messages=[Message(role="user", parts=[{"type": "text", "text": "hello"}])],
        )
        request_topic = topics.cmd_request_topic("team", "worker-a")

        def invoke_callback() -> None:
            thread_details["callback_ident"] = threading.get_ident()
            fake_client.subscriptions[request_topic](
                request_topic,
                task.to_json().encode("utf-8"),
                None,
            )
            callback_returned.set()

        callback_thread = threading.Thread(target=invoke_callback, name="mqtt-callback-test")
        callback_thread.start()
        assert execute_started.wait(2)
        # 실행기가 막혀 있어도 MQTT 콜백은 큐에 넣은 뒤 이미 반환해야 한다.
        assert callback_returned.wait(1)
        assert fake_client.working_published.is_set()
        assert thread_details["execute_ident"] != thread_details["callback_ident"]
        assert thread_details["download_ident"] == thread_details["execute_ident"]
        assert thread_details["execute_name"] == "a2a-worker-task-processor"
        assert thread_details["execute_daemon"] is True
        release_execute.set()
        assert fake_client.response_published.wait(2)
    finally:
        release_execute.set()
        stop.set()
        if callback_thread is not None:
            callback_thread.join(timeout=2)
        worker_thread.join(timeout=3)

    assert callback_thread is not None
    assert not callback_thread.is_alive()
    assert not worker_thread.is_alive()
    assert worker_errors == []
    assert fake_client.response_status == "completed"


def test_worker_control_installs_autostart_uses_config_executor(monkeypatch, tmp_path) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="cursor",
    )
    fake_client = FakeWorkerMqttClient()
    observed: dict[str, str] = {}
    registered_path = tmp_path / "a2a-worker.service"

    def fake_install(executor: str) -> runtime.autostart.AutostartResult:
        observed["executor"] = executor
        return runtime.autostart.AutostartResult(
            action="installed",
            platform="linux",
            path=registered_path,
            enabled=True,
        )

    monkeypatch.setenv("A2A_EXECUTOR", "opencode")
    monkeypatch.setattr(runtime.autostart, "install_autostart", fake_install)
    monkeypatch.setattr(
        runtime,
        "execute_text",
        lambda *args: pytest.fail("control task must not call the AI executor"),
    )
    monkeypatch.setattr(
        runtime,
        "_cache_task_share_config",
        lambda *args: pytest.fail("control task must not cache share config"),
    )
    monkeypatch.setattr(
        runtime,
        "_download_input_files",
        lambda *args: pytest.fail("control task must not download files"),
    )
    monkeypatch.setattr(
        runtime,
        "_task_out_dir",
        lambda *args: pytest.fail("control task must not create an output directory"),
    )
    monkeypatch.setattr(
        runtime,
        "_upload_output_files",
        lambda *args: pytest.fail("control task must not upload files"),
    )
    task = Task(
        id="task-control-install",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control="install-autostart",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert observed == {"executor": "cursor"}
    assert response.status == "completed"
    assert response.control == "install-autostart"
    assert f"path: {registered_path}" in runtime._task_text(response)
    assert [status.status for status in fake_client.task_statuses] == [
        "working",
        "completed",
    ]
    assert fake_client.task_statuses[-1].messages == []


def test_restart_worker_control_publishes_response_before_relaunch(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="cursor",
    )
    fake_client = FakeWorkerMqttClient()
    events: list[str] = []
    original_publish = fake_client.publish
    response_topic = topics.cmd_response_topic("team", "worker-a")
    task_status_topic = topics.cmd_task_status_topic("team", "worker-a")

    def record_publish(topic: str, payload: str, *, qos: int, retain: bool):
        if topic == response_topic:
            events.append("response")
        elif topic == task_status_topic:
            status = Task.from_json(payload)
            if status.status == "completed":
                events.append("completed-status")
        return original_publish(topic, payload, qos=qos, retain=retain)

    monkeypatch.setattr(fake_client, "publish", record_publish)
    monkeypatch.setattr(runtime.worker_instance, "owns_worker_pid", lambda home: True)
    monkeypatch.setattr(runtime, "_restart_worker_in_progress", False)

    def fake_restart() -> None:
        events.append("restart")
        raise OSError("simulated exec failure")

    monkeypatch.setattr(
        runtime,
        "_restart_current_worker",
        fake_restart,
    )
    task = Task(
        id="task-control-restart",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control="restart-worker",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert response.status == "completed"
    assert response.control == "restart-worker"
    assert "control completed: restart-worker" in runtime._task_text(response)
    assert "status: restarting" in runtime._task_text(response)
    assert events == ["response", "completed-status", "restart"]


def test_worker_control_diagnoses_executor_without_ai(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="opencode",
    )
    fake_client = FakeWorkerMqttClient()
    observed: dict[str, AgentConfig] = {}

    def fake_diagnose(task_config: AgentConfig) -> runtime.ExecutorResult:
        observed["config"] = task_config
        return runtime.ExecutorResult(
            ok=True,
            text="diagnostic: diag-executor\nstdin_probe.ok: false",
        )

    monkeypatch.setattr(runtime, "diagnose_executor", fake_diagnose)
    monkeypatch.setattr(
        runtime,
        "execute_text",
        lambda *args: pytest.fail("diag control must not call the AI executor"),
    )
    monkeypatch.setattr(
        runtime,
        "_cache_task_share_config",
        lambda *args: pytest.fail("diag control must not cache share config"),
    )
    monkeypatch.setattr(
        runtime,
        "_download_input_files",
        lambda *args: pytest.fail("diag control must not download files"),
    )
    monkeypatch.setattr(
        runtime,
        "_task_out_dir",
        lambda *args: pytest.fail("diag control must not create an output directory"),
    )
    monkeypatch.setattr(
        runtime,
        "_upload_output_files",
        lambda *args: pytest.fail("diag control must not upload files"),
    )
    task = Task(
        id="task-control-diag",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control="diag-executor",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert observed == {"config": config}
    assert response.status == "completed"
    assert response.control == "diag-executor"
    assert "diagnostic: diag-executor" in runtime._task_text(response)
    assert [status.status for status in fake_client.task_statuses] == [
        "working",
        "completed",
    ]


@pytest.mark.parametrize(
    ("control", "expected"),
    [
        ("set-executor:opencode", ("set-executor", "opencode")),
        (
            "set-cursor-key:test-cursor-api-key-789",
            ("set-cursor-key", "test-cursor-api-key-789"),
        ),
        (
            "set-ollama-key:test-ollama-api-key-789",
            ("set-ollama-key", "test-ollama-api-key-789"),
        ),
        (
            "set-ollama-model:gpt-oss:120b",
            ("set-ollama-model", "gpt-oss:120b"),
        ),
        ("install-autostart", ("install-autostart", None)),
        ("restart-worker", ("restart-worker", None)),
    ],
)
def test_parse_control_separates_optional_argument(
    control: str,
    expected: tuple[str, str | None],
) -> None:
    assert runtime._parse_control(control) == expected


def test_restart_current_worker_drops_executor_environment() -> None:
    class Restarted(Exception):
        pass

    observed: dict[str, object] = {}
    delays: list[float] = []

    def fake_execve(program: str, command, environment) -> None:
        observed["program"] = program
        observed["command"] = list(command)
        observed["environment"] = dict(environment)
        raise Restarted()

    with pytest.raises(Restarted):
        runtime._restart_current_worker(
            argv=["a2a-agent", "run"],
            environ={"A2A_EXECUTOR": "opencode", "KEEP": "yes"},
            sleep=delays.append,
            execve=fake_execve,
        )

    assert delays == [runtime.RESTART_WORKER_DELAY_SECONDS]
    assert observed["program"] == sys.executable
    assert observed["command"] == [sys.executable, "-m", "a2a_agent.cli", "run"]
    assert observed["environment"] == {"KEEP": "yes"}


def test_set_executor_control_persists_config_for_next_restart_only(
    monkeypatch,
    tmp_path,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    config_path = tmp_path / "config.yaml"
    persisted_config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="echo",
    )
    running_config = replace(persisted_config, executor="cursor")
    save_config(persisted_config, config_path)

    def must_not_run(*args, **kwargs):
        pytest.fail("set-executor must only update config")

    monkeypatch.setattr(runtime, "execute_text", must_not_run)
    monkeypatch.setattr(runtime, "diagnose_executor", must_not_run)
    monkeypatch.setattr(runtime.autostart, "install_autostart", must_not_run)
    monkeypatch.setattr(runtime.autostart, "uninstall_autostart", must_not_run)
    monkeypatch.setattr(
        runtime.worker_instance,
        "cleanup_stale_workers",
        must_not_run,
    )

    result = runtime._execute_control_task("set-executor:OpenCode", running_config)

    assert result.ok is True
    assert "executor set to opencode" in result.text
    assert "applies after next worker restart" in result.text
    assert load_config(config_path).executor == "opencode"
    assert running_config.executor == "cursor"


def test_set_cursor_key_control_persists_config_without_echoing_key(
    monkeypatch,
    tmp_path,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    cursor_api_key = "test-cursor-api-key-789"
    persisted_config = AgentConfig(executor="echo")
    running_config = AgentConfig(executor="cursor")
    save_config(persisted_config, config_path)

    def must_not_run(*args, **kwargs):
        pytest.fail("set-cursor-key must only update config")

    monkeypatch.setattr(runtime, "execute_text", must_not_run)
    monkeypatch.setattr(runtime, "diagnose_executor", must_not_run)

    result = runtime._execute_control_task(
        f"set-cursor-key:{cursor_api_key}",
        running_config,
    )

    assert result.ok is True
    assert f"cursor api key stored (len={len(cursor_api_key)})" in result.text
    assert "applies after next worker restart" in result.text
    assert cursor_api_key not in result.text
    assert load_config(config_path).cursor_api_key == cursor_api_key
    assert running_config.cursor_api_key is None


def test_set_ollama_key_control_persists_config_without_echoing_key(
    monkeypatch,
    tmp_path,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    ollama_api_key = "test-ollama-api-key-789"
    persisted_config = AgentConfig(executor="echo")
    running_config = AgentConfig(executor="ollama")
    save_config(persisted_config, config_path)

    def must_not_run(*args, **kwargs):
        pytest.fail("set-ollama-key must only update config")

    monkeypatch.setattr(runtime, "execute_text", must_not_run)
    monkeypatch.setattr(runtime, "diagnose_executor", must_not_run)

    result = runtime._execute_control_task(
        f"set-ollama-key:{ollama_api_key}",
        running_config,
    )

    assert result.ok is True
    assert f"ollama api key stored (len={len(ollama_api_key)})" in result.text
    assert "applies to the next task or after worker restart" in result.text
    assert ollama_api_key not in result.text
    assert load_config(config_path).ollama_api_key == ollama_api_key
    assert running_config.ollama_api_key is None


def test_set_ollama_model_control_persists_trimmed_model_for_next_task(
    monkeypatch,
    tmp_path,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    model = "gpt-oss:120b"
    save_config(AgentConfig(executor="echo"), config_path)

    result = runtime._execute_control_task(
        f"set-ollama-model:  {model}  ",
        AgentConfig(executor="ollama"),
    )

    assert result.ok is True
    assert f"ollama model set to {model}" in result.text
    assert "applies to the next task or after worker restart" in result.text
    assert load_config(config_path).ollama_model == model


@pytest.mark.parametrize(
    "control",
    ["set-cursor-key:", "set-cursor-key:test cursor api key"],
)
def test_set_cursor_key_control_rejects_invalid_value_without_saving(
    monkeypatch,
    tmp_path,
    control: str,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    initial_config = AgentConfig(executor="echo")
    save_config(initial_config, config_path)

    def must_not_save(*args, **kwargs):
        pytest.fail("invalid cursor key must not be persisted")

    monkeypatch.setattr(runtime, "save_config", must_not_save)

    result = runtime._execute_control_task(control, AgentConfig(executor="cursor"))

    assert result.ok is False
    assert result.text == "cursor api key must not be empty or contain whitespace"
    assert "test cursor api key" not in result.text
    assert load_config(config_path).cursor_api_key is None


@pytest.mark.parametrize(
    "control",
    ["set-ollama-key:", "set-ollama-key:test ollama api key"],
)
def test_set_ollama_key_control_rejects_invalid_value_without_saving(
    monkeypatch,
    tmp_path,
    control: str,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    initial_config = AgentConfig(executor="echo")
    save_config(initial_config, config_path)

    def must_not_save(*args, **kwargs):
        pytest.fail("invalid ollama key must not be persisted")

    monkeypatch.setattr(runtime, "save_config", must_not_save)

    result = runtime._execute_control_task(control, AgentConfig(executor="ollama"))

    assert result.ok is False
    assert result.text == "ollama api key must not be empty or contain whitespace"
    assert "test ollama api key" not in result.text
    assert load_config(config_path).ollama_api_key is None


def test_set_ollama_key_control_storage_error_redacts_key(monkeypatch) -> None:
    ollama_api_key = "test-ollama-api-key-987"

    monkeypatch.setattr(runtime, "load_config", lambda: AgentConfig())

    def fail_save(config: AgentConfig) -> None:
        raise OSError(f"save rejected {ollama_api_key}")

    monkeypatch.setattr(runtime, "save_config", fail_save)

    result = runtime._execute_control_task(
        f"set-ollama-key:{ollama_api_key}",
        AgentConfig(executor="ollama"),
    )

    assert result.ok is False
    assert result.text == "set-ollama-key failed while storing ollama api key"
    assert ollama_api_key not in result.text


def test_worker_set_cursor_key_redacts_control_from_response_and_status(
    monkeypatch,
    tmp_path,
) -> None:
    cursor_api_key = "test-cursor-api-key-901"
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="cursor",
    )
    fake_client = FakeWorkerMqttClient()
    saved: list[AgentConfig] = []

    monkeypatch.setattr(
        runtime,
        "save_config",
        lambda updated: saved.append(updated) or tmp_path / "config.yaml",
    )
    task = Task(
        id="task-control-cursor-key",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control=f"set-cursor-key:{cursor_api_key}",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert saved and saved[0].cursor_api_key == cursor_api_key
    assert response.control == "set-cursor-key"
    assert cursor_api_key not in response.to_json()
    assert cursor_api_key not in runtime._task_text(response)
    assert all(status.control == "set-cursor-key" for status in fake_client.task_statuses)
    assert all(cursor_api_key not in status.to_json() for status in fake_client.task_statuses)


def test_worker_set_ollama_key_redacts_control_from_response_and_status(
    monkeypatch,
    tmp_path,
) -> None:
    ollama_api_key = "test-ollama-api-key-901"
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="ollama",
    )
    fake_client = FakeWorkerMqttClient()
    saved: list[AgentConfig] = []

    monkeypatch.setattr(
        runtime,
        "save_config",
        lambda updated: saved.append(updated) or tmp_path / "config.yaml",
    )
    task = Task(
        id="task-control-ollama-key",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control=f"set-ollama-key:{ollama_api_key}",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert saved and saved[0].ollama_api_key == ollama_api_key
    assert response.control == "set-ollama-key"
    assert ollama_api_key not in response.to_json()
    assert ollama_api_key not in runtime._task_text(response)
    assert all(status.control == "set-ollama-key" for status in fake_client.task_statuses)
    assert all(ollama_api_key not in status.to_json() for status in fake_client.task_statuses)


def test_worker_ollama_key_control_unexpected_error_never_leaks_key(
    monkeypatch,
    tmp_path,
    capsys,
) -> None:
    ollama_api_key = "test-ollama-api-key-654"
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
        executor="ollama",
    )
    fake_client = FakeWorkerMqttClient()

    def fail_save(updated: AgentConfig) -> None:
        raise ValueError(f"unexpected save failure: {ollama_api_key}")

    monkeypatch.setattr(runtime, "save_config", fail_save)
    task = Task(
        id="task-control-ollama-key-failure",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control=f"set-ollama-key:{ollama_api_key}",
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)
    captured = capsys.readouterr()

    assert response.status == "failed"
    assert response.control == "set-ollama-key"
    assert ollama_api_key not in response.to_json()
    assert all(ollama_api_key not in status.to_json() for status in fake_client.task_statuses)
    assert ollama_api_key not in captured.out


def test_status_payload_never_contains_api_keys() -> None:
    cursor_api_key = "test-cursor-api-key-234"
    ollama_api_key = "test-ollama-api-key-234"

    payload = runtime._status_payload(
        AgentConfig(
            cursor_api_key=cursor_api_key,
            ollama_api_key=ollama_api_key,
        ),
        "online",
    )

    assert cursor_api_key not in payload
    assert ollama_api_key not in payload
    assert "cursor_api_key" not in payload
    assert "ollama_api_key" not in payload


@pytest.mark.parametrize(
    "control",
    ["set-executor:claude", "set-executor:", "set-executor:opencode:extra"],
)
def test_set_executor_control_rejects_unsupported_values_without_saving(
    monkeypatch,
    tmp_path,
    control: str,
) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    config_path = tmp_path / "config.yaml"
    initial_config = AgentConfig(executor="echo")
    save_config(initial_config, config_path)

    def must_not_save(*args, **kwargs):
        pytest.fail("unsupported executor must not be persisted")

    monkeypatch.setattr(runtime, "save_config", must_not_save)

    result = runtime._execute_control_task(control, AgentConfig(executor="cursor"))

    assert result.ok is False
    assert result.text.startswith("unsupported executor:")
    assert load_config(config_path).executor == "echo"


def test_diag_executor_reads_persisted_config_after_set_executor(monkeypatch, tmp_path) -> None:
    monkeypatch.setenv("A2A_HOME", str(tmp_path))
    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    config_path = tmp_path / "config.yaml"
    persisted_config = AgentConfig(executor="echo")
    running_config = replace(persisted_config, executor="cursor")
    save_config(persisted_config, config_path)
    observed: dict[str, AgentConfig] = {}

    def fake_diagnose(config: AgentConfig) -> runtime.ExecutorResult:
        observed["config"] = config
        return runtime.ExecutorResult(ok=True, text="diagnostic complete")

    assert runtime._execute_control_task(
        "set-executor:opencode",
        running_config,
    ).ok is True
    monkeypatch.setattr(runtime, "diagnose_executor", fake_diagnose)

    result = runtime._execute_control_task("diag-executor", running_config)

    assert result.ok is True
    assert observed["config"].executor == "opencode"
    assert running_config.executor == "cursor"


def test_control_kills_stale_workers_without_ai_executor(monkeypatch, tmp_path) -> None:
    cleanup = runtime.worker_instance.StaleWorkerCleanup(
        method="process-list",
        terminations=(
            runtime.worker_instance.ProcessTermination(
                pid=202,
                stopped=True,
                method="SIGTERM",
            ),
        ),
        skipped_pids=(303,),
        remaining_workers=1,
    )
    observed: dict[str, object] = {}

    def fake_cleanup(home):
        observed["home"] = home
        return cleanup

    monkeypatch.setattr(runtime, "_a2a_home", lambda: tmp_path)
    monkeypatch.setattr(runtime.worker_instance, "cleanup_stale_workers", fake_cleanup)
    monkeypatch.setattr(
        runtime,
        "execute_text",
        lambda *args: pytest.fail("stale-worker control must not call the AI executor"),
    )

    result = runtime._execute_control_task("kill-stale-workers", AgentConfig())

    assert observed == {"home": tmp_path}
    assert result.ok is True
    assert "control completed: kill-stale-workers" in result.text
    assert "terminated pids: 202 (SIGTERM)" in result.text
    assert "remaining workers: 1" in result.text


def test_kill_stale_workers_control_is_processed_only_by_pid_owner(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(runtime, "_a2a_home", lambda: tmp_path)
    monkeypatch.setattr(runtime.worker_instance, "owns_worker_pid", lambda home: False)

    assert runtime._should_process_control_task("kill-stale-workers") is False
    assert runtime._should_process_control_task("restart-worker") is False
    assert runtime._should_process_control_task("diag-executor") is True


def test_restart_worker_ignores_duplicate_restart_request(monkeypatch) -> None:
    started = threading.Event()
    release = threading.Event()
    calls: list[str] = []

    def fake_restart() -> None:
        calls.append("restart")
        started.set()
        assert release.wait(2)
        raise OSError("simulated exec failure")

    monkeypatch.setattr(runtime, "_restart_worker_in_progress", False)
    monkeypatch.setattr(runtime, "_restart_current_worker", fake_restart)
    first = threading.Thread(target=runtime._restart_worker_after_response)
    first.start()
    try:
        assert started.wait(2)
        runtime._restart_worker_after_response()
        assert calls == ["restart"]
    finally:
        release.set()
        first.join(timeout=2)

    assert not first.is_alive()
    assert runtime._restart_worker_in_progress is False


def test_non_owner_worker_does_not_win_kill_stale_workers_response_race(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    fake_client = FakeWorkerMqttClient()
    stop = threading.Event()
    worker_errors: list[BaseException] = []

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_connected_client", lambda *args: fake_client)
    monkeypatch.setattr(runtime, "_install_signal_handlers", lambda event: None)
    monkeypatch.setattr(runtime, "start_runtime_auto_update_monitor", lambda event: None)
    monkeypatch.setattr(runtime.worker_instance, "owns_worker_pid", lambda home: False)
    monkeypatch.setattr(
        runtime,
        "_execute_control_task",
        lambda *args: pytest.fail("non-owner must not run stale-worker cleanup"),
    )

    def run() -> None:
        try:
            runtime.run_worker(stop)
        except BaseException as exc:
            worker_errors.append(exc)

    worker_thread = threading.Thread(target=run, name="worker-non-owner-control-test")
    worker_thread.start()
    try:
        assert fake_client.request_subscribed.wait(2)
        task = Task(
            id="task-control-non-owner",
            from_="commander",
            to="worker-a",
            status="pending",
            messages=[],
            control="kill-stale-workers",
        )
        request_topic = topics.cmd_request_topic("team", "worker-a")
        fake_client.subscriptions[request_topic](
            request_topic,
            task.to_json().encode("utf-8"),
            None,
        )
        assert fake_client.working_published.wait(2)
        assert not fake_client.response_published.wait(0.2)
    finally:
        stop.set()
        worker_thread.join(timeout=3)

    assert not worker_thread.is_alive()
    assert worker_errors == []


@pytest.mark.parametrize(
    "control",
    ["run-shell", "diag-executor-invalid", "restart-worker:again", ""],
)
def test_worker_rejects_unsupported_control_without_executor(
    monkeypatch,
    control: str,
) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    fake_client = FakeWorkerMqttClient()
    monkeypatch.setattr(
        runtime,
        "execute_text",
        lambda *args: pytest.fail("unsupported control must not call the AI executor"),
    )
    monkeypatch.setattr(
        runtime.autostart,
        "install_autostart",
        lambda *args: pytest.fail("unsupported control must not install autostart"),
    )
    monkeypatch.setattr(
        runtime.autostart,
        "uninstall_autostart",
        lambda *args: pytest.fail("unsupported control must not uninstall autostart"),
    )
    task = Task(
        id="task-control-unsupported",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[],
        control=control,
    )

    response = _run_worker_task(monkeypatch, config, fake_client, task)

    assert response.status == "failed"
    assert response.control == control
    assert runtime._task_text(response) == f"unsupported control: {control}"


def test_control_uninstall_calls_fixed_autostart_function(monkeypatch, tmp_path) -> None:
    observed: list[str] = []
    registered_path = tmp_path / "worker.plist"

    def fake_uninstall() -> runtime.autostart.AutostartResult:
        observed.append("uninstall")
        return runtime.autostart.AutostartResult(
            action="uninstalled",
            platform="macos",
            path=registered_path,
            enabled=False,
        )

    monkeypatch.setattr(runtime.autostart, "uninstall_autostart", fake_uninstall)

    result = runtime._execute_control_task("uninstall-autostart", AgentConfig())

    assert observed == ["uninstall"]
    assert result.ok is True
    assert f"path: {registered_path}" in result.text


def test_control_autostart_error_becomes_failed_result(monkeypatch) -> None:
    def fail_install(executor: str) -> runtime.autostart.AutostartResult:
        raise runtime.autostart.AutostartError("registration denied")

    monkeypatch.setattr(runtime.autostart, "install_autostart", fail_install)

    result = runtime._execute_control_task("install-autostart", AgentConfig())

    assert result.ok is False
    assert result.text == "install-autostart failed: registration denied"


def test_control_incomplete_autostart_result_becomes_failed(monkeypatch, tmp_path) -> None:
    manual_command = "systemctl --user enable a2a-worker.service"
    monkeypatch.setattr(
        runtime.autostart,
        "install_autostart",
        lambda executor: runtime.autostart.AutostartResult(
            action="installed",
            platform="linux",
            path=tmp_path / "a2a-worker.service",
            enabled=False,
            manual_command=manual_command,
        ),
    )

    result = runtime._execute_control_task("install-autostart", AgentConfig())

    assert result.ok is False
    assert result.text.startswith("control failed: install-autostart")
    assert f"manual command: {manual_command}" in result.text


def test_worker_callback_ignores_task_after_stop(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    fake_client = FakeWorkerMqttClient()
    stop = threading.Event()
    stop.set()

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_connected_client", lambda *args: fake_client)
    monkeypatch.setattr(runtime, "_install_signal_handlers", lambda event: None)
    monkeypatch.setattr(runtime, "start_runtime_auto_update_monitor", lambda event: None)
    monkeypatch.setattr(
        runtime,
        "execute_text",
        lambda *args: pytest.fail("executor must not run after worker stop"),
    )

    runtime.run_worker(stop)

    task = Task(
        id="task-after-stop",
        from_="commander",
        to="worker-a",
        status="pending",
        messages=[Message(role="user", parts=[{"type": "text", "text": "hello"}])],
    )
    request_topic = topics.cmd_request_topic("team", "worker-a")
    fake_client.subscriptions[request_topic](
        request_topic,
        task.to_json().encode("utf-8"),
        None,
    )

    assert not fake_client.working_published.is_set()
    assert not fake_client.response_published.is_set()


def test_worker_reports_unexpected_processing_failure(monkeypatch) -> None:
    config = AgentConfig(
        room="team",
        device_name="worker-a",
        role="worker",
        token="worker-token",
    )
    fake_client = FakeWorkerMqttClient()
    stop = threading.Event()
    worker_errors: list[BaseException] = []

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_connected_client", lambda *args: fake_client)
    monkeypatch.setattr(runtime, "_install_signal_handlers", lambda event: None)
    monkeypatch.setattr(runtime, "start_runtime_auto_update_monitor", lambda event: None)
    monkeypatch.setattr(
        runtime,
        "_download_input_files",
        lambda *args: (_ for _ in ()).throw(RuntimeError("download crashed")),
    )

    def run() -> None:
        try:
            runtime.run_worker(stop)
        except BaseException as exc:
            worker_errors.append(exc)

    worker_thread = threading.Thread(target=run, name="worker-failure-test")
    worker_thread.start()
    try:
        assert fake_client.request_subscribed.wait(2)
        task = Task(
            id="task-failed",
            from_="commander",
            to="worker-a",
            status="pending",
            messages=[Message(role="user", parts=[{"type": "text", "text": "hello"}])],
        )
        request_topic = topics.cmd_request_topic("team", "worker-a")
        fake_client.subscriptions[request_topic](
            request_topic,
            task.to_json().encode("utf-8"),
            None,
        )

        assert fake_client.response_published.wait(2)
        assert fake_client.failed_status_published.wait(2)
    finally:
        stop.set()
        worker_thread.join(timeout=3)

    assert not worker_thread.is_alive()
    assert worker_errors == []
    assert fake_client.response_status == "failed"


class FakePublishInfo:
    def wait_for_publish(self, timeout: int) -> None:
        return None


class FakeUnacknowledgedPublishInfo(FakePublishInfo):
    def wait_for_publish(self, timeout: int) -> None:
        raise AssertionError("working status publish must not wait inside MQTT callback")


class FakeMqttClient:
    def __init__(self) -> None:
        self.subscriptions = {}
        self.request_payload = ""

    def connect(self, host: str, port: int, *, tls: bool) -> None:
        return None

    def loop_start(self) -> None:
        return None

    def loop_stop(self) -> None:
        return None

    def wait_connected(self) -> bool:
        return True

    def subscribe(self, topic: str, callback) -> None:
        self.subscriptions[topic] = callback

    def publish(
        self,
        topic: str,
        payload: str,
        *,
        qos: int,
        retain: bool,
    ) -> FakePublishInfo:
        if topic == topics.cmd_request_topic("team", "worker-a"):
            self.request_payload = payload
            request = Task.from_json(payload)
            response_text = "ok"
            if request.control == "restart-worker":
                response_text = "control completed: restart-worker\nstatus: restarting"
            response = Task(
                id=request.id,
                from_="worker-a",
                to="commander",
                status="completed",
                messages=[
                    Message(role="agent", parts=[{"type": "text", "text": response_text}])
                ],
                control=request.control,
            )
            response_topic = topics.cmd_response_topic("team", "worker-a")
            self.subscriptions[response_topic](
                response_topic,
                response.to_json().encode("utf-8"),
                None,
            )
        return FakePublishInfo()

    def disconnect(self) -> None:
        return None


class FakeStaleWorkerRaceMqttClient(FakeMqttClient):
    """Emit an old-worker failure before the PID-owner cleanup report."""

    def publish(
        self,
        topic: str,
        payload: str,
        *,
        qos: int,
        retain: bool,
    ) -> FakePublishInfo:
        if topic == topics.cmd_request_topic("team", "worker-a"):
            self.request_payload = payload
            request = Task.from_json(payload)
            response_topic = topics.cmd_response_topic("team", "worker-a")
            old_response = Task(
                id=request.id,
                from_="worker-a",
                to="commander",
                status="failed",
                messages=[
                    Message(
                        role="agent",
                        parts=[
                            {
                                "type": "text",
                                "text": "unsupported control: kill-stale-workers",
                            }
                        ],
                    )
                ],
                control=request.control,
            )
            owner_response = Task(
                id=request.id,
                from_="worker-a",
                to="commander",
                status="completed",
                messages=[
                    Message(
                        role="agent",
                        parts=[
                            {
                                "type": "text",
                                "text": "control completed: kill-stale-workers\n"
                                "remaining workers: 1",
                            }
                        ],
                    )
                ],
                control=request.control,
            )
            self.subscriptions[response_topic](
                response_topic,
                old_response.to_json().encode("utf-8"),
                None,
            )
            self.subscriptions[response_topic](
                response_topic,
                owner_response.to_json().encode("utf-8"),
                None,
            )
        return FakePublishInfo()


class FakeWorkerMqttClient:
    def __init__(self) -> None:
        self.subscriptions = {}
        self.request_subscribed = threading.Event()
        self.working_published = threading.Event()
        self.response_published = threading.Event()
        self.final_status_published = threading.Event()
        self.failed_status_published = threading.Event()
        self.response_status = ""
        self.response_task: Task | None = None
        self.task_statuses: list[Task] = []

    def subscribe(self, topic: str, callback) -> None:
        self.subscriptions[topic] = callback
        if topic == topics.cmd_request_topic("team", "worker-a"):
            self.request_subscribed.set()

    def publish(
        self,
        topic: str,
        payload: str,
        *,
        qos: int,
        retain: bool,
    ) -> FakePublishInfo:
        if topic == topics.cmd_task_status_topic("team", "worker-a"):
            task = Task.from_json(payload)
            self.task_statuses.append(task)
            if task.status == "working":
                self.working_published.set()
                return FakeUnacknowledgedPublishInfo()
            self.final_status_published.set()
            if task.status == "failed":
                self.failed_status_published.set()
        elif topic == topics.cmd_response_topic("team", "worker-a"):
            self.response_task = Task.from_json(payload)
            self.response_status = self.response_task.status
            self.response_published.set()
        return FakePublishInfo()

    def disconnect(self) -> None:
        return None

    def loop_stop(self) -> None:
        return None


def _run_worker_task(
    monkeypatch,
    config: AgentConfig,
    fake_client: FakeWorkerMqttClient,
    task: Task,
) -> Task:
    stop = threading.Event()
    worker_errors: list[BaseException] = []

    monkeypatch.setattr(runtime, "load_config", lambda: config)
    monkeypatch.setattr(runtime, "_connected_client", lambda *args: fake_client)
    monkeypatch.setattr(runtime, "_install_signal_handlers", lambda event: None)
    monkeypatch.setattr(runtime, "start_runtime_auto_update_monitor", lambda event: None)

    def run() -> None:
        try:
            runtime.run_worker(stop)
        except BaseException as exc:
            worker_errors.append(exc)

    worker_thread = threading.Thread(target=run, name="worker-control-test")
    worker_thread.start()
    try:
        assert fake_client.request_subscribed.wait(2)
        request_topic = topics.cmd_request_topic("team", "worker-a")
        fake_client.subscriptions[request_topic](
            request_topic,
            task.to_json().encode("utf-8"),
            None,
        )
        assert fake_client.response_published.wait(2)
        assert fake_client.final_status_published.wait(2)
    finally:
        stop.set()
        worker_thread.join(timeout=3)

    assert not worker_thread.is_alive()
    assert worker_errors == []
    assert fake_client.response_task is not None
    return fake_client.response_task
