"""Tests for the deploy runner's validation / result-reporting helper.

The validator is the only gate between a JSON file that arrived over the
network and `docker compose up` running on the node, so its rejection cases
matter more than its happy path. The emit-result tests pin the event format
against the control-plane executor's actual parser — if either side drifts,
redeploy actions would hang in running/ until they time out.
"""
from __future__ import annotations

import json
import shlex
import sys
import time
from pathlib import Path

import pytest

_JOB_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_JOB_DIR))

import action as action_mod  # noqa: E402
from action import Invalid, emit_result, validate  # noqa: E402


@pytest.fixture
def repo(tmp_path: Path) -> Path:
    """Minimal repo: node piha declares vikunja, which has a compose file."""
    repo = tmp_path / "repo"
    (repo / "hosts" / "piha").mkdir(parents=True)
    (repo / "hosts" / "piha" / "services.yaml").write_text(
        "host: piha\nservices:\n  vikunja:\n    role: task-tracker\n  node-agent:\n    role: monitor\n"
    )
    (repo / "services" / "vikunja").mkdir(parents=True)
    (repo / "services" / "vikunja" / "docker-compose.yml").write_text("services: {}\n")
    (repo / "services" / "gokapi").mkdir(parents=True)
    (repo / "services" / "gokapi" / "docker-compose.yml").write_text("services: {}\n")
    return repo


def _action_file(tmp_path: Path, **overrides) -> Path:
    action = {
        "action_id": "redeploy-piha-vikunja",
        "type": "redeploy",
        "node": "piha",
        "service": "vikunja",
    }
    action.update(overrides)
    path = tmp_path / "redeploy-piha-vikunja.json"
    path.write_text(json.dumps(action))
    return path


# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------

def test_valid_action_returns_id_and_service(tmp_path, repo):
    action_id, service = validate(_action_file(tmp_path), "piha", repo)
    assert action_id == "redeploy-piha-vikunja"
    assert service == "vikunja"


def test_action_for_another_node_is_refused(tmp_path, repo):
    path = _action_file(tmp_path, node="solaria")
    with pytest.raises(Invalid, match="addressed to node"):
        validate(path, "piha", repo)


def test_non_redeploy_type_is_refused(tmp_path, repo):
    """The runner executes redeploys only — container_restart stays node-agent's
    job, and anything else must never reach a shell."""
    path = _action_file(tmp_path, type="container_restart")
    with pytest.raises(Invalid, match="not executable by the deploy runner"):
        validate(path, "piha", repo)


def test_service_not_in_desired_state_is_refused(tmp_path, repo):
    """gokapi has a compose file in the repo but is not in hosts/piha —
    desired state is the authority on what this node may run."""
    path = _action_file(tmp_path, service="gokapi")
    with pytest.raises(Invalid, match="not in hosts/piha/services.yaml"):
        validate(path, "piha", repo)


@pytest.mark.parametrize("bad", ["../../etc/passwd", "/absolute", "Vikunja", "", "a b", "x" * 65])
def test_malformed_service_names_are_refused(tmp_path, repo, bad):
    path = _action_file(tmp_path, service=bad)
    with pytest.raises(Invalid, match="invalid service name"):
        validate(path, "piha", repo)


def test_service_without_compose_file_is_refused(tmp_path, repo):
    (repo / "services" / "vikunja" / "docker-compose.yml").unlink()
    with pytest.raises(Invalid, match="no compose file"):
        validate(_action_file(tmp_path), "piha", repo)


def test_missing_manifest_for_own_node_is_refused(tmp_path, repo):
    (repo / "hosts" / "piha" / "services.yaml").unlink()
    with pytest.raises(Invalid, match="no desired-state manifest"):
        validate(_action_file(tmp_path), "piha", repo)


def test_services_as_list_is_supported(tmp_path, repo):
    (repo / "hosts" / "piha" / "services.yaml").write_text("host: piha\nservices:\n  - vikunja\n")
    _, service = validate(_action_file(tmp_path), "piha", repo)
    assert service == "vikunja"


def test_unparseable_file_is_refused(tmp_path, repo):
    path = tmp_path / "broken.json"
    path.write_text("{not json")
    with pytest.raises(Invalid, match="unparseable"):
        validate(path, "piha", repo)


def test_invalid_action_id_is_refused(tmp_path, repo):
    path = _action_file(tmp_path, action_id="../escape")
    with pytest.raises(Invalid, match="invalid action_id"):
        validate(path, "piha", repo)


# ---------------------------------------------------------------------------
# validate via the CLI (shell-assignment contract used by deploy-runner.sh)
# ---------------------------------------------------------------------------

def _eval_cli(argv) -> dict:
    """Parse the KEY='value' lines the bash runner evaluates."""
    import io
    import contextlib

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        rc = action_mod.main(argv)
    assert rc == 0
    out = {}
    for line in buf.getvalue().splitlines():
        key, _, raw = line.partition("=")
        out[key] = shlex.split(raw)[0] if raw and raw != "''" else ""
    return out


def test_cli_valid_action_emits_ok_true(tmp_path, repo):
    path = _action_file(tmp_path)
    result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)])
    assert result["OK"] == "true"
    assert result["SERVICE"] == "vikunja"
    assert result["ACTION_ID"] == "redeploy-piha-vikunja"
    assert result["ERROR"] == ""


def test_cli_rejected_action_still_reports_action_id(tmp_path, repo):
    """So the runner can report a failure result instead of silently dropping
    the action — a dropped action would sit in running/ until it times out."""
    path = _action_file(tmp_path, service="gokapi")
    result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)])
    assert result["OK"] == "false"
    assert result["ACTION_ID"] == "redeploy-piha-vikunja"
    assert "not in hosts/piha/services.yaml" in result["ERROR"]


def test_cli_quotes_hostile_error_content(tmp_path, repo):
    """A crafted service name must not be able to inject shell syntax through
    the eval'd ERROR assignment."""
    path = _action_file(tmp_path, service="'; touch /tmp/pwned; '")
    result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)])
    assert result["OK"] == "false"
    assert "touch /tmp/pwned" in result["ERROR"]  # inert data, not a command


# ---------------------------------------------------------------------------
# emit-result — format compatibility with the control-plane executor
# ---------------------------------------------------------------------------

def test_emit_result_shape(tmp_path):
    events = tmp_path / "events" / "piha"
    path = emit_result(events, "piha", "redeploy-piha-vikunja", "vikunja", True, "")
    event = json.loads(path.read_text())
    assert event["type"] == "action_result"
    assert event["node"] == "piha"
    assert event["severity"] == "info"
    assert event["payload"]["action_id"] == "redeploy-piha-vikunja"
    assert event["payload"]["success"] is True
    assert event["payload"]["source"] == "deploy-runner"


def test_emit_result_failure_carries_error(tmp_path):
    events = tmp_path / "events" / "piha"
    path = emit_result(events, "piha", "act-1", "vikunja", False, "compose exited 1")
    event = json.loads(path.read_text())
    assert event["severity"] == "high"
    assert event["payload"]["success"] is False
    assert event["payload"]["error"] == "compose exited 1"


def test_executor_finds_the_emitted_result(tmp_path, monkeypatch):
    """The real consumer: control-plane executor._find_action_result(). This is
    the contract that makes a redeploy resolve instead of timing out."""
    executor_src = _JOB_DIR.parents[1] / "services" / "control-plane" / "src"
    sys.path.insert(0, str(executor_src))
    import executor as executor_mod

    events_root = tmp_path / "events"
    monkeypatch.setattr(executor_mod, "EVENTS_DIR", events_root)
    monkeypatch.setattr(executor_mod, "ACTIONS_DIR", tmp_path / "actions")
    monkeypatch.setattr(executor_mod, "DISPATCH_DIR", tmp_path / "actions" / "dispatch")
    monkeypatch.setattr(executor_mod, "DEPLOY_DISPATCH_DIR", tmp_path / "actions" / "deploy")

    started_at = time.time()
    emit_result(events_root / "piha", "piha", "redeploy-piha-vikunja", "vikunja", True, "")

    payload = executor_mod.Executor()._find_action_result(
        "piha", "redeploy-piha-vikunja", started_at
    )
    assert payload is not None
    assert payload["success"] is True


def test_executor_ignores_result_older_than_the_action(tmp_path, monkeypatch):
    """Action IDs are deterministic and repeat, so a result from a previous run
    must not resolve the current one."""
    executor_src = _JOB_DIR.parents[1] / "services" / "control-plane" / "src"
    sys.path.insert(0, str(executor_src))
    import executor as executor_mod

    events_root = tmp_path / "events"
    monkeypatch.setattr(executor_mod, "EVENTS_DIR", events_root)
    monkeypatch.setattr(executor_mod, "ACTIONS_DIR", tmp_path / "actions")
    monkeypatch.setattr(executor_mod, "DISPATCH_DIR", tmp_path / "actions" / "dispatch")
    monkeypatch.setattr(executor_mod, "DEPLOY_DISPATCH_DIR", tmp_path / "actions" / "deploy")

    emit_result(events_root / "piha", "piha", "redeploy-piha-vikunja", "vikunja", True, "")
    started_at = time.time() + 60  # action started after the event was written

    assert executor_mod.Executor()._find_action_result(
        "piha", "redeploy-piha-vikunja", started_at
    ) is None
