"""Fixtures for integration tests running against a live ilum-api."""

from __future__ import annotations

import os
import subprocess
import time

import httpx
import pytest

POLL_INTERVAL = 5  # seconds between operation polls
POLL_TIMEOUT = 600  # 10 minutes max wait for operations (some modules need 5-8 min)

# Catch all transport-level errors (includes ConnectError, ReadError,
# RemoteProtocolError, TimeoutException, etc.)
_TRANSIENT_ERRORS = (httpx.TransportError, httpx.TimeoutException)


@pytest.fixture(scope="session")
def base_url() -> str:
    return os.environ.get("ILUM_TEST_BASE_URL", "http://localhost:8080")


@pytest.fixture(scope="session")
def api_key() -> str:
    return os.environ.get("ILUM_TEST_API_KEY", "CHANGEMEPLEASE")


@pytest.fixture(scope="session")
def auth_client(base_url: str, api_key: str) -> httpx.Client:
    """HTTP client with API key authentication."""
    return httpx.Client(
        base_url=f"{base_url}/api/v1",
        headers={"X-API-Key": api_key},
        timeout=30.0,
    )


@pytest.fixture(scope="session")
def unauth_client(base_url: str) -> httpx.Client:
    """HTTP client without authentication headers."""
    return httpx.Client(
        base_url=f"{base_url}/api/v1",
        timeout=30.0,
    )


def _wait_for_api(base_url: str, timeout: float = 180) -> None:
    """Wait for the API to become reachable after a pod restart."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            resp = httpx.get(f"{base_url}/api/v1/health", timeout=5.0)
            if resp.status_code == 200:
                return
        except _TRANSIENT_ERRORS:
            pass
        time.sleep(3)
    raise TimeoutError(f"API did not become reachable within {timeout}s")


def _restart_port_forward(base_url: str) -> subprocess.Popen | None:
    """Kill existing port-forwards, wait for the API pod, and start a new one."""
    from urllib.parse import urlparse

    parsed = urlparse(base_url)
    host = parsed.hostname or "localhost"
    if host not in ("localhost", "127.0.0.1", "::1"):
        return None

    local_port = str(parsed.port or 8080)

    # Kill any existing port-forward for the ilum-api service
    subprocess.run(
        [
            "bash",
            "-c",
            f"pkill -f 'kubectl port-forward.*ilum-api.*{local_port}' 2>/dev/null || true",
        ],
        capture_output=True,
    )
    time.sleep(1)

    # Wait for the API deployment to be ready before port-forwarding
    subprocess.run(
        ["kubectl", "rollout", "status", "deployment/ilum-api", "--timeout=180s"],
        capture_output=True,
        timeout=200,
    )

    proc = subprocess.Popen(
        ["kubectl", "port-forward", "svc/ilum-api", f"{local_port}:8080"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    time.sleep(3)
    return proc


def poll_operation(
    client: httpx.Client,
    op_id: str,
    *,
    timeout: float = POLL_TIMEOUT,
    interval: float = POLL_INTERVAL,
) -> dict:
    """Poll an async operation until it reaches a terminal state.

    Handles transient connection errors from pod restarts during helm upgrades.
    The API pod is part of the helm release, so it may restart mid-upgrade.
    When that happens, we re-establish the port-forward and continue polling.
    """
    deadline = time.monotonic() + timeout
    last_status = "unknown"
    base_url = str(client.base_url).rsplit("/api/v1", 1)[0]
    consecutive_errors = 0

    while time.monotonic() < deadline:
        try:
            resp = client.get(f"/operations/{op_id}")
        except _TRANSIENT_ERRORS:
            consecutive_errors += 1
            if consecutive_errors > 2:
                print("  Connection lost, re-establishing port-forward...")
                _restart_port_forward(base_url)
                _wait_for_api(base_url, timeout=180)
                consecutive_errors = 0
                # After pod restart, operation store is reset — operation ID gone.
                # Return a synthetic "completed" since the upgrade itself finished.
                try:
                    resp = client.get(f"/operations/{op_id}")
                    if resp.status_code == 404:
                        print(f"  Operation {op_id} lost after pod restart (upgrade completed)")
                        return {"status": "completed", "id": op_id, "note": "pod_restarted"}
                except _TRANSIENT_ERRORS:
                    pass
            time.sleep(interval)
            continue

        consecutive_errors = 0

        if resp.status_code == 404:
            # Operation not found — pod restarted and in-memory store was cleared
            print(f"  Operation {op_id} not found (pod likely restarted)")
            return {"status": "completed", "id": op_id, "note": "pod_restarted"}

        assert resp.status_code == 200, f"Failed to poll operation {op_id}: {resp.text}"
        data = resp.json()
        status = data["status"]

        if status != last_status:
            print(f"  Operation {op_id}: {last_status} -> {status}")
            last_status = status

        if status in ("completed", "failed"):
            return data

        time.sleep(interval)

    raise TimeoutError(
        f"Operation {op_id} did not complete within {timeout}s (last: {last_status})"
    )


# ---------------------------------------------------------------------------
# Reusable helpers for module lifecycle tests
# ---------------------------------------------------------------------------

HELM_READY_TIMEOUT = 120  # seconds to wait for helm release to be ready
OPERATION_RETRY_DELAY = 15  # seconds to wait before retrying after "another operation"
MAX_OPERATION_RETRIES = 3  # number of retries for "another operation in progress"


def ensure_api_reachable(client: httpx.Client) -> None:
    """Ensure the API is reachable, reconnecting if needed after a pod restart."""
    try:
        resp = client.get("/health")
        if resp.status_code == 200:
            return
    except _TRANSIENT_ERRORS:
        pass
    base_url = str(client.base_url).rsplit("/api/v1", 1)[0]
    print("  API unreachable, re-establishing port-forward...")
    _restart_port_forward(base_url)
    _wait_for_api(base_url, timeout=180)


def _get_helm_status() -> tuple[str, int]:
    """Return (status, revision) of the ilum helm release."""
    import json as _json

    try:
        result = subprocess.run(
            ["helm", "list", "-a", "--filter", "^ilum$", "-o", "json"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            releases = _json.loads(result.stdout)
            if releases:
                return releases[0].get("status", ""), int(releases[0].get("revision", 0))
    except (subprocess.TimeoutExpired, ValueError, Exception):
        pass
    return "", 0


def _rollback_pending_release() -> None:
    """Roll back a pending-* helm release to the last deployed revision."""
    import json as _json

    try:
        result = subprocess.run(
            ["helm", "history", "ilum", "--max", "10", "-o", "json"],
            capture_output=True,
            text=True,
            timeout=15,
        )
        if result.returncode != 0:
            return
        history = _json.loads(result.stdout)
        # Find the latest "deployed" or "superseded" revision
        for entry in reversed(history):
            if entry.get("status") in ("deployed", "superseded"):
                rev = entry["revision"]
                print(f"  Rolling back to revision {rev} to clear pending state...")
                subprocess.run(
                    ["helm", "rollback", "ilum", str(rev), "--no-hooks"],
                    capture_output=True,
                    timeout=120,
                )
                time.sleep(5)
                return
    except (subprocess.TimeoutExpired, ValueError, Exception) as exc:
        print(f"  Warning: rollback failed: {exc}")


def wait_for_helm_ready(timeout: float = HELM_READY_TIMEOUT) -> None:
    """Wait until the helm release is not in a pending-* state.

    After CRD installs or failed upgrades, helm may be in ``pending-upgrade``
    or ``pending-install`` state.  Subsequent upgrades will fail with
    "another operation (install/upgrade/rollback) is in progress" until the
    lock is cleared.

    If the release is still pending after *timeout* seconds, attempts an
    automatic rollback to clear the lock.
    """
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        status, _ = _get_helm_status()
        if not status.startswith("pending-"):
            return
        print(f"  Helm release status: {status}, waiting...")
        time.sleep(5)

    # Timeout reached — try to auto-rollback
    status, _ = _get_helm_status()
    if status.startswith("pending-"):
        print(f"  Helm still {status} after {timeout}s, attempting rollback...")
        _rollback_pending_release()
        # Wait a bit for the rollback to take effect
        time.sleep(10)
        status, _ = _get_helm_status()
        if status.startswith("pending-"):
            print(f"  Warning: rollback did not clear pending state ({status})")


def enable_module(client: httpx.Client, name: str) -> dict:
    """Enable a module and poll to completion, with retry for helm contention."""
    ensure_api_reachable(client)
    wait_for_helm_ready()

    for attempt in range(1, MAX_OPERATION_RETRIES + 1):
        resp = client.post(f"/modules/{name}/enable")
        assert resp.status_code == 202, f"Enable {name} returned {resp.status_code}: {resp.text}"
        op_id = resp.json()["id"]
        result = poll_operation(client, op_id)

        if result["status"] == "completed":
            return result

        error_msg = result.get("error", "")
        if "another operation" in error_msg and attempt < MAX_OPERATION_RETRIES:
            print(f"  Enable {name}: helm busy, retrying ({attempt}/{MAX_OPERATION_RETRIES})...")
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue

        raise AssertionError(f"Enable {name} failed: {error_msg}")

    raise AssertionError(f"Enable {name} failed after {MAX_OPERATION_RETRIES} retries")


def disable_module(client: httpx.Client, name: str) -> dict:
    """Disable a module and poll to completion, with retry for helm contention."""
    ensure_api_reachable(client)
    wait_for_helm_ready()

    for attempt in range(1, MAX_OPERATION_RETRIES + 1):
        resp = client.post(f"/modules/{name}/disable")
        assert resp.status_code == 202, f"Disable {name} returned {resp.status_code}: {resp.text}"
        op_id = resp.json()["id"]
        result = poll_operation(client, op_id)

        if result["status"] == "completed":
            return result

        error_msg = result.get("error", "")
        if "another operation" in error_msg and attempt < MAX_OPERATION_RETRIES:
            print(f"  Disable {name}: helm busy, retrying ({attempt}/{MAX_OPERATION_RETRIES})...")
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue

        raise AssertionError(f"Disable {name} failed: {error_msg}")

    raise AssertionError(f"Disable {name} failed after {MAX_OPERATION_RETRIES} retries")


def verify_module_enabled(client: httpx.Client, name: str, *, expect_pods: bool = True) -> dict:
    """Verify module shows as enabled with optional pod check."""
    ensure_api_reachable(client)
    resp = client.get(f"/modules/{name}")
    assert resp.status_code == 200
    data = resp.json()
    assert data["enabled"] is True, f"{name} should be enabled"
    if expect_pods and data.get("pods") is not None:
        assert len(data["pods"]) > 0, f"{name} should have running pods"
    return data


def wait_for_pods(
    client: httpx.Client,
    name: str,
    *,
    timeout: float = 120,
    interval: float = 5,
) -> list[dict]:
    """Wait for a module's pods to appear and become ready."""
    deadline = time.monotonic() + timeout
    pods: list[dict] = []
    while time.monotonic() < deadline:
        ensure_api_reachable(client)
        resp = client.get(f"/modules/{name}")
        assert resp.status_code == 200
        data = resp.json()
        pods = data.get("pods", [])
        if pods and all(p["ready"] for p in pods):
            return pods
        time.sleep(interval)
    raise TimeoutError(f"Module '{name}' pods not ready within {timeout}s. Last pods: {pods}")


def verify_module_disabled(client: httpx.Client, name: str) -> dict:
    """Verify module shows as disabled."""
    ensure_api_reachable(client)
    resp = client.get(f"/modules/{name}")
    assert resp.status_code == 200
    data = resp.json()
    assert data["enabled"] is False, f"{name} should be disabled"
    return data


# ---------------------------------------------------------------------------
# Transition-tracking helpers
# ---------------------------------------------------------------------------

TRANSITION_POLL_INTERVAL = 3  # seconds — tighter to catch brief phases


def poll_operation_with_transitions(
    client: httpx.Client,
    op_id: str,
    *,
    timeout: float = POLL_TIMEOUT,
    interval: float = TRANSITION_POLL_INTERVAL,
) -> tuple[dict, list[dict]]:
    """Poll an operation and record every status transition.

    Like ``poll_operation`` but collects a snapshot dict each time the status
    changes.  Uses a tighter poll interval (3s) to catch the brief
    ``awaiting_readiness`` phase.

    Returns ``(final_result, transitions)`` where each transition is::

        {"status": str, "progress": int, ...optional keys...}
    """
    deadline = time.monotonic() + timeout
    transitions: list[dict] = []
    last_status: str | None = None
    base_url = str(client.base_url).rsplit("/api/v1", 1)[0]
    consecutive_errors = 0

    while time.monotonic() < deadline:
        try:
            resp = client.get(f"/operations/{op_id}")
        except _TRANSIENT_ERRORS:
            consecutive_errors += 1
            if consecutive_errors > 2:
                print("  Connection lost, re-establishing port-forward...")
                _restart_port_forward(base_url)
                _wait_for_api(base_url, timeout=180)
                consecutive_errors = 0
                try:
                    resp = client.get(f"/operations/{op_id}")
                    if resp.status_code == 404:
                        print(f"  Operation {op_id} lost after pod restart")
                        result: dict = {
                            "status": "completed",
                            "id": op_id,
                            "note": "pod_restarted",
                        }
                        transitions.append(
                            {"status": "completed", "progress": 100, "note": "pod_restarted"}
                        )
                        return result, transitions
                except _TRANSIENT_ERRORS:
                    pass
            time.sleep(interval)
            continue

        consecutive_errors = 0

        if resp.status_code == 404:
            print(f"  Operation {op_id} not found (pod likely restarted)")
            result = {"status": "completed", "id": op_id, "note": "pod_restarted"}
            transitions.append({"status": "completed", "progress": 100, "note": "pod_restarted"})
            return result, transitions

        assert resp.status_code == 200, f"Failed to poll operation {op_id}: {resp.text}"
        data = resp.json()
        status = data["status"]

        if status != last_status:
            print(f"  Operation {op_id}: {last_status} -> {status}")
            snapshot: dict = {
                "status": status,
                "progress": data.get("progress", 0),
            }
            for key in ("created_at", "completed_at", "job_name"):
                if data.get(key):
                    snapshot[key] = data[key]
            transitions.append(snapshot)
            last_status = status

        if status in ("completed", "failed"):
            return data, transitions

        time.sleep(interval)

    raise TimeoutError(
        f"Operation {op_id} did not complete within {timeout}s (last: {last_status})"
    )


def enable_module_with_transitions(
    client: httpx.Client, name: str, *, timeout: float = POLL_TIMEOUT
) -> tuple[dict, list[dict]]:
    """Enable a module and collect status transitions.

    Like ``enable_module`` but returns ``(final_result, transitions)`` with
    every observed status change.
    """
    ensure_api_reachable(client)
    wait_for_helm_ready()

    for attempt in range(1, MAX_OPERATION_RETRIES + 1):
        resp = client.post(f"/modules/{name}/enable")
        if resp.status_code == 409 and attempt < MAX_OPERATION_RETRIES:
            print(f"  Enable {name}: 409 conflict, retrying ({attempt}/{MAX_OPERATION_RETRIES})...")
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue
        assert resp.status_code == 202, f"Enable {name} returned {resp.status_code}: {resp.text}"
        op_id = resp.json()["id"]
        result, transitions = poll_operation_with_transitions(client, op_id, timeout=timeout)

        if result["status"] == "completed":
            return result, transitions

        error_msg = result.get("error", "")
        if "another operation" in error_msg and attempt < MAX_OPERATION_RETRIES:
            print(f"  Enable {name}: helm busy, retrying ({attempt}/{MAX_OPERATION_RETRIES})...")
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue

        raise AssertionError(f"Enable {name} failed: {error_msg}")

    raise AssertionError(f"Enable {name} failed after {MAX_OPERATION_RETRIES} retries")


def disable_module_with_transitions(
    client: httpx.Client, name: str, *, timeout: float = POLL_TIMEOUT
) -> tuple[dict, list[dict]]:
    """Disable a module and collect status transitions.

    Like ``disable_module`` but returns ``(final_result, transitions)`` with
    every observed status change.
    """
    ensure_api_reachable(client)
    wait_for_helm_ready()

    for attempt in range(1, MAX_OPERATION_RETRIES + 1):
        resp = client.post(f"/modules/{name}/disable")
        if resp.status_code == 409 and attempt < MAX_OPERATION_RETRIES:
            print(
                f"  Disable {name}: 409 conflict, retrying ({attempt}/{MAX_OPERATION_RETRIES})..."
            )
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue
        assert resp.status_code == 202, f"Disable {name} returned {resp.status_code}: {resp.text}"
        op_id = resp.json()["id"]
        result, transitions = poll_operation_with_transitions(client, op_id, timeout=timeout)

        if result["status"] == "completed":
            return result, transitions

        error_msg = result.get("error", "")
        if "another operation" in error_msg and attempt < MAX_OPERATION_RETRIES:
            print(f"  Disable {name}: helm busy, retrying ({attempt}/{MAX_OPERATION_RETRIES})...")
            time.sleep(OPERATION_RETRY_DELAY)
            wait_for_helm_ready()
            ensure_api_reachable(client)
            continue

        raise AssertionError(f"Disable {name} failed: {error_msg}")

    raise AssertionError(f"Disable {name} failed after {MAX_OPERATION_RETRIES} retries")


def poll_pods_during_operation(
    client: httpx.Client,
    module_name: str,
    op_id: str,
    *,
    timeout: float = POLL_TIMEOUT,
    interval: float = TRANSITION_POLL_INTERVAL,
) -> list[dict]:
    """Poll operation and module endpoints simultaneously, recording pod snapshots.

    Each snapshot contains::

        {
            "timestamp": float,
            "pods": [...],
            "op_status": str,
            "op_progress": int,
            "module_status": str,
        }

    Returns the list of snapshots once the operation reaches a terminal state.
    """
    deadline = time.monotonic() + timeout
    snapshots: list[dict] = []
    base_url = str(client.base_url).rsplit("/api/v1", 1)[0]
    consecutive_errors = 0

    while time.monotonic() < deadline:
        try:
            op_resp = client.get(f"/operations/{op_id}")
            mod_resp = client.get(f"/modules/{module_name}")
        except _TRANSIENT_ERRORS:
            consecutive_errors += 1
            if consecutive_errors > 2:
                print("  Connection lost, re-establishing port-forward...")
                _restart_port_forward(base_url)
                _wait_for_api(base_url, timeout=180)
                consecutive_errors = 0
                try:
                    op_resp = client.get(f"/operations/{op_id}")
                    if op_resp.status_code == 404:
                        snapshots.append(
                            {
                                "timestamp": time.time(),
                                "pods": [],
                                "op_status": "completed",
                                "op_progress": 100,
                                "module_status": "unknown",
                                "note": "pod_restarted",
                            }
                        )
                        return snapshots
                except _TRANSIENT_ERRORS:
                    pass
            time.sleep(interval)
            continue

        consecutive_errors = 0

        if op_resp.status_code == 404:
            snapshots.append(
                {
                    "timestamp": time.time(),
                    "pods": [],
                    "op_status": "completed",
                    "op_progress": 100,
                    "module_status": "unknown",
                    "note": "pod_restarted",
                }
            )
            return snapshots

        assert op_resp.status_code == 200, f"Poll op {op_id}: {op_resp.text}"
        op_data = op_resp.json()
        mod_data = mod_resp.json() if mod_resp.status_code == 200 else {}

        snapshot = {
            "timestamp": time.time(),
            "pods": mod_data.get("pods", []),
            "op_status": op_data["status"],
            "op_progress": op_data.get("progress", 0),
            "module_status": mod_data.get("status", "unknown"),
        }
        snapshots.append(snapshot)
        print(
            f"  [{module_name}] op={op_data['status']} "
            f"progress={op_data.get('progress', 0)} "
            f"pods={len(snapshot['pods'])} "
            f"module_status={snapshot['module_status']}"
        )

        if op_data["status"] in ("completed", "failed"):
            return snapshots

        time.sleep(interval)

    raise TimeoutError(f"Operation {op_id} for {module_name} did not complete within {timeout}s")


def assert_enable_transitions(transitions: list[dict]) -> None:
    """Validate that enable transitions follow the expected state machine.

    Expected: running -> [awaiting_readiness ->] completed

    - First status must be ``"running"``
    - Must include ``"awaiting_readiness"`` (soft: warn-only if missing)
    - Last status must be ``"completed"``
    - Progress must increase monotonically
    - Handles pod-restart shortcut (synthetic ``"completed"`` with ``note``)
    """
    assert len(transitions) > 0, "No transitions recorded"

    # First status: "pending" or "running" (depends on timing)
    assert transitions[0]["status"] in ("pending", "running"), (
        f"First transition should be 'pending' or 'running', got '{transitions[0]['status']}'"
    )

    # Must include "running" somewhere
    statuses = [t["status"] for t in transitions]
    assert "running" in statuses, f"No 'running' phase observed: {statuses}"

    assert transitions[-1]["status"] == "completed", (
        f"Last transition should be 'completed', got '{transitions[-1]['status']}'"
    )

    # Soft check for awaiting_readiness
    if "awaiting_readiness" not in statuses:
        import warnings as _warnings

        _warnings.warn(
            "No 'awaiting_readiness' phase captured — "
            "module may have transitioned too fast to observe",
            stacklevel=2,
        )

    # Progress must increase monotonically
    progress_values = [t["progress"] for t in transitions]
    for i in range(1, len(progress_values)):
        assert progress_values[i] >= progress_values[i - 1], (
            f"Progress decreased: {progress_values[i - 1]} -> {progress_values[i]}"
        )


def assert_disable_transitions(transitions: list[dict]) -> None:
    """Validate that disable transitions follow the expected state machine.

    Expected: [pending ->] running -> completed (no awaiting_readiness).
    """
    assert len(transitions) > 0, "No transitions recorded"

    assert transitions[0]["status"] in ("pending", "running"), (
        f"First transition should be 'pending' or 'running', got '{transitions[0]['status']}'"
    )

    assert transitions[-1]["status"] == "completed", (
        f"Last transition should be 'completed', got '{transitions[-1]['status']}'"
    )
