from __future__ import annotations

import ipaddress
import socket

import grpc
import pytest

import kyrodb
import kyrodb.client
from kyrodb import KyroDBClient, TLSConfig
from kyrodb.errors import DeadlineExceededError
from kyrodb.models import InsertItem, SearchQuery

try:
    import numpy as np
except Exception:  # pragma: no cover
    np = None  # type: ignore[assignment]


def test_client_rejects_non_loopback_without_tls() -> None:
    with pytest.raises(ValueError, match="TLS is required"):
        KyroDBClient(target="10.0.0.5:50051")


def test_client_allows_loopback_without_tls() -> None:
    client = KyroDBClient(target="127.0.0.1:50051")
    client.close()


def test_client_rejects_unresolved_or_non_loopback_hostname_without_tls(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(
        "kyrodb.client._resolve_host_ips",
        lambda _host: (ipaddress.ip_address("203.0.113.10"),),
    )
    with pytest.raises(ValueError, match="TLS is required"):
        KyroDBClient(target="localhost:50051")


def test_insert_item_validates_doc_id() -> None:
    with pytest.raises(ValueError, match="doc_id"):
        InsertItem.from_parts(doc_id=0, embedding=[0.1, 0.2])


def test_search_query_validates_k_and_ef_search() -> None:
    with pytest.raises(ValueError, match="k must be between 1 and 1000"):
        SearchQuery.from_parts(query_embedding=[0.1, 0.2], k=0)
    with pytest.raises(ValueError, match="ef_search must be >= 0"):
        SearchQuery.from_parts(query_embedding=[0.1, 0.2], k=1, ef_search=-1)


@pytest.mark.skipif(np is None, reason="NumPy not installed")
def test_insert_item_accepts_numpy_embedding_array() -> None:
    item = InsertItem.from_parts(doc_id=1, embedding=np.array([0.1, 0.2], dtype=np.float32))
    assert item.embedding == pytest.approx((0.1, 0.2))


def test_client_rejects_non_positive_default_timeout() -> None:
    with pytest.raises(ValueError, match="default_timeout_s"):
        KyroDBClient(target="127.0.0.1:50051", default_timeout_s=0)


def test_client_rejects_non_positive_max_unary_batch_size() -> None:
    with pytest.raises(ValueError, match="max_unary_batch_size"):
        KyroDBClient(target="127.0.0.1:50051", max_unary_batch_size=0)


def test_client_rejects_blank_lb_policy_name() -> None:
    with pytest.raises(ValueError, match="lb_policy_name"):
        KyroDBClient(target="127.0.0.1:50051", lb_policy_name="  ")


def test_wait_for_ready_times_out() -> None:
    client = KyroDBClient(target="127.0.0.1:65535")
    with pytest.raises(DeadlineExceededError, match="WaitForReady"):
        client.wait_for_ready(timeout_s=0.01)
    client.close()


def test_client_requires_key_and_cert_pair_for_mtls() -> None:
    with pytest.raises(ValueError, match="provided together"):
        KyroDBClient(
            target="127.0.0.1:50051",
            tls=TLSConfig(certificate_chain=b"cert-only"),
        )
    with pytest.raises(ValueError, match="provided together"):
        KyroDBClient(
            target="127.0.0.1:50051",
            tls=TLSConfig(private_key=b"key-only"),
        )


def test_client_rejects_empty_tls_bytes() -> None:
    with pytest.raises(ValueError, match="root_certificates must not be empty bytes"):
        KyroDBClient(target="127.0.0.1:50051", tls=TLSConfig(root_certificates=b""))


def test_client_applies_default_channel_options_and_compression(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    captured: dict[str, object] = {}

    class _DummyChannel:
        def close(self) -> None:
            return None

    class _DummyStub:
        def __init__(self, _channel: object) -> None:
            return None

    def fake_insecure_channel(
        target: str,
        *,
        options: list[tuple[str, int | str]] | None = None,
        compression: grpc.Compression | None = None,
    ) -> _DummyChannel:
        captured["target"] = target
        captured["options"] = dict(options or [])
        captured["compression"] = compression
        return _DummyChannel()

    monkeypatch.setattr("kyrodb.client.grpc.insecure_channel", fake_insecure_channel)
    monkeypatch.setattr("kyrodb.client.pb2_grpc.KyroDBServiceStub", _DummyStub)

    client = KyroDBClient(
        target="127.0.0.1:50051",
        compression=grpc.Compression.Gzip,
        channel_options=[("grpc.max_receive_message_length", 1_000_000)],
    )
    options = captured["options"]
    assert isinstance(options, dict)
    assert options["grpc.keepalive_time_ms"] == 30_000
    assert options["grpc.keepalive_timeout_ms"] == 10_000
    assert options["grpc.max_receive_message_length"] == 1_000_000
    assert captured["compression"] == grpc.Compression.Gzip
    client.close()


def test_client_sets_lb_policy_channel_option(monkeypatch: pytest.MonkeyPatch) -> None:
    captured: dict[str, object] = {}

    class _DummyChannel:
        def close(self) -> None:
            return None

    class _DummyStub:
        def __init__(self, _channel: object) -> None:
            return None

    def fake_insecure_channel(
        target: str,
        *,
        options: list[tuple[str, int | str]] | None = None,
        compression: grpc.Compression | None = None,
    ) -> _DummyChannel:
        _ = compression
        captured["target"] = target
        captured["options"] = dict(options or [])
        return _DummyChannel()

    monkeypatch.setattr("kyrodb.client.grpc.insecure_channel", fake_insecure_channel)
    monkeypatch.setattr("kyrodb.client.pb2_grpc.KyroDBServiceStub", _DummyStub)

    client = KyroDBClient(target="127.0.0.1:50051", lb_policy_name="round_robin")
    options = captured["options"]
    assert isinstance(options, dict)
    assert options["grpc.lb_policy_name"] == "round_robin"
    client.close()


def test_package_exposes_runtime_version() -> None:
    assert isinstance(kyrodb.__version__, str)
    assert kyrodb.__version__.strip() != ""


def test_package_exposes_circuit_breaker_policy() -> None:
    policy = kyrodb.CircuitBreakerPolicy()
    assert policy.failure_threshold > 0


def test_resolve_host_ips_handles_lookup_errors(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(
        "kyrodb.client.socket.getaddrinfo",
        lambda *args, **kwargs: (_ for _ in ()).throw(socket.gaierror("boom")),
    )
    assert kyrodb.client._resolve_host_ips("not-a-host") == ()


def test_resolve_host_ips_ignores_invalid_entries(monkeypatch: pytest.MonkeyPatch) -> None:
    def fake_getaddrinfo(
        host: str,
        port: object,
        *,
        type: int,
    ) -> list[tuple[int, int, int, str, tuple[str, int]]]:
        _ = (host, port, type)
        return [
            (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("not-an-ip", 0)),
            (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0)),
        ]

    monkeypatch.setattr("kyrodb.client.socket.getaddrinfo", fake_getaddrinfo)
    resolved = kyrodb.client._resolve_host_ips("localhost")
    assert resolved == (ipaddress.ip_address("127.0.0.1"),)


def test_sync_context_manager_closes_channel(monkeypatch: pytest.MonkeyPatch) -> None:
    class _DummyChannel:
        def __init__(self) -> None:
            self.closed = False

        def close(self) -> None:
            self.closed = True

    class _DummyStub:
        def __init__(self, _channel: object) -> None:
            return None

    channel = _DummyChannel()
    monkeypatch.setattr("kyrodb.client.grpc.insecure_channel", lambda *args, **kwargs: channel)
    monkeypatch.setattr("kyrodb.client.pb2_grpc.KyroDBServiceStub", _DummyStub)

    with KyroDBClient(target="127.0.0.1:50051"):
        pass

    assert channel.closed is True


def test_wait_for_ready_success_path(monkeypatch: pytest.MonkeyPatch) -> None:
    class _ReadyFuture:
        def result(self, timeout: float | None = None) -> None:
            _ = timeout
            return None

    monkeypatch.setattr("kyrodb.client.grpc.channel_ready_future", lambda _channel: _ReadyFuture())

    client = KyroDBClient(target="127.0.0.1:50051")
    client.wait_for_ready(timeout_s=0.5)
    client.close()


def test_client_rejects_nan_embedding_values() -> None:
    client = KyroDBClient(target="127.0.0.1:50051")
    # We don't need a real stub because validation happens before RPC
    with pytest.raises(ValueError, match="non-finite"):
        client.insert(doc_id=1, embedding=[0.1, float("nan")])
    client.close()


def test_client_rejects_nan_numpy_embedding() -> None:
    try:
        import numpy as np
    except ImportError:
        pytest.skip("numpy not installed")

    client = KyroDBClient(target="127.0.0.1:50051")
    # Test 1D array with NaN
    arr = np.array([0.1, np.nan], dtype=np.float32)
    with pytest.raises(ValueError, match="non-finite"):
        client.insert(doc_id=1, embedding=arr)
        
    # Test wrong dim
    arr_2d = np.array([[0.1, 0.2]], dtype=np.float32)
    with pytest.raises(ValueError, match="1D"):
        client.insert(doc_id=1, embedding=arr_2d)

    client.close()
