"""Root test configuration.

Provides shared fixtures: fake K8s client, factory, CLI runner.
Adds --backend option for selecting fake vs real K8s backend.
"""

from __future__ import annotations

import contextlib
import uuid
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

import pytest
from click.testing import CliRunner

from kuberoku.factory import KuberokuFactory
from kuberoku.k8s.client import K8sClient
from tests.backends.fake import FakeK8sClient
from tests.backends.real import RealK8sClient

if TYPE_CHECKING:
    from collections.abc import Iterator

# ── Test-safe branding ───────────────────────────────────────────
# Resources created during tests use a different prefix/domain than
# production so that accidentally running tests against a real cluster
# cannot collide with or damage real kuberoku-managed resources.
_TEST_NS_PREFIX = "k8ut-"
_TEST_RESOURCE_PREFIX = "k8ut"
_TEST_LABEL_DOMAIN = "test.kuberoku.com"


def pytest_addoption(parser: pytest.Parser) -> None:
    parser.addoption(
        "--backend",
        default="fake",
        choices=["fake", "real"],
        help="K8s backend: fake (in-memory) or real (requires running cluster)",
    )
    parser.addoption(
        "--runslow",
        action="store_true",
        default=False,
        help="Run slow tests (addon lifecycle, versioning, etc.)",
    )


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
    """Skip @pytest.mark.slow tests unless --runslow is passed."""
    if config.getoption("--runslow"):
        return
    skip_slow = pytest.mark.skip(reason="Slow test — pass --runslow to run")
    for item in items:
        if "slow" in item.keywords:
            item.add_marker(skip_slow)


def _get_k8s_apis():  # type: ignore[no-untyped-def]
    """Get K8s API clients from a properly configured connection (lazy import)."""
    client = K8sClient.from_context()
    return client._core, client._apps, client._networking


def _get_k8s_core():  # type: ignore[no-untyped-def]
    """Get the K8s CoreV1Api client (lazy import)."""
    core, _, _ = _get_k8s_apis()
    return core


def _cleanup_orphaned_namespaces() -> None:
    """Delete test-* namespaces left over from previous runs.

    Only deletes namespaces older than 10 minutes to avoid interfering
    with concurrent pytest processes that may have just created them.
    """
    with contextlib.suppress(Exception):
        core = _get_k8s_core()
        ns_list = core.list_namespace()
        cutoff = datetime.now(UTC) - timedelta(minutes=10)
        for ns in ns_list.items:
            name = ns.metadata.name
            if name.startswith(_TEST_NS_PREFIX):
                created = ns.metadata.creation_timestamp
                if created and created < cutoff:
                    with contextlib.suppress(Exception):
                        core.delete_namespace(name)


def _wait_for_test_namespaces_gone(timeout: int = 180) -> None:
    """Wait until all test-* namespaces are fully deleted."""
    from tests.e2e.helpers import poll_until

    with contextlib.suppress(Exception):
        core = _get_k8s_core()

        def _remaining_ns() -> list[str]:
            ns_list = core.list_namespace()
            return [
                ns.metadata.name
                for ns in ns_list.items
                if ns.metadata.name.startswith(_TEST_NS_PREFIX)
            ]

        poll_until(
            fn=_remaining_ns,
            predicate=lambda ns: len(ns) == 0,
            timeout=timeout,
            interval=0.5,
            message="Test namespaces still exist",
        )


def _is_xdist_worker(session: pytest.Session) -> bool:
    """Return True if running as an xdist worker (not controller)."""
    return hasattr(session.config, "workerinput")


def pytest_sessionstart(session: pytest.Session) -> None:
    """Clean up orphaned test namespaces before running tests.

    Only runs on the controller (or non-xdist). Workers skip this
    to avoid deleting namespaces that sibling workers just created.
    """
    if _is_xdist_worker(session):
        return
    if session.config.getoption("--backend", default="fake") == "real":
        _cleanup_orphaned_namespaces()
        _wait_for_test_namespaces_gone(timeout=30)


def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
    """Best-effort cleanup of test namespaces after all tests.

    Only runs on the controller (or non-xdist). Workers skip this
    because their namespaces are cleaned up by fixture teardown.
    """
    if _is_xdist_worker(session):
        return
    if session.config.getoption("--backend", default="fake") == "real":
        _cleanup_orphaned_namespaces()
        _wait_for_test_namespaces_gone(timeout=15)


@pytest.fixture()
def fake_k8s() -> FakeK8sClient:
    """Fresh in-memory K8s client for each test."""
    return FakeK8sClient()


@pytest.fixture()
def factory(request: pytest.FixtureRequest, tmp_namespace: str) -> Iterator[KuberokuFactory]:
    """Factory wired to K8s backend based on --backend flag.

    fake (default): FakeK8sClient — instant, no cluster needed.
    real:           K8sClient.from_context() — requires running cluster.

    On real backend, cleans up all kuberoku-managed resources after each test
    to maintain isolation within the module-scoped namespace.
    """
    backend = request.config.getoption("--backend")
    if backend == "real":
        yield KuberokuFactory(
            k8s_client=K8sClient.from_context(),
            namespace=tmp_namespace,
            resource_prefix=_TEST_RESOURCE_PREFIX,
            label_domain=_TEST_LABEL_DOMAIN,
        )
        _wipe_namespace_resources(tmp_namespace)
    else:
        # Fake backend is memory-only — no risk of touching real resources.
        # Use default prefix so existing tests with hardcoded 'kuberoku-' work.
        yield KuberokuFactory(k8s_client=FakeK8sClient(), namespace=tmp_namespace)


@pytest.fixture()
def cli_runner() -> CliRunner:
    """Click CLI test runner."""
    return CliRunner()


def _create_real_namespace(ns_name: str) -> None:
    """Create a real K8s namespace (called only for real backend)."""
    import kubernetes  # type: ignore[import-untyped]

    core = _get_k8s_core()
    body = kubernetes.client.V1Namespace(metadata=kubernetes.client.V1ObjectMeta(name=ns_name))
    core.create_namespace(body)


def _delete_real_namespace(ns_name: str) -> None:
    """Delete a real K8s namespace (best-effort cleanup)."""
    core = _get_k8s_core()
    with contextlib.suppress(Exception):
        core.delete_namespace(ns_name)


def _wipe_namespace_resources(ns_name: str) -> None:
    """Delete all kuberoku-managed resources in a namespace (real backend only).

    Uses label selector to only remove kuberoku-managed resources,
    preserving system resources like kube-root-ca.crt ConfigMap.
    Called after each test to maintain isolation within module-scoped namespaces.
    Waits for resources to be fully deleted before returning.
    """
    try:
        label_sel = f"{_TEST_LABEL_DOMAIN}/managed-by={_TEST_RESOURCE_PREFIX}"
        core, apps_v1, net_v1 = _get_k8s_apis()

        # Delete in dependency order (children before parents)
        with contextlib.suppress(Exception):
            apps_v1.delete_collection_namespaced_deployment(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            apps_v1.delete_collection_namespaced_stateful_set(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            core.delete_collection_namespaced_service(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            net_v1.delete_collection_namespaced_ingress(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            net_v1.delete_collection_namespaced_network_policy(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            core.delete_collection_namespaced_persistent_volume_claim(
                ns_name, label_selector=label_sel
            )
        with contextlib.suppress(Exception):
            core.delete_collection_namespaced_config_map(ns_name, label_selector=label_sel)
        with contextlib.suppress(Exception):
            core.delete_collection_namespaced_secret(ns_name, label_selector=label_sel)

        # Wait for all labeled resources to be fully deleted (K8s deletion is async)
        from tests.e2e.helpers import poll_until

        def _count_remaining() -> int:
            count = 0
            with contextlib.suppress(Exception):
                count += len(
                    apps_v1.list_namespaced_deployment(ns_name, label_selector=label_sel).items
                )
            with contextlib.suppress(Exception):
                count += len(
                    apps_v1.list_namespaced_stateful_set(ns_name, label_selector=label_sel).items
                )
            with contextlib.suppress(Exception):
                count += len(core.list_namespaced_service(ns_name, label_selector=label_sel).items)
            with contextlib.suppress(Exception):
                count += len(
                    net_v1.list_namespaced_ingress(ns_name, label_selector=label_sel).items
                )
            with contextlib.suppress(Exception):
                count += len(
                    net_v1.list_namespaced_network_policy(ns_name, label_selector=label_sel).items
                )
            with contextlib.suppress(Exception):
                count += len(
                    core.list_namespaced_persistent_volume_claim(
                        ns_name, label_selector=label_sel
                    ).items
                )
            with contextlib.suppress(Exception):
                count += len(
                    core.list_namespaced_config_map(ns_name, label_selector=label_sel).items
                )
            with contextlib.suppress(Exception):
                count += len(core.list_namespaced_secret(ns_name, label_selector=label_sel).items)
            return count

        poll_until(
            fn=_count_remaining,
            predicate=lambda n: n == 0,
            timeout=30,
            interval=0.5,
            message=f"Resources in {ns_name} still exist after wipe",
        )
    except Exception:
        pass


@pytest.fixture(scope="module")
def tmp_namespace(request: pytest.FixtureRequest) -> Iterator[str]:
    """Module-scoped namespace for test isolation.

    One real K8s namespace per test module (not per test), dramatically
    reducing namespace count from ~hundreds to ~40.

    For the fake backend, returns a unique string (no I/O needed — each
    test gets its own FakeK8sClient with an independent in-memory store).
    """
    backend = request.config.getoption("--backend")
    ns_name = f"{_TEST_NS_PREFIX}{uuid.uuid4().hex[:12]}"

    if backend == "real":
        _create_real_namespace(ns_name)
        yield ns_name
        _delete_real_namespace(ns_name)
    else:
        yield ns_name


@pytest.fixture()
def app_factory(tmp_namespace: str) -> KuberokuFactory:
    """Factory with a pre-created app ('myapp') on a fake backend."""
    factory = KuberokuFactory(k8s_client=FakeK8sClient(), namespace=tmp_namespace)
    factory.apps.create("myapp")
    return factory


@pytest.fixture()
def deployed_factory(app_factory: KuberokuFactory) -> KuberokuFactory:
    """Factory with an app that has been deployed (web process exists)."""
    app_factory.deploy.deploy("myapp", image="myapp:v1", wait=False)
    return app_factory


@pytest.fixture()
def k8s_client(request: pytest.FixtureRequest, tmp_namespace: str) -> FakeK8sClient:  # type: ignore[misc]
    """K8s client for contract tests — fake or real based on --backend flag.

    Return type is annotated as FakeK8sClient for compatibility, but may
    actually be RealK8sClient (which has the same interface).
    """
    backend = request.config.getoption("--backend")
    if backend == "real":
        return RealK8sClient()  # type: ignore[return-value]
    return FakeK8sClient()
