"""Tests for GatewayService — port allocation, deallocation, collision guard."""

from __future__ import annotations

import json
from typing import Any

import pytest

from kuberoku.exceptions import GatewayPortExhaustedError
from kuberoku.factory import KuberokuFactory
from kuberoku.sdk.gateway import PORT_MAX, PORT_MIN, GatewayService
from tests.backends.fake import FakeK8sClient, FakeNotFoundError


@pytest.fixture()
def k8s() -> FakeK8sClient:
    return FakeK8sClient()


@pytest.fixture()
def factory(k8s: FakeK8sClient) -> KuberokuFactory:
    return KuberokuFactory(k8s_client=k8s, namespace="test-ns")


class TestGatewayAllocate:
    def test_first_allocation_gets_min_port(self, factory: KuberokuFactory) -> None:
        alloc = factory.gateway.allocate("myapp", "svc-redis", 6379)
        assert alloc.external_port == PORT_MIN
        assert alloc.app == "myapp"
        assert alloc.target_service == "svc-redis"
        assert alloc.target_port == 6379

    def test_sequential_allocations(self, factory: KuberokuFactory) -> None:
        a1 = factory.gateway.allocate("myapp", "svc-redis", 6379)
        a2 = factory.gateway.allocate("myapp", "svc-pg", 5432)
        assert a1.external_port == PORT_MIN
        assert a2.external_port == PORT_MIN + 1

    def test_idempotent_allocation(self, factory: KuberokuFactory) -> None:
        a1 = factory.gateway.allocate("myapp", "svc-redis", 6379)
        a2 = factory.gateway.allocate("myapp", "svc-redis", 6379)
        assert a1.external_port == a2.external_port

    def test_different_apps_different_ports(self, factory: KuberokuFactory) -> None:
        a1 = factory.gateway.allocate("app1", "svc-redis-1", 6379)
        a2 = factory.gateway.allocate("app2", "svc-redis-2", 6379)
        assert a1.external_port != a2.external_port

    def test_creates_lb_service(self, factory: KuberokuFactory, k8s: FakeK8sClient) -> None:
        alloc = factory.gateway.allocate("myapp", "svc-redis", 6379)
        svc_name = f"{factory.prefix}-gw-{alloc.external_port}"
        svc = k8s.get_service(factory.namespace, svc_name)
        assert svc["spec"]["type"] == "LoadBalancer"
        assert svc["spec"]["ports"][0]["port"] == alloc.external_port
        assert svc["spec"]["ports"][0]["targetPort"] == 6379

    def test_lb_service_copies_target_selector(
        self, factory: KuberokuFactory, k8s: FakeK8sClient
    ) -> None:
        """Gateway LB Service should copy the target service's selector."""
        # Create a target service with a selector
        target_svc: dict[str, Any] = {
            "apiVersion": "v1",
            "kind": "Service",
            "metadata": {"name": "svc-redis"},
            "spec": {
                "type": "ClusterIP",
                "selector": {"app": "redis", "role": "primary"},
                "ports": [{"port": 6379, "targetPort": 6379, "protocol": "TCP"}],
            },
        }
        k8s.create_service(factory.namespace, target_svc)

        alloc = factory.gateway.allocate("myapp", "svc-redis", 6379)
        gw_svc_name = f"{factory.prefix}-gw-{alloc.external_port}"
        gw_svc = k8s.get_service(factory.namespace, gw_svc_name)
        assert gw_svc["spec"]["selector"] == {"app": "redis", "role": "primary"}

    def test_lb_service_empty_selector_when_target_missing(
        self, factory: KuberokuFactory, k8s: FakeK8sClient
    ) -> None:
        """Gateway LB Service falls back to empty selector if target not found."""
        alloc = factory.gateway.allocate("myapp", "missing-svc", 6379)
        gw_svc_name = f"{factory.prefix}-gw-{alloc.external_port}"
        gw_svc = k8s.get_service(factory.namespace, gw_svc_name)
        assert gw_svc["spec"]["selector"] == {}

    def test_on_step_callback(self, factory: KuberokuFactory) -> None:
        steps: list[str] = []
        factory.gateway.allocate("myapp", "svc-redis", 6379, on_step=steps.append)
        assert len(steps) > 0

    def test_creates_gateway_configmap(self, factory: KuberokuFactory, k8s: FakeK8sClient) -> None:
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        cm_name = f"{factory.prefix}-gateway"
        cm = k8s.get_configmap(factory.namespace, cm_name)
        assert "allocations" in cm["data"]
        assert "10000" in cm["data"]["allocations"]


class TestGatewayDeallocate:
    def test_deallocate_removes_allocation(self, factory: KuberokuFactory) -> None:
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        factory.gateway.deallocate("myapp", "svc-redis")
        alloc = factory.gateway.get_allocation("myapp", "svc-redis")
        assert alloc is None

    def test_deallocate_removes_lb_service(
        self, factory: KuberokuFactory, k8s: FakeK8sClient
    ) -> None:
        alloc = factory.gateway.allocate("myapp", "svc-redis", 6379)
        svc_name = f"{factory.prefix}-gw-{alloc.external_port}"
        factory.gateway.deallocate("myapp", "svc-redis")
        with pytest.raises(FakeNotFoundError):
            k8s.get_service(factory.namespace, svc_name)

    def test_deallocate_nonexistent_is_noop(self, factory: KuberokuFactory) -> None:
        # Should not raise
        factory.gateway.deallocate("myapp", "nonexistent")

    def test_deallocate_no_configmap_is_noop(self, factory: KuberokuFactory) -> None:
        # No gateway CM exists
        factory.gateway.deallocate("myapp", "svc-redis")

    def test_deallocate_on_step_callback(self, factory: KuberokuFactory) -> None:
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        steps: list[str] = []
        factory.gateway.deallocate("myapp", "svc-redis", on_step=steps.append)
        assert len(steps) > 0


class TestGatewayListAllocations:
    def test_list_empty(self, factory: KuberokuFactory) -> None:
        assert factory.gateway.list_allocations() == []

    def test_list_with_allocations(self, factory: KuberokuFactory) -> None:
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        factory.gateway.allocate("myapp", "svc-pg", 5432)
        allocs = factory.gateway.list_allocations()
        assert len(allocs) == 2
        ports = {a.external_port for a in allocs}
        assert PORT_MIN in ports
        assert PORT_MIN + 1 in ports


class TestGatewayGetAllocation:
    def test_get_existing(self, factory: KuberokuFactory) -> None:
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        alloc = factory.gateway.get_allocation("myapp", "svc-redis")
        assert alloc is not None
        assert alloc.external_port == PORT_MIN

    def test_get_nonexistent(self, factory: KuberokuFactory) -> None:
        alloc = factory.gateway.get_allocation("myapp", "nonexistent")
        assert alloc is None


class TestGatewayGetIp:
    def test_no_services(self, factory: KuberokuFactory) -> None:
        assert factory.gateway.get_gateway_ip() is None

    def test_with_lb_service(self, factory: KuberokuFactory, k8s: FakeK8sClient) -> None:
        # Allocate to create an LB service
        factory.gateway.allocate("myapp", "svc-redis", 6379)
        # FakeK8sClient doesn't simulate LB IPs, so it's None
        ip = factory.gateway.get_gateway_ip()
        assert ip is None  # Expected: no IP assigned in fake


class TestGatewayPortExhaustion:
    def test_exhaustion_raises(self, k8s: FakeK8sClient) -> None:
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        # Fill all ports via direct CM manipulation
        cm = factory.gateway._get_or_create_gateway_cm()
        allocations = {
            str(p): {"app": "app", "service": f"svc-{p}", "target_port": 80}
            for p in range(PORT_MIN, PORT_MAX + 1)
        }
        cm["data"]["allocations"] = json.dumps(allocations)
        cm["data"]["next_port"] = str(PORT_MAX + 1)
        k8s.update_configmap(factory.namespace, f"{factory.prefix}-gateway", cm)

        with pytest.raises(GatewayPortExhaustedError):
            factory.gateway.allocate("new", "svc-new", 9090)


class TestGatewayPortWrapAround:
    """Test that _find_next_port wraps from PORT_MAX back to PORT_MIN."""

    def test_wrap_around_when_near_max(self, k8s: FakeK8sClient) -> None:
        """Fill ports 10000-10500, set next_port near max, allocate 3 with wrap."""
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")

        # Create and fill the gateway ConfigMap
        cm = factory.gateway._get_or_create_gateway_cm()

        # Fill ports PORT_MIN through PORT_MIN+500 (10000-10500)
        allocations = {
            str(p): {"app": "app", "service": f"svc-{p}", "target_port": 80}
            for p in range(PORT_MIN, PORT_MIN + 501)
        }
        cm["data"]["allocations"] = json.dumps(allocations)
        cm["data"]["next_port"] = str(PORT_MAX - 1)  # 10998
        k8s.update_configmap(factory.namespace, f"{factory.prefix}-gateway", cm)

        # Allocate 3 ports
        a1 = factory.gateway.allocate("new1", "svc-a", 80)
        assert a1.external_port == PORT_MAX - 1  # 10998

        a2 = factory.gateway.allocate("new2", "svc-b", 80)
        assert a2.external_port == PORT_MAX  # 10999

        a3 = factory.gateway.allocate("new3", "svc-c", 80)
        # Should wrap back and find first free after PORT_MIN
        # Ports 10000-10500 are taken, so first free is 10501
        assert a3.external_port == PORT_MIN + 501  # 10501

    def test_wrap_around_finds_gap(self, k8s: FakeK8sClient) -> None:
        """Fill most ports, leave a gap near the beginning, verify wrap finds it."""
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")

        cm = factory.gateway._get_or_create_gateway_cm()

        # Fill ALL ports except PORT_MIN + 5 (10005)
        allocations = {
            str(p): {"app": "app", "service": f"svc-{p}", "target_port": 80}
            for p in range(PORT_MIN, PORT_MAX + 1)
            if p != PORT_MIN + 5
        }
        cm["data"]["allocations"] = json.dumps(allocations)
        cm["data"]["next_port"] = str(PORT_MAX + 1)  # Past the max
        k8s.update_configmap(factory.namespace, f"{factory.prefix}-gateway", cm)

        a = factory.gateway.allocate("gap", "svc-gap", 80)
        assert a.external_port == PORT_MIN + 5  # The only free port

    def test_find_next_port_static_wrap(self) -> None:
        """Test _find_next_port directly for wrap-around behavior."""
        # Ports 10000-10500 occupied
        allocs = {
            str(p): {"app": "a", "service": "s", "target_port": 80}
            for p in range(PORT_MIN, PORT_MIN + 501)
        }

        # Start past the end → should wrap to first free
        result = GatewayService._find_next_port(allocs, PORT_MAX + 1)
        assert result == PORT_MIN + 501

        # Start at PORT_MAX, nothing there
        result = GatewayService._find_next_port(allocs, PORT_MAX)
        assert result == PORT_MAX

        # Start at PORT_MIN → first occupied, scans forward
        result = GatewayService._find_next_port(allocs, PORT_MIN)
        assert result == PORT_MIN + 501
