"""Module dependency graph and resolver for the Ilum platform.

Encodes every module's enable flags, dependencies, and conflicts as a
declarative registry.  The ``ModuleResolver`` provides topological
resolution of transitive dependencies, conflict detection, and safe
disable-flag generation.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Any

from ilum.errors import ModuleError

# ---------------------------------------------------------------------------
# Enumerations
# ---------------------------------------------------------------------------


class ModuleCategory(Enum):
    """Logical grouping for Ilum platform modules."""

    CORE = "core"
    NOTEBOOK = "notebook"
    SQL = "sql"
    ORCHESTRATION = "orchestration"
    ANALYTICS = "analytics"
    INFRASTRUCTURE = "infrastructure"
    MONITORING = "monitoring"
    STORAGE = "storage"
    AI = "ai"
    SECURITY = "security"


# ---------------------------------------------------------------------------
# Module definition
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ModuleDefinition:
    """Immutable descriptor for a single Ilum module."""

    name: str
    """Short identifier, e.g. ``"sql"``, ``"jupyter"``."""

    description: str
    """One-line human-readable summary."""

    category: ModuleCategory
    """Logical category used for filtering and display."""

    enable_flags: tuple[str, ...]
    """Helm ``--set`` flags that enable this module."""

    disable_flags: tuple[str, ...]
    """Helm ``--set`` flags that disable this module."""

    requires: tuple[str, ...] = ()
    """Module names that must also be enabled for this module to work."""

    conflicts_with: tuple[str, ...] = ()
    """Module names that cannot be active at the same time."""

    chart_condition: str = ""
    """Exact ``condition`` value from ``helm_aio/Chart.yaml``."""

    values_key: str = ""
    """Top-level key in ``helm_aio/values.yaml``."""

    pod_label: str = ""
    """Label selector for querying pods, e.g. ``"app=ilum-core"``."""

    default_enabled: bool = False
    """Whether the module is enabled in a default Ilum installation."""

    required: bool = False
    """Whether this module is mandatory and cannot be deselected in the init wizard."""

    crd_chart: str = ""
    """Helm chart that provides required CRDs (installed before enable)."""

    console_path: str = ""
    """URL path to the module's web UI (e.g. ``/external/jupyter/``)."""


# ---------------------------------------------------------------------------
# Module registry
# ---------------------------------------------------------------------------

MODULE_REGISTRY: tuple[ModuleDefinition, ...] = (
    # -- Core -----------------------------------------------------------------
    ModuleDefinition(
        name="core",
        description="Ilum backend API (Spring Boot on Spark)",
        category=ModuleCategory.CORE,
        enable_flags=("ilum-core.enabled=true",),
        disable_flags=("ilum-core.enabled=false",),
        chart_condition="ilum-core.enabled",
        values_key="ilum-core",
        pod_label="app=ilum-core",
        default_enabled=True,
        required=True,
    ),
    ModuleDefinition(
        name="ui",
        description="Ilum web frontend (React + Nginx reverse proxy)",
        category=ModuleCategory.CORE,
        enable_flags=("ilum-ui.enabled=true",),
        disable_flags=("ilum-ui.enabled=false",),
        requires=("core",),
        chart_condition="ilum-ui.enabled",
        values_key="ilum-ui",
        pod_label="app=ilum-ui",
        default_enabled=True,
        required=True,
    ),
    # -- Infrastructure -------------------------------------------------------
    ModuleDefinition(
        name="mongodb",
        description="MongoDB document store for metadata",
        category=ModuleCategory.INFRASTRUCTURE,
        enable_flags=("mongodb.enabled=true",),
        disable_flags=("mongodb.enabled=false",),
        chart_condition="mongodb.enabled",
        values_key="mongodb",
        pod_label="app.kubernetes.io/name=mongodb",
        default_enabled=True,
        required=True,
    ),
    ModuleDefinition(
        name="kafka",
        description="Apache Kafka event bus",
        category=ModuleCategory.INFRASTRUCTURE,
        enable_flags=(
            "kafka.enabled=true",
            "ilum-core.communication.type=kafka",
        ),
        disable_flags=("kafka.enabled=false",),
        requires=("core",),
        chart_condition="kafka.enabled",
        values_key="kafka",
        pod_label="app.kubernetes.io/name=kafka",
    ),
    ModuleDefinition(
        name="minio",
        description="MinIO S3-compatible object storage",
        category=ModuleCategory.STORAGE,
        enable_flags=("minio.enabled=true",),
        disable_flags=("minio.enabled=false",),
        chart_condition="minio.enabled",
        values_key="minio",
        pod_label="app.kubernetes.io/name=minio",
        default_enabled=True,
        console_path="/external/minio/",
    ),
    ModuleDefinition(
        name="postgresql",
        description="PostgreSQL relational database",
        category=ModuleCategory.INFRASTRUCTURE,
        enable_flags=("postgresql.enabled=true",),
        disable_flags=("postgresql.enabled=false",),
        chart_condition="postgresql.enabled",
        values_key="postgresql",
        pod_label="app.kubernetes.io/name=postgresql",
        default_enabled=True,
    ),
    # -- Notebooks ------------------------------------------------------------
    ModuleDefinition(
        name="jupyter",
        description="Jupyter notebook server with Sparkmagic",
        category=ModuleCategory.NOTEBOOK,
        enable_flags=("ilum-jupyter.enabled=true",),
        disable_flags=("ilum-jupyter.enabled=false",),
        requires=("core",),
        chart_condition="ilum-jupyter.enabled",
        values_key="ilum-jupyter",
        pod_label="app=ilum-jupyter",
        default_enabled=True,
        console_path="/external/jupyter/",
    ),
    ModuleDefinition(
        name="jupyterhub",
        description="Multi-user JupyterHub (Kubernetes >= 1.28)",
        category=ModuleCategory.NOTEBOOK,
        enable_flags=("ilum-jupyterhub.enabled=true",),
        disable_flags=("ilum-jupyterhub.enabled=false",),
        requires=("core",),
        chart_condition="ilum-jupyterhub.enabled",
        values_key="ilum-jupyterhub",
        pod_label="app=ilum-jupyterhub",
        console_path="/external/jupyterhub/",
    ),
    ModuleDefinition(
        name="zeppelin",
        description="Apache Zeppelin notebook server",
        category=ModuleCategory.NOTEBOOK,
        enable_flags=("ilum-zeppelin.enabled=true",),
        disable_flags=("ilum-zeppelin.enabled=false",),
        requires=("core",),
        chart_condition="ilum-zeppelin.enabled",
        values_key="ilum-zeppelin",
        pod_label="app.kubernetes.io/name=ilum-zeppelin",
        console_path="/external/zeppelin/",
    ),
    # -- Proxy ----------------------------------------------------------------
    ModuleDefinition(
        name="livy-proxy",
        description="Livy-compatible Spark session proxy",
        category=ModuleCategory.CORE,
        enable_flags=("ilum-livy-proxy.legacy.enabled=true",),
        disable_flags=("ilum-livy-proxy.legacy.enabled=false",),
        requires=("core",),
        chart_condition="ilum-livy-proxy.legacy.enabled",
        values_key="ilum-livy-proxy",
        pod_label="app=ilum-livy-proxy",
    ),
    # -- Orchestration --------------------------------------------------------
    ModuleDefinition(
        name="airflow",
        description="Apache Airflow workflow orchestration",
        category=ModuleCategory.ORCHESTRATION,
        enable_flags=("airflow.enabled=true",),
        disable_flags=("airflow.enabled=false",),
        requires=("postgresql",),
        chart_condition="airflow.enabled",
        values_key="airflow",
        pod_label="tier=airflow",
        console_path="/external/airflow/",
    ),
    ModuleDefinition(
        name="kestra",
        description="Kestra declarative orchestration engine",
        category=ModuleCategory.ORCHESTRATION,
        enable_flags=("kestra.enabled=true",),
        disable_flags=("kestra.enabled=false",),
        requires=("postgresql",),
        chart_condition="kestra.enabled",
        values_key="kestra",
        pod_label="app.kubernetes.io/name=kestra",
        console_path="/external/kestra/",
    ),
    ModuleDefinition(
        name="n8n",
        description="n8n workflow automation platform",
        category=ModuleCategory.ORCHESTRATION,
        enable_flags=("ilum-n8n.enabled=true",),
        disable_flags=("ilum-n8n.enabled=false",),
        requires=("postgresql",),
        chart_condition="ilum-n8n.enabled",
        values_key="n8n",
        pod_label="app.kubernetes.io/name=n8n",
        console_path="/external/n8n/",
    ),
    ModuleDefinition(
        name="nifi",
        description="Apache NiFi data integration",
        category=ModuleCategory.ORCHESTRATION,
        enable_flags=("nifi.enabled=true",),
        disable_flags=("nifi.enabled=false",),
        requires=("core",),
        chart_condition="nifi.enabled",
        values_key="nifi",
        pod_label="app.kubernetes.io/name=nifi",
        console_path="/external/nifi/",
    ),
    ModuleDefinition(
        name="mageai",
        description="Mage.ai data pipeline tool",
        category=ModuleCategory.ORCHESTRATION,
        enable_flags=("mageai.enabled=true",),
        disable_flags=("mageai.enabled=false",),
        requires=("postgresql",),
        chart_condition="mageai.enabled",
        values_key="mageai",
        pod_label="app.kubernetes.io/name=ilum-mageai",
        console_path="/external/mageai/",
    ),
    # -- Analytics / ML -------------------------------------------------------
    ModuleDefinition(
        name="mlflow",
        description="MLflow experiment tracking and model registry",
        category=ModuleCategory.AI,
        enable_flags=("mlflow.enabled=true",),
        disable_flags=("mlflow.enabled=false",),
        requires=("postgresql",),
        chart_condition="mlflow.enabled",
        values_key="mlflow",
        pod_label="app.kubernetes.io/name=mlflow",
        console_path="/external/mlflow/",
    ),
    ModuleDefinition(
        name="superset",
        description="Apache Superset BI dashboards",
        category=ModuleCategory.ANALYTICS,
        enable_flags=("superset.enabled=true",),
        disable_flags=("superset.enabled=false",),
        requires=("postgresql",),
        chart_condition="superset.enabled",
        values_key="superset",
        pod_label="app=superset",
        console_path="/external/superset/",
    ),
    ModuleDefinition(
        name="trino",
        description="Trino distributed SQL query engine",
        category=ModuleCategory.SQL,
        enable_flags=(
            "trino.enabled=true",
            "ilum-sql.config.trino.enabled=true",
        ),
        disable_flags=(
            "trino.enabled=false",
            "ilum-sql.config.trino.enabled=false",
        ),
        requires=("hive-metastore", "sql"),
        chart_condition="trino.enabled",
        values_key="trino",
        pod_label="app.kubernetes.io/name=trino",
        console_path="/external/trino/",
    ),
    ModuleDefinition(
        name="streamlit",
        description="Streamlit interactive data applications",
        category=ModuleCategory.ANALYTICS,
        enable_flags=("streamlit.enabled=true",),
        disable_flags=("streamlit.enabled=false",),
        requires=("core",),
        chart_condition="streamlit.enabled",
        values_key="streamlit",
        pod_label="app.kubernetes.io/name=streamlit",
        console_path="/external/streamlit/",
    ),
    # -- SQL ------------------------------------------------------------------
    ModuleDefinition(
        name="sql",
        description="Ilum SQL engine (Kyuubi)",
        category=ModuleCategory.SQL,
        enable_flags=(
            "ilum-sql.enabled=true",
            "ilum-core.sql.enabled=true",
        ),
        disable_flags=(
            "ilum-sql.enabled=false",
            "ilum-core.sql.enabled=false",
        ),
        requires=("postgresql", "core"),
        chart_condition="ilum-sql.enabled",
        values_key="ilum-sql",
        pod_label="app.kubernetes.io/name=ilum-sql",
    ),
    # -- Lineage / Catalog ----------------------------------------------------
    ModuleDefinition(
        name="marquez",
        description="Marquez data lineage tracking",
        category=ModuleCategory.ANALYTICS,
        enable_flags=("global.lineage.enabled=true",),
        disable_flags=("global.lineage.enabled=false",),
        requires=("postgresql",),
        chart_condition="global.lineage.enabled",
        values_key="ilum-marquez",
        pod_label="app.kubernetes.io/name=ilum-marquez",
    ),
    ModuleDefinition(
        name="hive-metastore",
        description="Hive Metastore service",
        category=ModuleCategory.SQL,
        enable_flags=(
            "ilum-hive-metastore.enabled=true",
            "ilum-core.metastore.enabled=true",
            "ilum-core.metastore.type=hive",
        ),
        disable_flags=(
            "ilum-hive-metastore.enabled=false",
            "ilum-core.metastore.enabled=false",
        ),
        requires=("postgresql", "core"),
        conflicts_with=("nessie", "unity-catalog"),
        chart_condition="ilum-hive-metastore.enabled",
        values_key="ilum-hive-metastore",
        pod_label="app=ilum-hive-metastore",
    ),
    ModuleDefinition(
        name="nessie",
        description="Nessie versioned data catalog",
        category=ModuleCategory.SQL,
        enable_flags=(
            "nessie.enabled=true",
            "ilum-core.metastore.enabled=true",
            "ilum-core.metastore.type=nessie",
        ),
        disable_flags=(
            "nessie.enabled=false",
            "ilum-core.metastore.enabled=false",
        ),
        requires=("postgresql",),
        conflicts_with=("hive-metastore", "unity-catalog"),
        chart_condition="nessie.enabled",
        values_key="nessie",
        pod_label="app.kubernetes.io/name=nessie",
    ),
    ModuleDefinition(
        name="unity-catalog",
        description="Unity Catalog for data governance",
        category=ModuleCategory.SQL,
        enable_flags=(
            "ilum-unity-catalog.enabled=true",
            "ilum-core.metastore.enabled=true",
            "ilum-core.metastore.type=unity",
        ),
        disable_flags=(
            "ilum-unity-catalog.enabled=false",
            "ilum-core.metastore.enabled=false",
        ),
        requires=("postgresql",),
        conflicts_with=("hive-metastore", "nessie"),
        chart_condition="ilum-unity-catalog.enabled",
        values_key="ilum-unity-catalog",
        pod_label="app.kubernetes.io/name=unitycatalog",
    ),
    # -- Monitoring -----------------------------------------------------------
    ModuleDefinition(
        name="monitoring",
        description="Prometheus + Grafana monitoring stack",
        category=ModuleCategory.MONITORING,
        enable_flags=("kube-prometheus-stack.enabled=true",),
        disable_flags=("kube-prometheus-stack.enabled=false",),
        chart_condition="kube-prometheus-stack.enabled",
        values_key="kube-prometheus-stack",
        pod_label="app.kubernetes.io/name=kube-prometheus-stack",
        crd_chart="prometheus-community/prometheus-operator-crds",
        console_path="/external/grafana/",
    ),
    ModuleDefinition(
        name="loki",
        description="Grafana Loki log aggregation",
        category=ModuleCategory.MONITORING,
        enable_flags=(
            "global.logAggregation.enabled=true",
            "global.logAggregation.loki.enabled=true",
            "global.logAggregation.promtail.enabled=true",
        ),
        disable_flags=(
            "global.logAggregation.enabled=false",
            "global.logAggregation.loki.enabled=false",
            "global.logAggregation.promtail.enabled=false",
        ),
        chart_condition="global.logAggregation.loki.enabled",
        values_key="loki",
        pod_label="app.kubernetes.io/name=loki",
    ),
    ModuleDefinition(
        name="graphite",
        description="Graphite metrics exporter",
        category=ModuleCategory.MONITORING,
        enable_flags=(
            "graphite-exporter.graphite.enabled=true",
            "ilum-core.job.graphite.enabled=true",
        ),
        disable_flags=(
            "graphite-exporter.graphite.enabled=false",
            "ilum-core.job.graphite.enabled=false",
        ),
        chart_condition="graphite-exporter.graphite.enabled",
        values_key="graphite-exporter",
        pod_label="app.kubernetes.io/name=graphite",
    ),
    # -- Source control -------------------------------------------------------
    ModuleDefinition(
        name="gitea",
        description="Gitea self-hosted Git service",
        category=ModuleCategory.INFRASTRUCTURE,
        enable_flags=("gitea.enabled=true",),
        disable_flags=("gitea.enabled=false",),
        requires=("postgresql",),
        chart_condition="gitea.enabled",
        values_key="gitea",
        pod_label="app.kubernetes.io/name=gitea",
        default_enabled=True,
        console_path="/external/gitea/",
    ),
    # -- Security -------------------------------------------------------------
    ModuleDefinition(
        name="openldap",
        description="OpenLDAP directory service",
        category=ModuleCategory.SECURITY,
        enable_flags=("openldap.enabled=true",),
        disable_flags=("openldap.enabled=false",),
        chart_condition="openldap.enabled",
        values_key="openldap",
        pod_label="app.kubernetes.io/name=openldap",
    ),
    # -- AI -------------------------------------------------------------------
    ModuleDefinition(
        name="langfuse",
        description="Langfuse LLM observability platform",
        category=ModuleCategory.AI,
        enable_flags=("langfuse.enabled=true",),
        disable_flags=("langfuse.enabled=false",),
        requires=("postgresql", "clickhouse"),
        chart_condition="langfuse.enabled",
        values_key="langfuse",
        pod_label="app.kubernetes.io/name=langfuse",
        console_path="/external/langfuse/",
    ),
    ModuleDefinition(
        name="clickhouse",
        description="ClickHouse columnar analytics database",
        category=ModuleCategory.INFRASTRUCTURE,
        enable_flags=("clickhouse.enabled=true",),
        disable_flags=("clickhouse.enabled=false",),
        chart_condition="clickhouse.enabled",
        values_key="clickhouse",
        pod_label="app.kubernetes.io/name=clickhouse",
    ),
    # -- API ------------------------------------------------------------------
    ModuleDefinition(
        name="api",
        description="Ilum REST API for module management",
        category=ModuleCategory.CORE,
        enable_flags=("ilum-api.enabled=true",),
        disable_flags=("ilum-api.enabled=false",),
        requires=("core",),
        chart_condition="ilum-api.enabled",
        values_key="ilum-api",
        pod_label="app.kubernetes.io/name=api",
    ),
)


# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------


class ModuleResolver:
    """Resolves module dependencies, conflicts, and Helm flags.

    The resolver builds an internal lookup from ``MODULE_REGISTRY`` and
    provides helpers that walk the dependency graph.
    """

    def __init__(self) -> None:
        self._modules: dict[str, ModuleDefinition] = {m.name: m for m in MODULE_REGISTRY}

    # -- Defaults ------------------------------------------------------------

    def default_enabled(self) -> list[str]:
        """Return names of modules that are enabled in a default installation."""
        return [m.name for m in self._modules.values() if m.default_enabled]

    # -- Required modules ----------------------------------------------------

    def required_modules(self) -> list[str]:
        """Return names of modules marked as required (non-deselectable)."""
        return [m.name for m in self._modules.values() if m.required]

    # -- Selection resolution ------------------------------------------------

    def resolve_selection(self, selected: list[str]) -> tuple[list[str], list[str]]:
        """Enforce required modules and resolve transitive dependencies.

        Returns ``(final_module_list, info_messages)`` where *info_messages*
        explains any auto-added modules.
        """
        final: set[str] = set(selected)
        messages: list[str] = []

        # 1. Enforce required modules
        for name in self.required_modules():
            if name not in final:
                final.add(name)
                messages.append(f"Added '{name}' (required)")

        # 2. Resolve transitive dependencies
        to_check = list(final)
        for name in to_check:
            deps: set[str] = set()
            self._collect_all_deps(name, deps)
            for dep in deps:
                if dep not in final:
                    final.add(dep)
                    to_check.append(dep)
                    messages.append(f"Added '{dep}' (required by '{name}')")

        # 3. Report conflicts
        conflicts = self.validate_combination(frozenset(final))
        for warning in conflicts:
            messages.append(f"Conflict: {warning}")

        return sorted(final), messages

    # -- Live detection ------------------------------------------------------

    def detect_enabled_modules(self, live_values: dict[str, Any]) -> list[str]:
        """Detect which modules are enabled based on live Helm values.

        Walks all modules, parses each ``chart_condition`` as a dot-path,
        resolves against *live_values*, and returns names where the value
        is truthy.
        """
        enabled: list[str] = []
        for mod in self._modules.values():
            if not mod.chart_condition:
                continue
            value = self._resolve_dotted(live_values, mod.chart_condition)
            if value:
                enabled.append(mod.name)
        return enabled

    @staticmethod
    def _resolve_dotted(data: dict[str, Any], path: str) -> Any | None:
        """Resolve a dot-separated path against a nested dict.

        Returns ``None`` if any segment is missing.
        """
        current: Any = data
        for segment in path.split("."):
            if not isinstance(current, dict):
                return None
            current = current.get(segment)
            if current is None:
                return None
        return current

    # -- Lookup --------------------------------------------------------------

    def get(self, name: str) -> ModuleDefinition:
        """Return a module by *name*, or raise ``ModuleError``."""
        try:
            return self._modules[name]
        except KeyError:
            available = ", ".join(sorted(self._modules))
            raise ModuleError(
                f"Unknown module: {name!r}",
                suggestion=f"Available modules: {available}",
                error_code="ILUM-040",
            ) from None

    def all_modules(self) -> list[ModuleDefinition]:
        """Return every registered module."""
        return list(self._modules.values())

    def by_category(self, category: ModuleCategory) -> list[ModuleDefinition]:
        """Return modules belonging to *category*."""
        return [m for m in self._modules.values() if m.category is category]

    # -- Enable resolution ---------------------------------------------------

    def resolve_enables(self, name: str) -> list[str]:
        """Return all ``--set`` flags needed to enable *name* and its deps.

        Walks the ``requires`` graph in topological order (dependencies
        first) and collects every ``enable_flags`` entry.  A visited set
        prevents infinite loops if the graph has cycles.
        """
        ordered: list[ModuleDefinition] = []
        visited: set[str] = set()
        self._topo_visit(name, visited, ordered)
        flags: list[str] = []
        for module in ordered:
            flags.extend(module.enable_flags)
        return flags

    # -- Disable resolution --------------------------------------------------

    def resolve_disables(
        self,
        name: str,
        active_modules: frozenset[str],
    ) -> list[str]:
        """Return ``--set`` flags to disable *name*.

        Dependencies that are still required by other *active_modules*
        are **not** disabled.
        """
        module = self.get(name)

        # Remaining active set after removing the module being disabled.
        remaining = active_modules - {name}

        # Collect all deps still required by the remaining active modules.
        still_needed: set[str] = set()
        for other_name in remaining:
            other = self._modules.get(other_name)
            if other is None:
                continue
            self._collect_all_deps(other_name, still_needed)

        flags: list[str] = list(module.disable_flags)

        # Walk transitive deps of the module being disabled and disable
        # those that are no longer needed.
        own_deps: set[str] = set()
        self._collect_all_deps(name, own_deps)
        own_deps.discard(name)

        for dep_name in own_deps:
            if dep_name in still_needed:
                continue
            if dep_name in remaining:
                # The dep is itself explicitly active -- don't disable it.
                continue
            dep = self._modules.get(dep_name)
            if dep is not None:
                flags.extend(dep.disable_flags)

        return flags

    # -- Validation ----------------------------------------------------------

    def validate_combination(self, modules: frozenset[str]) -> list[str]:
        """Check *modules* for conflicts and missing dependencies.

        Returns a list of human-readable warning/error messages.  An empty
        list means the combination is valid.
        """
        warnings: list[str] = []

        for name in sorted(modules):
            module = self._modules.get(name)
            if module is None:
                warnings.append(f"Unknown module: {name!r}")
                continue

            # Missing dependencies
            for req in module.requires:
                if req not in modules:
                    warnings.append(f"Module {name!r} requires {req!r}, which is not enabled")

            # Conflicts
            for conflict in module.conflicts_with:
                if conflict in modules:
                    warnings.append(
                        f"Module {name!r} conflicts with {conflict!r}, but both are enabled"
                    )

        return warnings

    # -- Internal helpers ----------------------------------------------------

    def _topo_visit(
        self,
        name: str,
        visited: set[str],
        ordered: list[ModuleDefinition],
    ) -> None:
        """Depth-first topological walk over the ``requires`` graph."""
        if name in visited:
            return
        visited.add(name)

        module = self.get(name)

        for dep_name in module.requires:
            self._topo_visit(dep_name, visited, ordered)

        ordered.append(module)

    def _collect_all_deps(self, name: str, result: set[str]) -> None:
        """Recursively collect all transitive dependencies of *name*."""
        if name in result:
            return
        result.add(name)
        module = self._modules.get(name)
        if module is None:
            return
        for dep_name in module.requires:
            self._collect_all_deps(dep_name, result)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def extract_module_version(module_values: dict[str, Any]) -> str:
    """Extract a version string from a module's values dict.

    Handles three image formats found in ``helm_aio/values.yaml``:
    - String: ``"ilum/core:6.7.0-RC2"`` -> ``"6.7.0-RC2"``
    - Dict with tag: ``{repository: "ilum/superset", tag: "4.1.0.1"}``
      -> ``"4.1.0.1"``
    - Dict with registry+repo+tag: ``{..., tag: "6.0.5"}`` -> ``"6.0.5"``

    Returns ``""`` if no image field is found or the format is
    unrecognised.
    """
    image = module_values.get("image")
    if image is None:
        return ""
    if isinstance(image, str):
        # "ilum/core:6.7.0-RC2" -> "6.7.0-RC2"
        if ":" in image:
            return image.rsplit(":", 1)[1]
        return ""
    if isinstance(image, dict):
        tag = image.get("tag")
        if tag is not None:
            return str(tag)
    return ""
