"""Tests for infrastructure readiness checks."""

from __future__ import annotations

import importlib
import os
import subprocess

import httpx
import pytest

from agent_preflight.health import (
    InfraCheck,
    _parse_version,
    _resolve_dotpath,
    docker_health,
    env_vars,
    git_version,
    http_health,
)
from agent_preflight.models import InfraResult
from agent_preflight.preflight import preflight

health_mod = importlib.import_module("agent_preflight.health")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

class _FakeResponse:
    def __init__(self, *, status_code=200, json_data=None, raises_json=False):
        self.status_code = status_code
        self._json_data = json_data or {}
        self._raises_json = raises_json

    def json(self):
        if self._raises_json:
            raise ValueError("not JSON")
        return self._json_data


class _FakeSubprocessResult:
    def __init__(self, *, returncode=0, stdout="", stderr=""):
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr


# ---------------------------------------------------------------------------
# _parse_version
# ---------------------------------------------------------------------------

class TestParseVersion:
    def test_simple_semver(self):
        assert _parse_version("1.2.3") == (1, 2, 3)

    def test_v_prefix(self):
        assert _parse_version("v0.5.1") == (0, 5, 1)

    def test_two_part(self):
        assert _parse_version("1.0") == (1, 0)

    def test_non_numeric_segments_dropped(self):
        assert _parse_version("1.2.3-rc1") == (1, 2, 3)

    def test_empty_string(self):
        assert _parse_version("") == ()


# ---------------------------------------------------------------------------
# _resolve_dotpath
# ---------------------------------------------------------------------------

class TestResolveDotpath:
    def test_simple_key(self):
        assert _resolve_dotpath({"a": 1}, "a") == 1

    def test_nested(self):
        assert _resolve_dotpath({"a": {"b": {"c": True}}}, "a.b.c") is True

    def test_missing_raises(self):
        with pytest.raises(KeyError):
            _resolve_dotpath({"a": 1}, "b")

    def test_non_dict_raises(self):
        with pytest.raises(KeyError):
            _resolve_dotpath({"a": 42}, "a.b")


# ---------------------------------------------------------------------------
# http_health
# ---------------------------------------------------------------------------

class TestHttpHealth:
    def test_healthy_endpoint_passes(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"status": "ok"}),
        )
        check = http_health("http://localhost:3100/health")
        result = check.check()
        assert result.ok is True

    def test_unreachable_endpoint_fails(self, monkeypatch):
        def raise_connect(*a, **kw):
            raise httpx.ConnectError("Connection refused")
        monkeypatch.setattr(health_mod.httpx, "get", raise_connect)
        result = http_health("http://localhost:3100/health").check()
        assert result.ok is False
        assert "Connection refused" in result.detail

    def test_timeout_fails(self, monkeypatch):
        def raise_timeout(*a, **kw):
            raise httpx.TimeoutException("timed out")
        monkeypatch.setattr(health_mod.httpx, "get", raise_timeout)
        result = http_health("http://localhost:3100/health", timeout=2.0).check()
        assert result.ok is False
        assert "timed out" in result.detail

    def test_non_200_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(status_code=503),
        )
        result = http_health("http://localhost:3100/health").check()
        assert result.ok is False
        assert "503" in result.detail

    def test_non_json_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(raises_json=True),
        )
        result = http_health("http://localhost:3100/health").check()
        assert result.ok is False
        assert "not valid JSON" in result.detail

    def test_require_field_match_passes(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"llm": {"configured": True}}),
        )
        result = http_health(
            "http://localhost:3100/health",
            require={"llm.configured": True},
        ).check()
        assert result.ok is True

    def test_require_field_mismatch_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"llm": {"configured": False}}),
        )
        result = http_health(
            "http://localhost:3100/health",
            require={"llm.configured": True},
        ).check()
        assert result.ok is False
        assert "llm.configured" in result.detail

    def test_require_missing_field_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"status": "ok"}),
        )
        result = http_health(
            "http://localhost:3100/health",
            require={"llm.configured": True},
        ).check()
        assert result.ok is False
        assert "missing field" in result.detail

    def test_min_version_met_passes(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"version": "0.6.0"}),
        )
        result = http_health(
            "http://localhost:3100/health",
            min_version="0.5.0",
        ).check()
        assert result.ok is True
        assert "0.6.0" in result.detail

    def test_min_version_below_warns(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"version": "0.4.0"}),
        )
        result = http_health(
            "http://localhost:3100/health",
            min_version="0.5.0",
        ).check()
        assert result.ok is None
        assert "0.4.0" in result.detail
        assert "0.5.0" in result.detail

    def test_min_version_no_version_in_response(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.httpx, "get",
            lambda *a, **kw: _FakeResponse(json_data={"status": "ok"}),
        )
        result = http_health(
            "http://localhost:3100/health",
            min_version="0.5.0",
        ).check()
        # Service is reachable, no version key to compare — pass
        assert result.ok is True

    def test_default_name_from_url(self):
        check = http_health("http://localhost:3100/health")
        assert "localhost:3100" in check.name

    def test_custom_name(self):
        check = http_health("http://localhost:3100/health", name="Open LOS")
        assert check.name == "Open LOS"

    def test_hint_propagated_on_failure(self, monkeypatch):
        def raise_connect(*a, **kw):
            raise httpx.ConnectError("refused")
        monkeypatch.setattr(health_mod.httpx, "get", raise_connect)
        result = http_health(
            "http://localhost:3100/health",
            hint="start the LOS server",
        ).check()
        assert result.ok is False
        assert result.hint == "start the LOS server"


# ---------------------------------------------------------------------------
# env_vars
# ---------------------------------------------------------------------------

class TestEnvVars:
    def test_all_present_passes(self, monkeypatch):
        monkeypatch.setenv("TEST_VAR_A", "val")
        monkeypatch.setenv("TEST_VAR_B", "val")
        result = env_vars("TEST_VAR_A", "TEST_VAR_B").check()
        assert result.ok is True
        assert "2 vars present" in result.detail

    def test_one_missing_fails(self, monkeypatch):
        monkeypatch.setenv("TEST_VAR_A", "val")
        monkeypatch.delenv("TEST_VAR_MISSING", raising=False)
        result = env_vars("TEST_VAR_A", "TEST_VAR_MISSING").check()
        assert result.ok is False
        assert "TEST_VAR_MISSING" in result.detail

    def test_all_missing_fails(self, monkeypatch):
        monkeypatch.delenv("MISSING_A", raising=False)
        monkeypatch.delenv("MISSING_B", raising=False)
        result = env_vars("MISSING_A", "MISSING_B").check()
        assert result.ok is False
        assert "MISSING_A" in result.detail
        assert "MISSING_B" in result.detail

    def test_empty_value_counts_as_present(self, monkeypatch):
        monkeypatch.setenv("EMPTY_VAR", "")
        result = env_vars("EMPTY_VAR").check()
        assert result.ok is True

    def test_hint_on_failure(self, monkeypatch):
        monkeypatch.delenv("MISSING_X", raising=False)
        result = env_vars("MISSING_X", hint="check .env file").check()
        assert result.hint == "check .env file"


# ---------------------------------------------------------------------------
# docker_health
# ---------------------------------------------------------------------------

class TestDockerHealth:
    def test_running_passes(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.subprocess, "run",
            lambda *a, **kw: _FakeSubprocessResult(stdout="running\n"),
        )
        result = docker_health("my-app").check()
        assert result.ok is True
        assert "running" in result.detail

    def test_not_found_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.subprocess, "run",
            lambda *a, **kw: _FakeSubprocessResult(returncode=1, stderr="not found"),
        )
        result = docker_health("my-app").check()
        assert result.ok is False
        assert "not found" in result.detail

    def test_exited_fails(self, monkeypatch):
        monkeypatch.setattr(
            health_mod.subprocess, "run",
            lambda *a, **kw: _FakeSubprocessResult(stdout="exited\n"),
        )
        result = docker_health("my-app").check()
        assert result.ok is False
        assert "exited" in result.detail

    def test_docker_not_installed(self, monkeypatch):
        def raise_fnf(*a, **kw):
            raise FileNotFoundError("docker")
        monkeypatch.setattr(health_mod.subprocess, "run", raise_fnf)
        result = docker_health("my-app").check()
        assert result.ok is False
        assert "docker CLI not found" in result.detail

    def test_subprocess_timeout(self, monkeypatch):
        def raise_timeout(*a, **kw):
            raise subprocess.TimeoutExpired("docker", 5)
        monkeypatch.setattr(health_mod.subprocess, "run", raise_timeout)
        result = docker_health("my-app").check()
        assert result.ok is False
        assert "timed out" in result.detail

    def test_default_name(self):
        check = docker_health("los-server")
        assert check.name == "Docker los-server"

    def test_custom_name(self):
        check = docker_health("los-server", name="LOS Container")
        assert check.name == "LOS Container"


# ---------------------------------------------------------------------------
# git_version
# ---------------------------------------------------------------------------

class TestGitVersion:
    def _mock_git(self, monkeypatch, *, rev_parse_ok=True, describe_tag="v0.6.0", describe_ok=True):
        """Helper to mock sequential subprocess.run calls for git commands."""
        calls = []

        def fake_run(cmd, **kw):
            calls.append(cmd)
            if "rev-parse" in cmd:
                if rev_parse_ok:
                    return _FakeSubprocessResult(stdout=".git\n")
                return _FakeSubprocessResult(returncode=128, stderr="not a git repo")
            if "describe" in cmd:
                if describe_ok:
                    return _FakeSubprocessResult(stdout=f"{describe_tag}\n")
                return _FakeSubprocessResult(returncode=128, stderr="no tags")
            return _FakeSubprocessResult()

        monkeypatch.setattr(health_mod.subprocess, "run", fake_run)
        return calls

    def test_tag_meets_minimum_passes(self, monkeypatch):
        self._mock_git(monkeypatch, describe_tag="v0.6.0")
        result = git_version("/tmp/repo", min_tag="v0.5.0").check()
        assert result.ok is True
        assert "v0.6.0" in result.detail

    def test_tag_below_minimum_warns(self, monkeypatch):
        self._mock_git(monkeypatch, describe_tag="v0.4.0")
        result = git_version("/tmp/repo", min_tag="v0.5.0").check()
        assert result.ok is None
        assert "v0.4.0" in result.detail
        assert "v0.5.0" in result.detail

    def test_no_tags_warns(self, monkeypatch):
        self._mock_git(monkeypatch, describe_ok=False)
        result = git_version("/tmp/repo", min_tag="v0.5.0").check()
        assert result.ok is None
        assert "no tags found" in result.detail

    def test_not_a_git_repo_fails(self, monkeypatch):
        self._mock_git(monkeypatch, rev_parse_ok=False)
        result = git_version("/tmp/not-a-repo", min_tag="v0.5.0").check()
        assert result.ok is False
        assert "not a git repo" in result.detail

    def test_git_not_installed(self, monkeypatch):
        def raise_fnf(*a, **kw):
            raise FileNotFoundError("git")
        monkeypatch.setattr(health_mod.subprocess, "run", raise_fnf)
        result = git_version("/tmp/repo").check()
        assert result.ok is False
        assert "git CLI not found" in result.detail

    def test_no_constraints_validates_repo_exists(self, monkeypatch):
        self._mock_git(monkeypatch)
        result = git_version("/tmp/repo").check()
        assert result.ok is True

    def test_default_name(self):
        check = git_version("/tmp/my-repo")
        assert check.name == "Git my-repo"


# ---------------------------------------------------------------------------
# InfraCheck subclassing
# ---------------------------------------------------------------------------

class TestInfraCheckSubclass:
    def test_custom_subclass_works(self):
        class MyCheck(InfraCheck):
            @property
            def name(self) -> str:
                return "My service"

            def check(self) -> InfraResult:
                return InfraResult(ok=True, detail="all good")

        check = MyCheck()
        assert check.name == "My service"
        result = check.check()
        assert result.ok is True

    def test_cannot_instantiate_abstract(self):
        with pytest.raises(TypeError):
            InfraCheck()  # type: ignore[abstract]


# ---------------------------------------------------------------------------
# Preflight integration
# ---------------------------------------------------------------------------

class TestPreflightInfraIntegration:
    def _make_check(self, ok, detail="", hint=""):
        class _Check(InfraCheck):
            @property
            def name(self_):
                return "Test check"

            def check(self_):
                return InfraResult(ok=ok, detail=detail, hint=hint)

        return _Check()

    def test_infra_failure_in_failures_list(self):
        failures = preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[self._make_check(False, "service down", "restart it")],
        )
        assert len(failures) == 1
        assert "service down" in failures[0]
        assert "restart it" in failures[0]

    def test_infra_warning_not_in_failures(self):
        failures = preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[self._make_check(None, "old version")],
        )
        assert failures == []

    def test_infra_pass_not_in_failures(self):
        failures = preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[self._make_check(True, "all good")],
        )
        assert failures == []

    def test_infra_exception_caught(self):
        class _Broken(InfraCheck):
            @property
            def name(self):
                return "Broken"

            def check(self):
                raise RuntimeError("boom")

        failures = preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[_Broken()],
        )
        assert len(failures) == 1
        assert "boom" in failures[0]

    def test_infra_hint_in_output(self, capsys):
        preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[self._make_check(False, "missing key", "export API_KEY")],
        )
        out = capsys.readouterr().out
        assert "export API_KEY" in out

    def test_infra_warning_hint_in_output(self, capsys):
        preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
            infra=[self._make_check(None, "old version", "consider upgrading")],
        )
        out = capsys.readouterr().out
        assert "consider upgrading" in out

    def test_no_infra_is_noop(self):
        failures = preflight(
            admin_key="admin",
            or_key="or",
            models=["m"],
            budget_per_model=0.01,
            checks=[],
            raise_on_failure=False,
        )
        assert failures == []
