"""
Build parameter dependency graph and compute required-parameter set from command schema.

Author: Vasiliy Zdanovskiy
email: vasilyvz@gmail.com

Purpose: Pure in-memory transformation from a JSON Schema–like command schema (dict)
to (1) a parameter dependency graph and (2) the exact set of required parameter names.
Used by the orchestration layer (e.g. catalog_builder) for FR-4/FR-5: graph-derived
required set is the validation source before remote invocation. No I/O, no network.
"""

from typing import Any, Dict, Set, Tuple

# Keys used in schema (JSON Schema–like)
_SCHEMA_PROPERTIES = "properties"
_SCHEMA_REQUIRED = "required"
# Optional per-property dependency keys (if present, used to build graph edges)
_PROP_DEPENDS_ON = "dependsOn"
_PROP_DEPENDENCIES = "dependencies"


def build_parameter_graph(schema: Any) -> Tuple[Dict[str, Set[str]], Set[str]]:
    """
    Build parameter dependency graph and required-parameter set from a command schema.

    Expects a JSON Schema–like dict with optional "properties" (param name -> subschema)
    and optional "required" (list of param names). If a property subschema contains
    "dependsOn" or "dependencies" (list of param names), those define graph edges.

    Args:
        schema: Command parameter schema (e.g. from Command.get_schema()). Can be None
                or non-dict.

    Returns:
        Tuple of (graph, required_names):
        - graph: Dict mapping each parameter name to the set of parameter names it
                 depends on. Only includes parameters that appear in "properties".
        - required_names: Set of parameter names that must be supplied. Derived from
                          schema "required" array plus any parameters that are
                          dependencies (direct or transitive) of those.

    Behavior for missing or malformed schema:
        - If schema is None or not a dict: returns ({}, set()).
        - If "properties" is missing or not a dict: returns ({}, set()).
        - "required" is ignored for names not in "properties".
        - Invalid "dependsOn"/"dependencies" (non-iterable or non-string elements)
          are skipped; only param names that exist in "properties" are added as edges.
    """
    if schema is None or not isinstance(schema, dict):
        return ({}, set())

    properties = schema.get(_SCHEMA_PROPERTIES)
    if properties is None or not isinstance(properties, dict):
        return ({}, set())

    param_names = set(properties.keys())
    graph: Dict[str, Set[str]] = {p: set() for p in param_names}

    for param, subschema in properties.items():
        if not isinstance(subschema, dict):
            continue
        deps: Set[str] = set()
        for key in (_PROP_DEPENDS_ON, _PROP_DEPENDENCIES):
            val = subschema.get(key)
            if val is None:
                continue
            try:
                for item in val:
                    if isinstance(item, str) and item in param_names:
                        deps.add(item)
            except TypeError:
                continue
        graph[param] = deps

    base_required = set(schema.get(_SCHEMA_REQUIRED) or []) & param_names
    required_final = _transitive_closure(base_required, graph)
    return (graph, required_final)


def _transitive_closure(roots: Set[str], graph: Dict[str, Set[str]]) -> Set[str]:
    """
    Return roots plus all parameters that are dependencies (direct or transitive).

    A node is in the closure if it is in roots or is a dependency of a node in
    the closure. "Dependency" here means: B is a dependency of A if A depends on B
    (graph[A] contains B). So we collect all nodes reachable by following edges
    backward from roots (roots need their dependencies to be present).

    Args:
        roots: Initial required parameter names.
        graph: Param -> set of params it depends on.

    Returns:
        Set of parameter names: roots plus every param that must be present because
        some required param (or its dependency) depends on it.
    """
    result = set(roots)
    stack = list(roots)
    while stack:
        node = stack.pop()
        for dep in graph.get(node, set()):
            if dep not in result:
                result.add(dep)
                stack.append(dep)
    return result
