"""Tests for NodeAgent remediation dispatch: pull_dispatched_actions /
process_dispatched_actions / _execute_dispatched_action.

Covers the security gates required by docs/backlog.md "PROJEKT: remediacja
bez SSH": the agent executes only actions addressed to its own node, refuses
anything outside the container_restart whitelist, never restarts its own
container, and treats a repeated dispatch of the same action_id as a no-op.
"""
from __future__ import annotations

import json
import shutil
import time
from unittest.mock import MagicMock

import pytest

import node_agent


@pytest.fixture(autouse=True)
def _isolated_runtime_state():
    """ACTIONS_DIR/STATE_DIR/EVENTS_DIR are a single temp dir shared for the
    whole test session (conftest.py sets RUNTIME_PATH once at import time).
    Clear the dispatch/processed-actions/events state before and after each
    test so tests don't leak into each other or other test modules."""
    def _clear():
        for d in (node_agent.ACTIONS_DIR, node_agent.EVENTS_DIR, node_agent.STATE_DIR):
            if d.exists():
                shutil.rmtree(d)
            d.mkdir(parents=True, exist_ok=True)

    _clear()
    yield
    _clear()


def _write_dispatch(agent, action_id, node, action_type="container_restart",
                     container_name="zigbee2mqtt"):
    inbox = agent._dispatch_inbox_dir()
    inbox.mkdir(parents=True, exist_ok=True)
    path = inbox / f"{action_id}.json"
    path.write_text(json.dumps({
        "action_id": action_id,
        "type": action_type,
        "node": node,
        "service": container_name,
        "container_name": container_name,
        "dispatched_at": time.time(),
    }))
    return path


def _fake_docker_client():
    client = MagicMock()
    container = MagicMock()
    client.containers.get.return_value = container
    return client, container


def _last_action_result_payload(agent):
    events_dir = agent._node_events_dir()
    files = sorted(events_dir.glob("evt-*-action_result-*.json"))
    assert files, "expected an action_result event to have been emitted"
    return json.loads(files[-1].read_text())["payload"]


# ---------------------------------------------------------------------------
# Node scoping
# ---------------------------------------------------------------------------

def test_executes_action_addressed_to_own_node(agent):
    agent.docker_client, container = _fake_docker_client()
    _write_dispatch(agent, "act-1", node=agent.node_name)

    agent.process_dispatched_actions()

    container.restart.assert_called_once()
    payload = _last_action_result_payload(agent)
    assert payload["action_id"] == "act-1"
    assert payload["success"] is True


def test_refuses_action_addressed_to_another_node(agent):
    agent.docker_client, container = _fake_docker_client()
    _write_dispatch(agent, "act-2", node="some-other-node")
    # Force it into THIS node's inbox directly (simulates a mis-delivery —
    # the dispatch dir is normally already scoped by node name).
    inbox = agent._dispatch_inbox_dir()
    action_file = inbox / "act-2.json"
    data = json.loads(action_file.read_text())
    assert data["node"] == "some-other-node"

    agent.process_dispatched_actions()

    container.restart.assert_not_called()
    payload = _last_action_result_payload(agent)
    assert payload["success"] is False
    assert "not '" + agent.node_name + "'" in payload["error"]


# ---------------------------------------------------------------------------
# Type whitelist
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("bad_type", ["redeploy", "disk_cleanup", "shell_exec", "alert_only"])
def test_rejects_non_whitelisted_action_types(agent, bad_type):
    agent.docker_client, container = _fake_docker_client()
    _write_dispatch(agent, f"act-{bad_type}", node=agent.node_name, action_type=bad_type)

    agent.process_dispatched_actions()

    container.restart.assert_not_called()
    payload = _last_action_result_payload(agent)
    assert payload["success"] is False
    assert "not whitelisted" in payload["error"]


def test_accepts_container_restart(agent):
    assert "container_restart" in node_agent.ALLOWED_DISPATCH_ACTION_TYPES
    assert node_agent.ALLOWED_DISPATCH_ACTION_TYPES == {"container_restart"}


# ---------------------------------------------------------------------------
# Self-restart guard
# ---------------------------------------------------------------------------

def test_refuses_to_restart_itself(agent):
    agent.docker_client, container = _fake_docker_client()
    _write_dispatch(agent, "act-self", node=agent.node_name, container_name="node-agent")

    agent.process_dispatched_actions()

    container.restart.assert_not_called()
    agent.docker_client.containers.get.assert_not_called()
    payload = _last_action_result_payload(agent)
    assert payload["success"] is False
    assert "restart itself" in payload["error"]


# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------

def test_same_action_id_executes_only_once(agent):
    agent.docker_client, container = _fake_docker_client()
    _write_dispatch(agent, "act-dup", node=agent.node_name)

    agent.process_dispatched_actions()
    assert container.restart.call_count == 1

    # Dispatch file is consumed after processing (real pipeline: rsync
    # --remove-source-files means it can't be re-pulled either), but even if
    # the SAME action_id is re-delivered, the processed marker must block it.
    _write_dispatch(agent, "act-dup", node=agent.node_name)
    agent.process_dispatched_actions()

    assert container.restart.call_count == 1, "action_id re-delivery must be a no-op"


def test_dispatch_file_removed_after_processing(agent):
    agent.docker_client, _ = _fake_docker_client()
    action_file = _write_dispatch(agent, "act-cleanup", node=agent.node_name)

    agent.process_dispatched_actions()

    assert not action_file.exists()


# ---------------------------------------------------------------------------
# Docker restart failure surfaces as a failed action_result
# ---------------------------------------------------------------------------

def test_docker_restart_exception_reports_failure(agent):
    client = MagicMock()
    client.containers.get.side_effect = Exception("no such container")
    agent.docker_client = client
    _write_dispatch(agent, "act-fail", node=agent.node_name)

    agent.process_dispatched_actions()

    payload = _last_action_result_payload(agent)
    assert payload["success"] is False
    assert "no such container" in payload["error"]


def test_no_docker_client_reports_failure(agent):
    agent.docker_client = None
    _write_dispatch(agent, "act-nodocker", node=agent.node_name)

    agent.process_dispatched_actions()

    payload = _last_action_result_payload(agent)
    assert payload["success"] is False
    assert "Docker SDK unavailable" in payload["error"]


# ---------------------------------------------------------------------------
# pull_dispatched_actions: rsync gating (mirrors test_ship_events_to_vps.py)
# ---------------------------------------------------------------------------

def test_pull_skips_when_vps_host_not_set(agent, monkeypatch):
    monkeypatch.setattr(node_agent, "VPS_EVENTS_HOST", "")
    fake_run = MagicMock()
    monkeypatch.setattr(node_agent.subprocess, "run", fake_run)

    agent.pull_dispatched_actions()

    fake_run.assert_not_called()


def test_pull_skips_on_vps_node(agent, monkeypatch):
    agent.node_name = node_agent.VPS_NODE_NAME
    fake_run = MagicMock()
    monkeypatch.setattr(node_agent.subprocess, "run", fake_run)

    agent.pull_dispatched_actions()

    fake_run.assert_not_called()


def test_pull_invokes_rsync_pull_direction(agent, monkeypatch):
    captured = {}

    def fake_run(cmd, **kwargs):
        captured["cmd"] = cmd
        return MagicMock(returncode=0, stderr="")

    monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
    agent.pull_dispatched_actions()

    cmd = captured["cmd"]
    assert cmd[0] == "rsync"
    assert "--remove-source-files" in cmd
    # Source (remote VPS) comes before destination (local inbox) — pull, not push.
    remote_arg = f"{node_agent.VPS_EVENTS_USER}@{node_agent.VPS_EVENTS_HOST}:"
    assert any(a.startswith(remote_arg) for a in cmd[:-1])
    assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/"


def test_pull_treats_empty_source_returncodes_as_non_error(agent, monkeypatch, caplog):
    def fake_run(cmd, **kwargs):
        return MagicMock(returncode=23, stderr="rsync: some vanished-source message")

    monkeypatch.setattr(node_agent.subprocess, "run", fake_run)

    with caplog.at_level("WARNING"):
        agent.pull_dispatched_actions()

    assert "Dispatch pull failed" not in caplog.text
