from __future__ import annotations

import json
import subprocess
from unittest.mock import MagicMock

import pytest

from atlas_runner._docker_cli import DockerCLI
from atlas_runner.errors import ErrorCode, RunnerError
from tests.conftest import make_completed, make_inspect_json

# ------------------------------------------------------------------
# Construction / docker host
# ------------------------------------------------------------------


class TestDockerCLIInit:
    def test_default_base_command(self, mock_subprocess: MagicMock) -> None:
        cli = DockerCLI()
        assert cli._base == ["docker"]

    def test_custom_host(self, mock_subprocess: MagicMock) -> None:
        cli = DockerCLI(docker_host="tcp://remote:2375")
        assert cli._base == ["docker", "--host", "tcp://remote:2375"]

    def test_host_included_in_commands(self, mock_subprocess: MagicMock) -> None:
        cli = DockerCLI(docker_host="tcp://remote:2375")
        mock_subprocess.return_value = make_completed(stdout="img_pulled\n")
        cli.pull("alpine")
        cmd = mock_subprocess.call_args[0][0]
        assert cmd[:3] == ["docker", "--host", "tcp://remote:2375"]


# ------------------------------------------------------------------
# _run error detection
# ------------------------------------------------------------------


class TestRunErrorDetection:
    def test_daemon_unavailable(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(
            returncode=1,
            stderr="Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
        )
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli._run(["ps"], check=True)
        assert exc_info.value.code == ErrorCode.DOCKER_DAEMON_UNAVAILABLE

    def test_generic_failure(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(
            returncode=1, stderr="something went wrong"
        )
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli._run(["bad"], check=True)
        assert exc_info.value.code == ErrorCode.BACKEND_ERROR

    def test_timeout(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.side_effect = subprocess.TimeoutExpired(cmd="docker", timeout=5)
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli._run(["pull", "big-image"], check=True, timeout=5)
        assert exc_info.value.code == ErrorCode.BACKEND_ERROR
        assert "timed out" in exc_info.value.message

    def test_check_false_does_not_raise(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(returncode=1, stderr="error")
        cli = DockerCLI()
        result = cli._run(["stop", "x"], check=False)
        assert result.returncode == 1


# ------------------------------------------------------------------
# login
# ------------------------------------------------------------------


class TestLogin:
    def test_command_and_stdin(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed()
        cli = DockerCLI()
        cli.login("registry.example.com", "user", "s3cret")
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == [
            "docker",
            "login",
            "--username",
            "user",
            "--password-stdin",
            "registry.example.com",
        ]
        # Password passed via stdin, never in args
        assert "s3cret" not in cmd
        assert mock_subprocess.call_args[1]["input"] == "s3cret"

    def test_login_failure_maps_to_login_failed(
        self, mock_subprocess: MagicMock
    ) -> None:
        mock_subprocess.return_value = make_completed(
            returncode=1, stderr="unauthorized"
        )
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli.login("reg", "user", "bad")
        assert exc_info.value.code == ErrorCode.LOGIN_FAILED


# ------------------------------------------------------------------
# pull
# ------------------------------------------------------------------


class TestPull:
    def test_pull_command(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed()
        cli = DockerCLI()
        cli.pull("alpine:latest")
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "pull", "alpine:latest"]

    def test_pull_failure_maps_to_image_pull_failed(
        self, mock_subprocess: MagicMock
    ) -> None:
        mock_subprocess.return_value = make_completed(returncode=1, stderr="not found")
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli.pull("bad-image")
        assert exc_info.value.code == ErrorCode.IMAGE_PULL_FAILED


# ------------------------------------------------------------------
# run
# ------------------------------------------------------------------


class TestRun:
    def test_returns_container_id(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="abc123def\n")
        cli = DockerCLI()
        cid = cli.run(["-d", "--name", "test", "alpine"])
        assert cid == "abc123def"
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "run", "-d", "--name", "test", "alpine"]


# ------------------------------------------------------------------
# stop / rm
# ------------------------------------------------------------------


class TestStopRm:
    def test_stop_command(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed()
        cli = DockerCLI()
        cli.stop("mycontainer")
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "stop", "mycontainer"]

    def test_stop_does_not_raise_on_failure(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(returncode=1, stderr="not found")
        cli = DockerCLI()
        cli.stop("gone")  # should not raise

    def test_rm_command(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed()
        cli = DockerCLI()
        cli.rm("mycontainer")
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "rm", "mycontainer"]

    def test_rm_does_not_raise_on_failure(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(returncode=1, stderr="not found")
        cli = DockerCLI()
        cli.rm("gone")  # should not raise


# ------------------------------------------------------------------
# inspect
# ------------------------------------------------------------------


class TestInspect:
    def test_returns_parsed_dict(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout=make_inspect_json())
        cli = DockerCLI()
        data = cli.inspect("test")
        assert data["Id"] == "abc123"
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "inspect", "test"]


# ------------------------------------------------------------------
# ps
# ------------------------------------------------------------------


class TestPs:
    def test_parses_ndjson(self, mock_subprocess: MagicMock) -> None:
        lines = "\n".join(
            [
                json.dumps({"ID": "a1", "Names": "c1"}),
                json.dumps({"ID": "b2", "Names": "c2"}),
            ]
        )
        mock_subprocess.return_value = make_completed(stdout=lines)
        cli = DockerCLI()
        result = cli.ps()
        assert len(result) == 2
        assert result[0]["ID"] == "a1"

    def test_filters_passed(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="")
        cli = DockerCLI()
        cli.ps(filters=["label=project=atlas"])
        cmd = mock_subprocess.call_args[0][0]
        assert "--filter" in cmd
        assert "label=project=atlas" in cmd

    def test_empty_output(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="")
        cli = DockerCLI()
        result = cli.ps()
        assert result == []


# ------------------------------------------------------------------
# logs
# ------------------------------------------------------------------


class TestLogs:
    def test_returns_stdout(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="line1\nline2\n")
        cli = DockerCLI()
        output = cli.logs("test")
        assert output == "line1\nline2\n"
        cmd = mock_subprocess.call_args[0][0]
        assert cmd == ["docker", "logs", "test"]


# ------------------------------------------------------------------
# container_exists
# ------------------------------------------------------------------


class TestContainerExists:
    def test_exists(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="abc123\n")
        cli = DockerCLI()
        assert cli.container_exists("mycontainer") is True

    def test_not_exists(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="")
        cli = DockerCLI()
        assert cli.container_exists("missing") is False

    def test_uses_exact_name_filter(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="")
        cli = DockerCLI()
        cli.container_exists("mycontainer")
        cmd = mock_subprocess.call_args[0][0]
        assert "name=^/mycontainer$" in cmd
        assert "{{.ID}}" in cmd

    def test_escapes_regex_metacharacters(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(stdout="")
        cli = DockerCLI()
        cli.container_exists("my.container")
        cmd = mock_subprocess.call_args[0][0]
        assert r"name=^/my\.container$" in cmd

    def test_raises_on_daemon_failure(self, mock_subprocess: MagicMock) -> None:
        mock_subprocess.return_value = make_completed(
            returncode=1,
            stderr="Cannot connect to the Docker daemon",
        )
        cli = DockerCLI()
        with pytest.raises(RunnerError) as exc_info:
            cli.container_exists("test")
        assert exc_info.value.code == ErrorCode.DOCKER_DAEMON_UNAVAILABLE
