from __future__ import annotations

import json
import re
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch

import pytest
from typer.testing import CliRunner

from atlas_runner.cli import app
from atlas_runner.errors import ErrorCode, RunnerError
from atlas_runner.models import ContainerState, ContainerStatus, RunResult

runner = CliRunner()

_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")


def _strip_ansi(text: str) -> str:
    """Remove ANSI escape sequences from text."""
    return _ANSI_RE.sub("", text)


# ------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _mock_runner() -> MagicMock:
    """Patch create_backend so every command uses a mock backend."""
    mock_backend = MagicMock()
    with patch("atlas_runner.cli.create_backend", return_value=mock_backend):
        yield mock_backend


def _ts() -> datetime:
    return datetime(2026, 1, 1, tzinfo=UTC)


# ------------------------------------------------------------------
# Global options
# ------------------------------------------------------------------


class TestHelp:
    def test_help(self) -> None:
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "atlas-runner" in result.output.lower() or "Atlas" in result.output

    def test_run_help(self) -> None:
        result = runner.invoke(app, ["run", "--help"])
        assert result.exit_code == 0
        plain = _strip_ansi(result.output)
        assert "--name" in plain
        assert "--image" in plain


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


class TestLogin:
    def test_login_with_password(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app, ["login", "registry.io", "-u", "user", "-p", "secret"]
        )
        assert result.exit_code == 0
        _mock_runner.login.assert_called_once()

    def test_login_password_stdin(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            ["login", "registry.io", "-u", "user", "--password-stdin"],
            input="secret\n",
        )
        assert result.exit_code == 0
        _mock_runner.login.assert_called_once()

    def test_login_no_password_fails(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["login", "registry.io", "-u", "user"])
        assert result.exit_code == 1

    def test_login_password_and_stdin_rejected(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            ["login", "registry.io", "-u", "user", "-p", "secret", "--password-stdin"],
            input="other\n",
        )
        assert result.exit_code != 0

    def test_login_no_password_json_fails(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["--json", "login", "registry.io", "-u", "user"])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert data["code"] == "LOGIN_FAILED"

    def test_login_json_output(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app, ["--json", "login", "registry.io", "-u", "user", "-p", "secret"]
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "ok"
        assert data["registry"] == "registry.io"

    def test_login_error(self, _mock_runner: MagicMock) -> None:
        _mock_runner.login.side_effect = RunnerError(
            code=ErrorCode.LOGIN_FAILED, message="bad creds"
        )
        result = runner.invoke(
            app, ["login", "registry.io", "-u", "user", "-p", "secret"]
        )
        assert result.exit_code == 1
        assert "bad creds" in result.output

    def test_login_error_json(self, _mock_runner: MagicMock) -> None:
        _mock_runner.login.side_effect = RunnerError(
            code=ErrorCode.LOGIN_FAILED, message="bad creds"
        )
        result = runner.invoke(
            app, ["--json", "login", "registry.io", "-u", "user", "-p", "secret"]
        )
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert data["code"] == "LOGIN_FAILED"


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


class TestPull:
    def test_pull(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["pull", "alpine:latest"])
        assert result.exit_code == 0
        _mock_runner.pull.assert_called_once_with("alpine:latest")

    def test_pull_json(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["--json", "pull", "alpine"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "ok"

    def test_pull_error(self, _mock_runner: MagicMock) -> None:
        _mock_runner.pull.side_effect = RunnerError(
            code=ErrorCode.IMAGE_PULL_FAILED, message="not found"
        )
        result = runner.invoke(app, ["pull", "bad:image"])
        assert result.exit_code == 1


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


class TestRun:
    def test_run_basic(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(app, ["run", "--name", "c1", "--image", "alpine"])
        assert result.exit_code == 0
        _mock_runner.run.assert_called_once()

    def test_run_json(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app, ["--json", "run", "--name", "c1", "--image", "alpine"]
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["container_id"] == "abc"
        assert data["name"] == "c1"

    def test_run_with_cmd(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--cmd",
                "echo",
                "--cmd",
                "hi",
            ],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.command == ["echo", "hi"]

    def test_run_with_shell(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "--shell", "echo hi && ls"],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.shell == "echo hi && ls"

    def test_run_with_env(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "-e", "FOO=bar", "-e", "X=1"],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.env == {"FOO": "bar", "X": "1"}

    def test_run_with_labels(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "-l", "project=atlas"],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.labels == {"project": "atlas"}

    def test_run_with_gpu(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "--gpu", "0", "--gpu", "1"],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.gpus == [0, 1]

    def test_run_with_mount(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--mount",
                "/data:/mnt/data:ro",
                "--mount",
                "/logs:/mnt/logs",
            ],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert len(spec.mounts) == 2
        assert spec.mounts[0].src == "/data"
        assert spec.mounts[0].ro is True
        assert spec.mounts[1].ro is False

    def test_run_with_port(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "--port", "8080:80"],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.ports == {"8080": 80}

    def test_run_invalid_mount_format(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            ["run", "--name", "c1", "--image", "alpine", "--mount", "/bad"],
        )
        assert result.exit_code != 0

    def test_run_invalid_mount_flag(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--mount",
                "/a:/b:rw",
            ],
        )
        assert result.exit_code != 0

    def test_run_invalid_port(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--port",
                "8080:abc",
            ],
        )
        assert result.exit_code != 0

    def test_run_cmd_and_shell_rejected(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--cmd",
                "echo",
                "--shell",
                "ls",
            ],
        )
        assert result.exit_code != 0

    def test_run_with_resources(self, _mock_runner: MagicMock) -> None:
        _mock_runner.run.return_value = RunResult(
            container_id="abc", name="c1", started_at=_ts()
        )
        result = runner.invoke(
            app,
            [
                "run",
                "--name",
                "c1",
                "--image",
                "alpine",
                "--cpu-cores",
                "4",
                "--memory-gb",
                "16",
                "--scratch",
                "/tmp/scratch",
                "--network",
                "host",
                "--workdir",
                "/app",
            ],
        )
        assert result.exit_code == 0
        spec = _mock_runner.run.call_args[0][0]
        assert spec.cpu_cores == 4
        assert spec.memory_gb == 16
        assert spec.scratch == "/tmp/scratch"
        assert spec.network == "host"
        assert spec.workdir == "/app"


# ------------------------------------------------------------------
# status
# ------------------------------------------------------------------


class TestStatus:
    def test_status(self, _mock_runner: MagicMock) -> None:
        _mock_runner.inspect.return_value = ContainerStatus(
            container_id="abc",
            name="c1",
            state=ContainerState.RUNNING,
            started_at=_ts(),
        )
        result = runner.invoke(app, ["status", "c1"])
        assert result.exit_code == 0

    def test_status_json(self, _mock_runner: MagicMock) -> None:
        _mock_runner.inspect.return_value = ContainerStatus(
            container_id="abc",
            name="c1",
            state=ContainerState.RUNNING,
            started_at=_ts(),
        )
        result = runner.invoke(app, ["--json", "status", "c1"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["state"] == "running"
        assert data["container_id"] == "abc"


# ------------------------------------------------------------------
# stop
# ------------------------------------------------------------------


class TestStop:
    def test_stop(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["stop", "c1"])
        assert result.exit_code == 0
        _mock_runner.stop.assert_called_once_with("c1")

    def test_stop_json(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["--json", "stop", "c1"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "ok"


# ------------------------------------------------------------------
# destroy
# ------------------------------------------------------------------


class TestDestroy:
    def test_destroy(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["destroy", "c1"])
        assert result.exit_code == 0
        _mock_runner.stop.assert_called_once_with("c1")
        _mock_runner.rm.assert_called_once_with("c1")

    def test_destroy_json(self, _mock_runner: MagicMock) -> None:
        result = runner.invoke(app, ["--json", "destroy", "c1"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "ok"


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


class TestLogs:
    def test_logs(self, _mock_runner: MagicMock) -> None:
        _mock_runner.logs.return_value = "hello world"
        result = runner.invoke(app, ["logs", "c1"])
        assert result.exit_code == 0
        assert "hello world" in result.output

    def test_logs_json(self, _mock_runner: MagicMock) -> None:
        _mock_runner.logs.return_value = "hello world"
        result = runner.invoke(app, ["--json", "logs", "c1"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["logs"] == "hello world"
        assert data["name"] == "c1"


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


class TestPs:
    def test_ps(self, _mock_runner: MagicMock) -> None:
        _mock_runner.ps.return_value = [
            ContainerStatus(
                container_id="a1",
                name="c1",
                state=ContainerState.RUNNING,
                started_at=_ts(),
            ),
        ]
        result = runner.invoke(app, ["ps"])
        assert result.exit_code == 0

    def test_ps_json(self, _mock_runner: MagicMock) -> None:
        _mock_runner.ps.return_value = [
            ContainerStatus(
                container_id="a1",
                name="c1",
                state=ContainerState.RUNNING,
                started_at=_ts(),
            ),
            ContainerStatus(
                container_id="b2",
                name="c2",
                state=ContainerState.EXITED,
                exit_code=0,
                started_at=_ts(),
            ),
        ]
        result = runner.invoke(app, ["--json", "ps"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == 2
        assert data[0]["container_id"] == "a1"
        assert data[1]["state"] == "exited"

    def test_ps_empty(self, _mock_runner: MagicMock) -> None:
        _mock_runner.ps.return_value = []
        result = runner.invoke(app, ["--json", "ps"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data == []


# ------------------------------------------------------------------
# Global options
# ------------------------------------------------------------------


class TestGlobalOptions:
    def test_backend_option(self, _mock_runner: MagicMock) -> None:
        with patch("atlas_runner.cli.create_backend") as mock_create:
            mock_create.return_value = _mock_runner
            _mock_runner.ps.return_value = []
            result = runner.invoke(app, ["--backend", "docker", "--json", "ps"])
            assert result.exit_code == 0
            mock_create.assert_called_once_with(backend_type="docker", docker_host=None)

    def test_docker_host_option(self, _mock_runner: MagicMock) -> None:
        with patch("atlas_runner.cli.create_backend") as mock_create:
            mock_create.return_value = _mock_runner
            _mock_runner.ps.return_value = []
            result = runner.invoke(
                app, ["--docker-host", "tcp://remote:2375", "--json", "ps"]
            )
            assert result.exit_code == 0
            mock_create.assert_called_once_with(
                backend_type="docker", docker_host="tcp://remote:2375"
            )
