"""Shared E2E test helpers.

Extracted from individual E2E test files to avoid duplication.
All helpers require a real K8s cluster.
"""

from __future__ import annotations

import socket
import subprocess
import time
import uuid
from collections.abc import Callable
from typing import Any, TypeVar

import pytest

from kuberoku.factory import KuberokuFactory
from kuberoku.k8s import labels as k8s_labels
from kuberoku.k8s.client import K8sClient

_T = TypeVar("_T")


# ── Core primitives ─────────────────────────────────────────────


def is_pod_active(pod: dict[str, Any]) -> bool:
    """True if a pod is genuinely running — not being terminated.

    K8s pods keep phase=Running during graceful shutdown (they have
    deletionTimestamp set but haven't fully terminated yet). Checking
    phase alone causes flaky tests that count dying pods as alive.
    """
    if pod.get("metadata", {}).get("deletionTimestamp"):
        return False
    phase: str = pod.get("status", {}).get("phase", "")
    return phase == "Running"


def is_pod_ready(pod: dict[str, Any]) -> bool:
    """True if a pod is Running+Ready and not being terminated."""
    if not is_pod_active(pod):
        return False
    statuses = pod.get("status", {}).get("containerStatuses", [])
    return bool(statuses) and all(s.get("ready", False) for s in statuses)


def poll_until(
    fn: Callable[[], _T],
    predicate: Callable[[_T], bool],
    timeout: float = 10,
    interval: float = 0.5,
    message: str = "Condition not met within timeout",
) -> _T:
    """Poll fn() until predicate(result) is True, or raise TimeoutError.

    Transient exceptions from fn() are tolerated (retried), but the last
    exception is included in the TimeoutError message for diagnostics.
    """
    deadline = time.monotonic() + timeout
    last: _T | None = None
    last_exc: Exception | None = None
    while time.monotonic() < deadline:
        try:
            last = fn()
            last_exc = None
            if predicate(last):
                return last
        except Exception as exc:
            last_exc = exc
        time.sleep(interval)
    if last is not None and predicate(last):
        return last
    parts = [message]
    if last_exc is not None:
        parts.append(f"last exception: {last_exc}")
    if last is not None:
        last_str = str(last)
        if len(last_str) > 200:
            last_str = last_str[:200] + "..."
        parts.append(f"last value: {last_str}")
    raise TimeoutError(" | ".join(parts))


# ── Cluster availability ────────────────────────────────────────


def skip_if_no_cluster() -> None:
    """Skip the test module if no K8s cluster is available."""
    try:
        result = subprocess.run(
            ["kubectl", "cluster-info"],
            capture_output=True,
            timeout=5,
        )
        if result.returncode != 0:
            pytest.skip("No K8s cluster available")
    except Exception:
        pytest.skip("No K8s cluster available")


# ── Pod wait helpers ────────────────────────────────────────────


def _pod_state_summary(pods: list[dict[str, Any]]) -> str:
    """Compact summary of pod states for error messages."""
    states = [
        (
            p["metadata"]["name"],
            p.get("status", {}).get("phase", "Unknown"),
            bool(p.get("metadata", {}).get("deletionTimestamp")),
            p["spec"]["containers"][0].get("image", ""),
        )
        for p in pods
    ]
    return f"(name, phase, terminating, image): {states}"


def wait_for_pods_ready(
    factory: KuberokuFactory,
    app: str,
    process_type: str = "web",
    expected: int = 1,
    timeout: int = 90,
    image: str | None = None,
) -> list[dict[str, Any]]:
    """Poll until expected number of pods are Running+Ready.

    If image is specified, only count pods running that specific image.
    This handles rolling updates where old pods are still Running.
    Excludes pods with deletionTimestamp (graceful termination).
    """
    selector = k8s_labels.selector(factory.domain, app, process_type)

    def _get_ready() -> list[dict[str, Any]]:
        pods = factory.k8s.list_pods(factory.namespace, labels=selector)
        ready = []
        for pod in pods:
            if not is_pod_ready(pod):
                continue
            if image is not None:
                container_image = pod["spec"]["containers"][0].get("image", "")
                if container_image != image:
                    continue
            ready.append(pod)
        return ready

    img_desc = f" with image {image}" if image else ""
    return poll_until(
        fn=_get_ready,
        predicate=lambda pods: len(pods) >= expected,
        timeout=timeout,
        interval=0.3,
        message=f"Timed out waiting for {expected} ready pods{img_desc}",
    )


def wait_for_no_pods(
    factory: KuberokuFactory,
    app: str,
    process_type: str = "web",
    timeout: int = 60,
) -> None:
    """Wait until no active (non-terminating) Running pods remain."""
    selector = k8s_labels.selector(factory.domain, app, process_type)

    def _get_active() -> list[dict[str, Any]]:
        pods = factory.k8s.list_pods(factory.namespace, labels=selector)
        return [p for p in pods if is_pod_active(p)]

    poll_until(
        fn=_get_active,
        predicate=lambda active: len(active) == 0,
        timeout=timeout,
        interval=0.3,
        message="Timed out waiting for pods to terminate",
    )


def wait_for_sts_ready(
    factory: KuberokuFactory,
    sts_name: str,
    timeout: int = 180,
    image: str | None = None,
) -> dict[str, Any]:
    """Poll until the StatefulSet pod is Running+Ready.

    If image is specified, only match the pod running that specific image.
    This handles rolling updates where the old pod is still Ready briefly.
    """
    pod_name = f"{sts_name}-0"

    def _get_ready_pod() -> dict[str, Any] | None:
        pods = factory.k8s.list_pods(factory.namespace)
        for pod in pods:
            if pod.get("metadata", {}).get("name") != pod_name:
                continue
            if not is_pod_ready(pod):
                continue
            if image is not None:
                container_image = pod["spec"]["containers"][0].get("image", "")
                if container_image != image:
                    continue
            return pod
        return None

    img_desc = f" with image {image}" if image else ""
    result = poll_until(
        fn=_get_ready_pod,
        predicate=lambda pod: pod is not None,
        timeout=timeout,
        interval=0.3,
        message=f"Timed out waiting for StatefulSet pod {pod_name} to be ready{img_desc}",
    )
    assert result is not None  # for type narrowing
    return result


# ── Pod exec helpers ────────────────────────────────────────────


def exec_in_pod_safe(
    factory: KuberokuFactory,
    pod_name: str,
    command: list[str],
) -> str:
    """Exec command in pod with error handling.

    Note: kubernetes.stream() does NOT raise on non-zero exit codes,
    so the return value must be inspected for errors.
    """
    try:
        output = factory.k8s.exec_in_pod(
            factory.namespace,
            pod_name,
            command,
            tty=False,
        )
        return str(output)
    except Exception as e:
        return f"EXEC_ERROR: {e}"


def http_probe_from_pod(
    factory: KuberokuFactory,
    pod_name: str,
    url: str,
) -> str:
    """Execute an HTTP GET from inside a pod.

    Uses wget (available in alpine-based images).
    Returns the response body or an error string.
    """
    return exec_in_pod_safe(
        factory,
        pod_name,
        ["sh", "-c", f"wget -qO- '{url}' -T 5 2>&1 || echo PROBE_FAILED"],
    )


def tcp_probe_from_pod(
    factory: KuberokuFactory,
    pod_name: str,
    host: str,
    port: int,
) -> bool:
    """Execute a TCP probe from inside a pod.

    Uses sh built-in /dev/tcp or nc if available.
    Returns True if connection succeeded.
    """
    output = exec_in_pod_safe(
        factory,
        pod_name,
        [
            "sh",
            "-c",
            f"(echo > /dev/tcp/{host}/{port}) 2>/dev/null && echo TCP_OK || echo TCP_FAIL",
        ],
    )
    return "TCP_OK" in output


def find_running_pod(
    factory: KuberokuFactory,
    app: str,
    process_type: str = "web",
) -> str | None:
    """Find the name of an active Running pod for the given app/process type."""
    selector = k8s_labels.selector(factory.domain, app, process_type)
    pods = factory.k8s.list_pods(factory.namespace, labels=selector)
    for pod in pods:
        if is_pod_active(pod):
            return str(pod["metadata"]["name"])
    return None


# ── Service wait helpers ───────────────────────────────────────


def wait_for_service_deleted(
    factory: KuberokuFactory,
    svc_name: str,
    timeout: int = 15,
) -> None:
    """Wait until a K8s Service no longer exists (async deletion)."""

    def _get_service() -> dict[str, Any] | None:
        try:
            return factory.k8s.get_service(factory.namespace, svc_name)
        except Exception:
            return None

    poll_until(
        fn=_get_service,
        predicate=lambda svc: svc is None,
        timeout=timeout,
        interval=0.5,
        message=f"Service {svc_name} still exists after {timeout}s",
    )


def wait_for_port_open(
    host: str,
    port: int,
    timeout: float = 5,
) -> None:
    """Poll until a TCP port is accepting connections.

    Used to stabilize port-forward before issuing HTTP requests.
    """

    def _try_connect() -> bool:
        try:
            with socket.create_connection((host, port), timeout=0.5):
                return True
        except OSError:
            return False

    poll_until(
        fn=_try_connect,
        predicate=lambda ok: ok,
        timeout=timeout,
        interval=0.1,
        message=f"Port {host}:{port} not open after {timeout}s",
    )


def wait_for_lb_address(
    factory: KuberokuFactory,
    svc_name: str,
    timeout: int = 60,
) -> str | None:
    """Wait for a LoadBalancer Service to get an external IP/hostname.

    Returns the IP/hostname or None if timeout is reached (not an error —
    some environments never assign LB IPs).
    """

    def _get_lb_ip() -> str | None:
        svc = factory.k8s.get_service(factory.namespace, svc_name)
        lb = svc.get("status", {}).get("loadBalancer", {})
        for ing in lb.get("ingress", []):
            ip = ing.get("ip") or ing.get("hostname")
            if ip:
                return str(ip)
        return None

    try:
        return poll_until(
            fn=_get_lb_ip,
            predicate=lambda ip: ip is not None,
            timeout=timeout,
            interval=1,
            message=f"LB address not assigned for {svc_name}",
        )
    except TimeoutError:
        return None  # LB timeout is not an error


def wait_for_pg_ready(
    factory: KuberokuFactory,
    sts_name: str,
    timeout: int = 120,
) -> None:
    """Poll pg_isready until postgres is accepting connections."""
    pod_name = f"{sts_name}-0"
    poll_until(
        fn=lambda: factory.k8s.exec_in_pod(
            factory.namespace,
            pod_name,
            ["pg_isready", "-U", "kuberoku"],
        ),
        predicate=lambda o: "accepting connections" in o,
        timeout=timeout,
        interval=0.5,
        message=f"pg_isready timed out on {pod_name} after {timeout}s",
    )


def wait_for_redis_ready(
    factory: KuberokuFactory,
    sts_name: str,
    app: str,
    timeout: int = 120,
) -> None:
    """Poll redis-cli PING until redis responds with PONG.

    Handles the case where the container hasn't started yet or
    credentials are not yet available.
    """
    pod_name = f"{sts_name}-0"
    try:
        creds = factory.addons.credentials(app, "redis")
        password: str | None = creds["password"]
    except Exception:
        password = None

    def _ping() -> str:
        cmd = ["redis-cli"]
        if password:
            cmd.extend(["-a", password])
        cmd.append("PING")
        return factory.k8s.exec_in_pod(factory.namespace, pod_name, cmd)

    poll_until(
        fn=_ping,
        predicate=lambda o: "PONG" in o,
        timeout=timeout,
        interval=0.5,
        message=f"redis PING timed out on {pod_name} after {timeout}s",
    )


# ── 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_RESOURCE_PREFIX = "k8ut"
TEST_LABEL_DOMAIN = "test.kuberoku.com"


def make_real_factory(ns_prefix: str = "e2e") -> KuberokuFactory:
    """Create a factory connected to a real K8s cluster with a unique namespace.

    Uses test-safe prefix ('k8ut') and domain ('test.kuberoku.com') to
    prevent accidental interference with production kuberoku resources.
    """
    ns = f"k8ut-{ns_prefix}-{uuid.uuid4().hex[:8]}"
    k8s = K8sClient.from_context()
    return KuberokuFactory(
        k8s_client=k8s,
        namespace=ns,
        resource_prefix=TEST_RESOURCE_PREFIX,
        label_domain=TEST_LABEL_DOMAIN,
    )


def cleanup_namespace_fixture(real_factory: KuberokuFactory) -> Any:
    """Delete the test namespace (for use as fixture teardown)."""
    try:
        subprocess.run(
            [
                "kubectl",
                "delete",
                "namespace",
                real_factory.namespace,
                "--ignore-not-found",
                "--wait=false",
            ],
            capture_output=True,
            timeout=30,
        )
    except Exception:
        pass
