"""
FuncStep memory contract validator for OpenHCS.

This module provides the FuncStepContractValidator class, which is responsible for
validating memory type declarations for FunctionStep instances in a pipeline.
"""

import ast
import importlib
import inspect
import logging
import sys
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple

from openhcs.constants.constants import VALID_MEMORY_TYPES, get_openhcs_config
from openhcs.core.steps.function_step import FunctionStep

from openhcs.core.components.validation import GenericValidator

# Import ObjectState - it's always available
from objectstate import ObjectState

logger = logging.getLogger(__name__)

# ===== DECLARATIVE DEFAULT VALUES =====
# These declarations control defaults and may be moved to configuration in the future

# Simple, direct error messages
def missing_memory_type_error(func_name, step_name):
    return (
        f"Function '{func_name}' in step '{step_name}' needs memory type decorator (@numpy, @cupy, @torch, etc.)\n"
        f"\n"
        f"💡 SOLUTION: Use OpenHCS registry functions instead of raw external library functions:\n"
        f"\n"
        f"❌ WRONG:\n"
        f"   import pyclesperanto as cle\n"
        f"   step = FunctionStep(func=cle.{func_name}, name='{step_name}')\n"
        f"\n"
        f"✅ CORRECT:\n"
        f"   from openhcs.processing.func_registry import get_function_by_name\n"
        f"   {func_name}_func = get_function_by_name('{func_name}', 'pyclesperanto')  # or 'numpy', 'cupy'\n"
        f"   step = FunctionStep(func={func_name}_func, name='{step_name}')\n"
        f"\n"
        f"📋 Available functions: Use get_all_function_names('pyclesperanto') to see all options"
    )

def inconsistent_memory_types_error(step_name, func1, func2):
    return f"Functions in step '{step_name}' have different memory types: {func1} vs {func2}"

def invalid_memory_type_error(func_name, input_type, output_type, valid_types):
    return f"Function '{func_name}' has invalid memory types: {input_type}/{output_type}. Valid: {valid_types}"

def invalid_function_error(location, func):
    return f"Invalid function in {location}: {func}"

def invalid_pattern_error(pattern):
    return f"Invalid function pattern: {pattern}"

def missing_required_args_error(func_name, step_name, missing_args):
    return f"Function '{func_name}' in step '{step_name}' missing required args: {missing_args}"

def complex_pattern_error(step_name):
    return f"Step '{step_name}' with special decorators must use simple function pattern"

def missing_external_library_error(func_name, step_name, module_name, install_command=None):
    error_msg = (
        f"Function '{func_name}' in step '{step_name}' requires external library '{module_name}' which is not installed.\n"
        f"\n"
        f"💡 SOLUTION: Install the required library before compiling the pipeline.\n"
        f"\n"
    )
    if install_command:
        error_msg += f"Install with: {install_command}\n"
    return error_msg


class ImportStatementExtractor(ast.NodeVisitor):
    """
    AST visitor to extract import statements from a function's source code.

    This visitor identifies explicit import statements (import x, from x import y)
    both at the module level and inside functions. It does not analyze attribute
    access patterns, avoiding false positives from local aliases like 'np' instead
    of 'numpy'.
    """

    def __init__(self, module_name: Optional[str] = None):
        """
        Initialize the extractor.

        Args:
            module_name: The name of the module being analyzed (for resolving relative imports)
        """
        self.modules: Set[str] = set()
        self.module_name = module_name
        # Common Python standard library modules to skip
        self.stdlib_modules = {
            'os', 'sys', 're', 'math', 'json', 'collections', 'itertools',
            'functools', 'typing', 'datetime', 'time', 'pathlib', 'io',
            'logging', 'warnings', 'contextlib', 'copy', 'pickle', 'random',
            'string', 'enum', 'dataclasses', 'inspect', 'ast', 'importlib',
            'types', 'numbers', 'abc', 'threading', 'multiprocessing',
            'concurrent', 'queue', 'subprocess', 'shutil', 'tempfile',
            'glob', 'fnmatch', 'hashlib', 'base64', 'uuid', 'decimal',
            'fractions', 'statistics', 'secrets', 'textwrap', 'unicodedata',
            'codecs', 'csv', 'configparser', 'xml', 'html', 'urllib',
            'http', 'email', 'mimetypes', 'socket', 'ssl', 'hashlib',
            'hmac', 'secrets', 'zipfile', 'tarfile', 'gzip', 'bz2', 'lzma',
            'sqlite3', 'decimal', 'fractions', 'statistics', 'typing',
            'typing_extensions', 'builtins', '__future__', 'warnings',
        }

    def visit_Import(self, node: ast.Import) -> None:
        """Visit import statements."""
        for alias in node.names:
            module_name = alias.name.split('.')[0]
            self._add_module_if_external(module_name)
        self.generic_visit(node)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Visit function definitions to extract inline imports."""
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        """Visit async function definitions to extract inline imports."""
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        """Visit from-import statements (AST uses node.level for relative imports)."""
        level = getattr(node, "level", 0) or 0

        if level > 0:
            # Relative import: use node.level to determine how many levels to go up
            absolute_module = self._resolve_relative_import(node.module, level)
            if absolute_module:
                # Only consider the true top-level package
                self._add_module_if_external(absolute_module.split(".")[0])
        elif node.module:
            # Absolute import: no level means it's an absolute import
            self._add_module_if_external(node.module.split(".")[0])

        self.generic_visit(node)

    def _resolve_relative_import(self, module: Optional[str], level: Optional[int] = None) -> Optional[str]:
        """
        Resolve an ImportFrom-relative import (module + level) to an absolute module name.

        This method supports two calling conventions for backward compatibility:
        1. New interface: _resolve_relative_import(module, level) - AST-based
        2. Old interface: _resolve_relative_import(relative_module) - string-based

        Args:
            module: The ImportFrom module (e.g., 'percentile_utils' for `from .percentile_utils import ...`)
                    OR the relative module string (e.g., '.percentile_utils') for old interface
            level: The ImportFrom level (1='.', 2='..', ...) for new interface, or None for old interface

        Returns:
            Absolute module name if resolution succeeds, None otherwise
        """
        if self.module_name is None:
            return None

        # Handle old interface (string-based) for backward compatibility
        if level is None:
            # Old interface: module is the relative module string (e.g., '.percentile_utils')
            relative_module = module
            if relative_module is None:
                return None

            # Count the number of dots in the relative import
            # e.g., '.' -> 1 (current package), '..' -> 2 (parent package), '...' -> 3 (grandparent package)
            level = 0
            for char in relative_module:
                if char == '.':
                    level += 1
                else:
                    break

            # Get the package part of the relative import (after the dots)
            # e.g., '.percentile_utils' -> 'percentile_utils'
            # e.g., '..utils' -> 'utils'
            package_part = relative_module[level:]
        else:
            # New interface: module is the module name (without dots), level is provided separately
            package_part = module

        # Split the current module name into parts
        # e.g., 'openhcs.processing.backends.processors.numpy_processor'
        # -> ['openhcs', 'processing', 'backends', 'processors', 'numpy_processor']
        module_parts = self.module_name.split('.')

        # Remove the last part (the module name itself)
        # e.g., ['openhcs', 'processing', 'backends', 'processors', 'numpy_processor']
        # -> ['openhcs', 'processing', 'backends', 'processors']
        module_parts = module_parts[:-1]

        # Go up the specified number of levels
        # In Python relative imports:
        # - '.' (level=1) means current package (same directory) -> don't go up
        # - '..' (level=2) means parent package -> go up 1 level
        # - '...' (level=3) means grandparent package -> go up 2 levels
        # So we need to go up (level - 1) levels
        levels_to_go_up = max(level - 1, 0)

        if levels_to_go_up >= len(module_parts):
            return None

        module_parts = module_parts[:-levels_to_go_up] if levels_to_go_up > 0 else module_parts

        # Add the module path parts (may be nested like "utils.foo")
        if package_part:
            package_parts = package_part.split(".")
            module_parts.extend(package_parts)

        # Join to get the absolute module name
        absolute_module = '.'.join(module_parts)
        return absolute_module

    def _add_module_if_external(self, module_name: str) -> None:
        """
        Add a module if it's external (not stdlib or openhcs).

        Args:
            module_name: The module name to check
        """
        # Skip openhcs internal modules
        if module_name == 'openhcs':
            return

        # Skip standard library modules
        if module_name in self.stdlib_modules:
            return

        # Skip built-in modules
        if module_name in ('builtins', '__builtins__'):
            return

        # Add the module
        self.modules.add(module_name)


def extract_import_statements(func: Callable) -> Set[str]:
    """
    Extract explicit import statements from a function's module's source code.

    This function parses the entire module's source code using AST and identifies
    only explicit import statements (import x, from x import y), avoiding
    false positives from attribute access patterns.

    Args:
        func: The function to analyze for import statements

    Returns:
        Set of top-level module names that are explicitly imported
    """
    # Get the module name from the function
    module_name = getattr(func, '__module__', None)
    if module_name is None:
        return set()

    try:
        # Get the module's source file
        module = importlib.import_module(module_name)
        module_file = getattr(module, '__file__', None)
        if module_file is None:
            return set()

        # Read the source code
        with open(module_file, 'r', encoding='utf-8') as f:
            source = f.read()
    except Exception:
        # Can't get source code
        return set()

    try:
        # Parse the source code into an AST
        tree = ast.parse(source)
    except SyntaxError:
        # Can't parse the source
        return set()

    # Extract import statements using the AST visitor
    extractor = ImportStatementExtractor(module_name)
    extractor.visit(tree)

    return extractor.modules

class FuncStepContractValidator:
    """
    Validator for FunctionStep memory type contracts.

    Enforces that all FunctionStep instances require explicit memory type declarations
    and named positional arguments for all functions.

    Key principles:
    1. All functions in a FunctionStep must have consistent memory types
    2. The shared memory types are set as the step's memory types in the step plan
    3. Memory types must be validated at plan time, not runtime
    4. No fallback or inference of memory types is allowed
    5. All function patterns (callable, tuple, list, dict) are supported
    6. When using (func, kwargs) pattern, all required positional arguments must be
        explicitly provided in the kwargs dict
    """

    @staticmethod
    def validate_external_library_installation(func: Callable, step_name: str) -> None:
        """
        Validate that external libraries required by a function are installed.

        This function uses a combined approach:
        1. For openhcs modules: Parse the module's source code to find all import statements
        2. For external modules: Import the module directly to verify it's installed

        This approach is more reliable than AST-based analysis because:
        1. It actually tests if dependencies work
        2. No false positives from local aliases (e.g., np instead of numpy)
        3. Works for any importable function
        4. The import error message will identify the missing dependency
        5. Catches dependencies in helper functions called by the main function
        6. Doesn't incorrectly flag openhcs internal modules as external

        Note: For openhcs modules, we parse the source code to find all import statements
        (including those in helper functions) and try to import each one. For external
        modules, we import the module directly.

        Args:
            func: The function to check for external library dependencies
            step_name: The name of the step containing the function

        Raises:
            ValueError: If the external library required by the function is not installed
        """
        # Get the module name from the function
        module_name = getattr(func, '__module__', None)
        if module_name is None:
            # No module info, skip validation (e.g., built-in or dynamically created)
            return

        # Extract the top-level package name
        # e.g., "openhcs.processing.backends.analysis.skan_axon_analysis" -> "openhcs"
        # e.g., "skimage.measure" -> "skimage"
        # e.g., "skan" -> "skan"
        top_level_package = module_name.split('.')[0]

        # For openhcs modules, parse source code for import statements
        if top_level_package == 'openhcs':
            # Extract import statements from the module's source code
            import_statements = extract_import_statements(func)

            # Try to import each module to verify it's installed
            for module_name_to_import in import_statements:
                try:
                    importlib.import_module(module_name_to_import)
                except ImportError as e:
                    # Parse the error message to extract the missing module name
                    error_str = str(e)
                    missing_module = module_name_to_import

                    # Try to extract the missing module from the error message
                    if "No module named" in error_str:
                        import re
                        match = re.search(r"No module named '([^']+)'", error_str)
                        if match:
                            missing_module = match.group(1)

                    # Generate a generic install command for the module
                    install_command = f'pip install {missing_module}'

                    raise ValueError(missing_external_library_error(
                        func.__name__, step_name, missing_module, install_command
                    )) from e
        else:
            # For external modules, try to import the module directly
            try:
                importlib.import_module(module_name)
            except ImportError as e:
                # Parse the error message to extract the missing module name
                # ImportError messages typically look like:
                # "No module named 'numpy'"
                # "cannot import name 'something' from 'module'"
                error_str = str(e)
                missing_module = top_level_package  # Default to the top-level package

                # Try to extract the missing module from the error message
                if "No module named" in error_str:
                    # Extract module name from quotes
                    import re
                    match = re.search(r"No module named '([^']+)'", error_str)
                    if match:
                        missing_module = match.group(1)
                elif "cannot import name" in error_str or "cannot import" in error_str:
                    # Use the top-level package as fallback
                    missing_module = top_level_package

                # Generate a generic install command for the module
                install_command = f'pip install {missing_module}'

                raise ValueError(missing_external_library_error(
                    func.__name__, step_name, missing_module, install_command
                )) from e

    @staticmethod
    def validate_pipeline(steps: List[Any], pipeline_context: Optional[Dict[str, Any]] = None, step_state_map: Optional[Dict[int, 'ObjectState']] = None, orchestrator=None) -> Dict[str, Dict[str, str]]:
        """
        Validate memory type contracts and function patterns for all FunctionStep instances in a pipeline.

        This validator must run after materialization and path planners to ensure
        proper plan integration. It verifies that these planners have run by checking
        pipeline_context for planner execution flags and by validating presence
        of required fields in step plans.

        Args:
            steps: The steps in the pipeline
            pipeline_context: Optional context object with planner execution flags
            step_state_map: Map of step index to ObjectState for accessing config values
            orchestrator: Optional orchestrator for dict pattern key validation

        Returns:
            Dictionary mapping step UIDs to memory type dictionaries

        Raises:
            ValueError: If any FunctionStep violates memory type contracts or dict pattern validation
            AssertionError: If required planners have not run before this validator
        """
        # Validate steps
        if not steps:
            logger.warning("No steps provided to FuncStepContractValidator")
            return {}

        # Verify that required planners have run before this validator
        if pipeline_context is not None:
            # Check that step plans exist and have required fields from planners
            if not pipeline_context.step_plans:
                raise AssertionError(
                    "Clause 101 Violation: Step plans must be initialized before FuncStepContractValidator."
                )

            # Check that materialization planner has run by verifying read_backend/write_backend exist
            sample_step_index = next(iter(pipeline_context.step_plans.keys()))
            sample_plan = pipeline_context.step_plans[sample_step_index]
            if 'read_backend' not in sample_plan or 'write_backend' not in sample_plan:
                raise AssertionError(
                    "Clause 101 Violation: Materialization planner must run before FuncStepContractValidator. "
                    "Step plans missing read_backend/write_backend fields."
                )
        else:
            logger.warning(
                "No pipeline_context provided to FuncStepContractValidator. "
                "Cannot verify planner execution order. Falling back to attribute checks."
            )

        # Create step memory types dictionary
        step_memory_types = {}

        # Process each step in the pipeline
        for i, step in enumerate(steps):
            # Only validate FunctionStep instances
            if isinstance(step, FunctionStep):
                # Verify that other planners have run before this validator by checking attributes
                # This is a fallback verification when pipeline_context is not provided
                try:
                    # Check for path planner fields (using dunder names)
                    _ = step.__input_dir__
                    _ = step.__output_dir__
                except AttributeError as e:
                    raise AssertionError(
                        f"Clause 101 Violation: Required planners must run before FuncStepContractValidator. "
                        f"Missing attribute: {e}. Path planner must run first."
                    ) from e

                step_objectstate = step_state_map.get(i) if step_state_map else None
                memory_types = FuncStepContractValidator.validate_funcstep(step, orchestrator, step_objectstate)
                step_memory_types[i] = memory_types  # Use step index instead of step_id



        return step_memory_types

    @staticmethod
    def validate_funcstep(step: FunctionStep, orchestrator=None, step_objectstate: Optional[ObjectState] = None) -> Dict[str, str]:
        """
        Validate memory type contracts, func_pattern structure, and dict pattern keys for a FunctionStep instance.

        Args:
            step: The FunctionStep to validate
            orchestrator: Optional orchestrator for dict pattern key validation
            step_objectstate: ObjectState for accessing config values

        Returns:
            Dictionary of validated memory types

        Raises:
            ValueError: If FunctionStep violates memory type contracts, structural rules,
                        or dict pattern key validation.
        """
        # Extracting config values via ObjectState get_saved_resolved_value()
        if step_objectstate is None:
            raise ValueError(f"Step '{step.name}': ObjectState is required for config access")

        variable_components = step_objectstate.get_saved_resolved_value('processing_config.variable_components')
        group_by = step_objectstate.get_saved_resolved_value('processing_config.group_by')
        input_source = step_objectstate.get_saved_resolved_value('processing_config.input_source')

        # Extracting function pattern and name from step
        func_pattern = step.func
        step_name = step.name

        # 1. Check if any function in the pattern uses special contract decorators
        # _extract_functions_from_pattern will raise ValueError if func_pattern itself is invalid (e.g. None, or bad structure)
        all_callables = FuncStepContractValidator._extract_functions_from_pattern(func_pattern, step_name)
        
        uses_special_contracts = False
        if all_callables: # Only check attributes if we have actual callables
            for f_callable in all_callables:
                if hasattr(f_callable, '__special_inputs__') or \
                   hasattr(f_callable, '__special_outputs__') or \
                   hasattr(f_callable, '__chain_breaker__'):
                    uses_special_contracts = True
                    break

        # 2. Special contracts validation is handled by validate_pattern_structure() below
        # No additional restrictions needed - all valid patterns support special contracts

        # 3. Validate using generic validation system
        config = get_openhcs_config()
        validator = GenericValidator(config)

        # Check for constraint violation: group_by ∈ variable_components
        if group_by and group_by.value in [vc.value for vc in variable_components]:
            # Auto-resolve constraint violation by setting group_by to NONE
            # Use GroupBy.NONE (explicit "no grouping") instead of None (which means "inherit")
            from openhcs.constants import GroupBy
            logger.warning(
                f"Step '{step_name}': Auto-resolved group_by conflict. "
                f"Set group_by to GroupBy.NONE due to conflict with variable_components {[vc.value for vc in variable_components]}. "
                f"Original group_by was {group_by.value}."
            )
            # Update group_by to GroupBy.NONE (explicit no-grouping)
            # Note: We don't mutate the step itself, just use the resolved value
            group_by = GroupBy.NONE

        # Sequential processing validation removed - it's now pipeline-level, not per-step

        # Validate step configuration after auto-resolution
        validation_result = validator.validate_step(
            variable_components, group_by, func_pattern, step_name
        )
        if not validation_result.is_valid:
            raise ValueError(validation_result.error_message)

        # Validate dict pattern keys if orchestrator is available
        if orchestrator is not None and isinstance(func_pattern, dict) and group_by is not None:
            dict_validation_result = validator.validate_dict_pattern_keys(
                func_pattern, group_by, step_name, orchestrator
            )
            if not dict_validation_result.is_valid:
                raise ValueError(dict_validation_result.error_message)

        # 4. Proceed with existing memory type validation using the original func_pattern
        input_type, output_type = FuncStepContractValidator.validate_function_pattern(
            func_pattern, step_name)

        # Return the validated memory types and store the func for stateless execution
        return {
            'input_memory_type': input_type,
            'output_memory_type': output_type,
            'func': func_pattern  # Store the validated func for stateless execution
        }

    @staticmethod
    def validate_function_pattern(
        func: Any,
        step_name: str
    ) -> Tuple[str, str]:
        """
        Validate memory type contracts for a function pattern.

        Args:
            func: The function pattern to validate
            step_name: The name of the step containing the function

        Returns:
            Tuple of (input_memory_type, output_memory_type)

        Raises:
            ValueError: If the function pattern violates memory type contracts
        """
        # Extract all functions from the pattern
        functions = FuncStepContractValidator.validate_pattern_structure(func, step_name)

        if not functions:
            raise ValueError(f"No valid functions found in pattern for step {step_name}")

        # Get memory types from the first function
        first_fn = functions[0]

        # Validate that external libraries are installed (compile-time check)
        # This catches missing dependencies like 'skan' before execution
        FuncStepContractValidator.validate_external_library_installation(first_fn, step_name)

        # Validate that the function has explicit memory type declarations
        try:
            input_type = first_fn.input_memory_type
            output_type = first_fn.output_memory_type
        except AttributeError as exc:
            raise ValueError(missing_memory_type_error(first_fn.__name__, step_name)) from exc

        # Validate memory types against known valid types
        if input_type not in VALID_MEMORY_TYPES or output_type not in VALID_MEMORY_TYPES:
            raise ValueError(invalid_memory_type_error(
                first_fn.__name__, input_type, output_type, ", ".join(sorted(VALID_MEMORY_TYPES))
            ))

        # Validate that all functions have valid memory type declarations
        for fn in functions[1:]:
            # Validate that the function has explicit memory type declarations
            try:
                fn_input_type = fn.input_memory_type
                fn_output_type = fn.output_memory_type
            except AttributeError as exc:
                raise ValueError(missing_memory_type_error(fn.__name__, step_name)) from exc

            # Validate memory types against known valid types
            if fn_input_type not in VALID_MEMORY_TYPES or fn_output_type not in VALID_MEMORY_TYPES:
                raise ValueError(invalid_memory_type_error(
                    fn.__name__, fn_input_type, fn_output_type, ", ".join(sorted(VALID_MEMORY_TYPES))
                ))

        # Return first function's input type and last function's output type
        last_function = functions[-1]
        return input_type, last_function.output_memory_type

    @staticmethod
    def _validate_required_args(func: Callable, kwargs: Dict[str, Any], step_name: str) -> None:
        """
        Validate that all required positional arguments are provided in kwargs.

        All required positional arguments must be explicitly provided in the kwargs dict
        when using the (func, kwargs) pattern.

        Args:
            func: The function to validate
            kwargs: The kwargs dict to check
            step_name: The name of the step containing the function

        Raises:
            ValueError: If any required positional arguments are missing from kwargs
        """
        # Get the function signature
        sig = inspect.signature(func)

        # Collect names of required positional arguments
        required_args = []
        for name, param in sig.parameters.items():
            # Check if parameter is positional (POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD)
            if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
                # Check if parameter has no default value
                if param.default is inspect.Parameter.empty:
                    required_args.append(name)

        # Check if all required args are in kwargs
        missing_args = [arg for arg in required_args if arg not in kwargs]

        # Raise error if any required args are missing
        if missing_args:
            raise ValueError(missing_required_args_error(func.__name__, step_name, missing_args))

    @staticmethod
    def _validate_dict_pattern_keys(
        func_pattern: dict,
        group_by,
        step_name: str,
        orchestrator
    ) -> None:
        """
        Validate that dict function pattern keys match available component keys.

        This validation ensures compile-time guarantee that dict patterns will work
        at runtime by checking that all dict keys exist in the actual component data.

        Args:
            func_pattern: Dict function pattern to validate
            group_by: GroupBy enum specifying component type
            step_name: Name of the step containing the function
            orchestrator: Orchestrator for component key access

        Raises:
            ValueError: If dict pattern keys don't match available component keys
        """
        # Get available component keys from orchestrator
        try:
            available_keys = orchestrator.get_component_keys(group_by)
            available_keys_set = set(str(key) for key in available_keys)
        except Exception as e:
            raise ValueError(f"Failed to get component keys for {group_by.value}: {e}")

        # Check each dict key against available keys
        pattern_keys = list(func_pattern.keys())
        pattern_keys_set = set(str(key) for key in pattern_keys)

        # Try direct string match first
        missing_keys = pattern_keys_set - available_keys_set

        if missing_keys:
            # Try integer conversion for missing keys
            still_missing = set()
            for key in missing_keys:
                try:
                    # Try converting pattern key to int and check if int version exists
                    key_as_int = int(key)
                    if str(key_as_int) in available_keys_set:
                        continue  # Key exists as integer, not missing
                except (ValueError, TypeError):
                    still_missing.add(key)

            if still_missing:
                raise ValueError(
                    f"Function pattern keys not found in available {group_by.value} components for step '{step_name}'. "
                    f"Missing keys: {sorted(still_missing)}. "
                    f"Available keys: {sorted(available_keys)}. "
                    f"Function pattern keys must match component values from the plate data."
                )

    @staticmethod
    def validate_pattern_structure(
        func: Any,
        step_name: str
    ) -> List[Callable]:
        """
        Validate and extract all functions from a function pattern.

        This is a public wrapper for _extract_functions_from_pattern that provides
        a stable API for pattern structure validation.

        Supports nested patterns of arbitrary depth, including:
        - Direct callable
        - Tuple of (callable/FunctionReference, kwargs)
        - List of callables or patterns
        - Dict of keyed callables or patterns

        Args:
            func: The function pattern to validate and extract functions from
            step_name: The name of the step or component containing the function

        Returns:
            List of functions in the pattern

        Raises:
            ValueError: If the function pattern is invalid
        """
        return FuncStepContractValidator._extract_functions_from_pattern(func, step_name)

    @staticmethod
    def _is_function_reference(obj):
        """Check if an object is a FunctionReference."""
        try:
            from openhcs.core.pipeline.compiler import FunctionReference
            return isinstance(obj, FunctionReference)
        except ImportError:
            return False

    @staticmethod
    def _resolve_function_reference(func_or_ref):
        """Resolve a FunctionReference to an actual function, or return the original."""
        from openhcs.core.pipeline.compiler import FunctionReference
        if isinstance(func_or_ref, FunctionReference):
            return func_or_ref.resolve()
        return func_or_ref

    @staticmethod
    def _extract_functions_from_pattern(
        func: Any,
        step_name: str
    ) -> List[Callable]:
        """
        Extract all functions from a function pattern.

        Supports nested patterns of arbitrary depth, including:
        - Direct callable
        - FunctionReference objects
        - Tuple of (callable/FunctionReference, kwargs)
        - List of callables or patterns
        - Dict of keyed callables or patterns

        Args:
            func: The function pattern to extract functions from
            step_name: The name of the step containing the function

        Returns:
            List of functions in the pattern

        Raises:
            ValueError: If the function pattern is invalid
        """
        functions = []

        # Case 1: Direct FunctionReference
        from openhcs.core.pipeline.compiler import FunctionReference
        if isinstance(func, FunctionReference):
            resolved_func = func.resolve()
            functions.append(resolved_func)
            return functions

        # Case 2: Direct callable
        if callable(func) and not isinstance(func, type):
            functions.append(func)
            return functions

        # Case 3: Tuple of (callable/FunctionReference, kwargs)
        if isinstance(func, tuple) and len(func) == 2 and isinstance(func[1], dict):
            # Resolve the first element if it's a FunctionReference
            resolved_first = FuncStepContractValidator._resolve_function_reference(func[0])
            if callable(resolved_first) and not isinstance(resolved_first, type):
                # The kwargs dict is optional - if provided, it will be used during execution
                # No need to validate required args here as the execution logic handles this gracefully
                functions.append(resolved_first)
            return functions

        # Case 4: List of patterns
        if isinstance(func, list):
            from openhcs.core.pipeline.compiler import FunctionReference
            for i, f in enumerate(func):
                # Check if it's a valid pattern (including FunctionReference)
                is_valid_pattern = (
                    isinstance(f, (list, dict, tuple, FunctionReference)) or
                    (callable(f) and not isinstance(f, type))
                )
                if is_valid_pattern:
                    nested_functions = FuncStepContractValidator._extract_functions_from_pattern(f, step_name)
                    functions.extend(nested_functions)
                else:
                    raise ValueError(invalid_function_error(f"list at index {i}", f))

            return functions

        # Case 5: Dict of keyed patterns
        if isinstance(func, dict):
            from openhcs.core.pipeline.compiler import FunctionReference
            for key, f in func.items():
                # Check if it's a valid pattern (including FunctionReference)
                is_valid_pattern = (
                    isinstance(f, (list, dict, tuple, FunctionReference)) or
                    (callable(f) and not isinstance(f, type))
                )
                if is_valid_pattern:
                    nested_functions = FuncStepContractValidator._extract_functions_from_pattern(f, step_name)
                    functions.extend(nested_functions)
                else:
                    raise ValueError(invalid_function_error(f"dict with key '{key}'", f))

            return functions

        # Invalid type
        raise ValueError(invalid_pattern_error(func))
