"""
design_space_constraints.py
---------------------------

Provides classes and utility functions to define and manage feature-based constraints
for design space exploration. These constraints can be used to filter out candidate
individuals (e.g., structures or parameter sets) that do not meet certain user-defined
criteria. Typical use-cases include feature-based restrictions, ratio checks,
upper/lower bounds, and custom logic to ensure the population remains within feasible
design regions.

Overview
--------
- ConstraintGenerator: A collection of static methods returning *callable* objects
  that verify whether a given individual's features satisfy a specified condition
  (e.g., "feature[i] > 0", "ratio of feature[i]/feature[j] < 2", etc.).

- FeatureConstraint: Encapsulates a single constraint (i.e., check_func) with an
  optional name and description. This allows for easier introspection, logging,
  and debugging.

- DesignSpaceConstraintManager: A manager class to collect multiple constraints,
  apply logical modes ("all" or "any"), and check if a candidate's features meet
  the overall constraints.

Usage Example
-------------
1) Define your constraints:
    from design_space_constraints import ConstraintGenerator, FeatureConstraint

    # Create a constraint that ensures feature[0] >= 5.0
    c1 = FeatureConstraint(
        check_func=ConstraintGenerator.greater_or_equal(feature_idx=0, threshold=5.0),
        name="C1_feature0_GE_5",
        description="Ensures feature[0] is >= 5.0"
    )

    # Create a ratio constraint that ensures feature[1]/feature[2] is between 0.3 and 0.7
    c2 = FeatureConstraint(
        check_func=ConstraintGenerator.ratio_in_range(
            numerator_idx=1, denominator_idx=2, min_ratio=0.3, max_ratio=0.7
        ),
        name="C2_ratio_in_range",
        description="Ensures ratio(feature[1]/feature[2]) is between 0.3 and 0.7"
    )

2) Create a manager and add constraints:
    from design_space_constraints import DesignSpaceConstraintManager

    manager = DesignSpaceConstraintManager(logic="all")  # all constraints must be satisfied
    manager.add_constraint(c1)
    manager.add_constraint(c2)

3) In your evolutionary loop, filter individuals:
    for individual in population:
        feat_vector = compute_features(individual)  # shape (D, )
        if manager.validate(feat_vector):
            # keep or process this individual
        else:
            # discard this individual

This approach cleanly separates the definition of constraints from the logic that
applies them to candidate solutions, enabling a flexible mechanism for restricting
the design space.
"""

import numpy as np
import itertools
import random
import warnings
from typing import Callable, List, Tuple, Optional

# =============================================================================
# Constraint Generator Functions
# =============================================================================
class ConstraintGenerator:
    """
    A collection of static methods to generate callable functions that verify
    whether specific feature-based conditions are satisfied.

    All methods return a function: f(features: np.ndarray) -> bool.
    """

    @staticmethod
    def greater_than(feature_idx: int, threshold: float):
        r"""
        Return a function that checks if \(x_{feature\_idx} > threshold\).

        \[f(\mathbf{x}) = \mathbf{1}\{x_{feature\_idx} > \text{threshold}\}\]

        Parameters
        ----------
        feature_idx : int
            Index of the feature to inspect.
        threshold : float
            Threshold value for comparison.

        Returns
        -------
        Callable[[np.ndarray], bool]
        """
        def _check(features: np.ndarray) -> bool:
            return features[feature_idx] > threshold
        return _check

    @staticmethod
    def greater_or_equal(feature_idx: int, threshold: float):
        """
        Returns a callable that checks if features[feature_idx] >= threshold.

        Parameters
        ----------
        feature_idx : int
            Index of the feature to inspect.
        threshold : float
            Threshold value for comparison.

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            return features[feature_idx] >= threshold
        return _check

    @staticmethod
    def less_than(feature_idx: int, threshold: float):
        """
        Returns a callable that checks if features[feature_idx] < threshold.

        Parameters
        ----------
        feature_idx : int
            Index of the feature to inspect.
        threshold : float
            Threshold value for comparison.

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            return features[feature_idx] < threshold
        return _check

    @staticmethod
    def less_or_equal(feature_idx: int, threshold: float):
        """
        Returns a callable that checks if features[feature_idx] <= threshold.

        Parameters
        ----------
        feature_idx : int
            Index of the feature to inspect.
        threshold : float
            Threshold value for comparison.

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            return features[feature_idx] <= threshold
        return _check

    @staticmethod
    def within_range(feature_idx: int, min_val: float, max_val: float):
        r"""
        Return a function that checks if \(min\_val \le x_{feature\_idx} \le max\_val\).

        \[f(\mathbf{x}) = \mathbf{1}\{min\_val \le x_{feature\_idx} \le max\_val\}\]

        Parameters
        ----------
        feature_idx : int
            Index of the feature.
        min_val : float
            Lower bound (inclusive).
        max_val : float
            Upper bound (inclusive).

        Returns
        -------
        Callable[[np.ndarray], bool]
        """
        def _check(features):
            val = features[feature_idx]
            return (val >= min_val) and (val <= max_val)
        return _check

    @staticmethod
    def ratio_in_range(numerator_idx: int, denominator_idx: int,
                       min_ratio: float, max_ratio: float, epsilon=1e-12):
        """
        Returns a callable that checks if:
            min_ratio <= (features[numerator_idx] / features[denominator_idx]) <= max_ratio

        A small epsilon is added to the denominator to prevent division-by-zero issues.

        Parameters
        ----------
        numerator_idx : int
            Index of the feature used as numerator.
        denominator_idx : int
            Index of the feature used as denominator.
        min_ratio : float
            Lower bound for the ratio (inclusive).
        max_ratio : float
            Upper bound for the ratio (inclusive).
        epsilon : float, optional
            Small constant to avoid zero division, by default 1e-12.

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            denominator_val = features[denominator_idx] + epsilon
            ratio = features[numerator_idx] / denominator_val
            return (ratio >= min_ratio) and (ratio <= max_ratio)
        return _check

    @staticmethod
    def sum_in_range(feature_indices: list, min_val: float, max_val: float):
        """
        Returns a callable that checks if the sum of features at the specified
        indices is within the inclusive range [min_val, max_val].

        Parameters
        ----------
        feature_indices : list of int
            Indices of the features whose sum is to be checked.
        min_val : float
            Lower bound (inclusive).
        max_val : float
            Upper bound (inclusive).

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            total = sum(features[idx] for idx in feature_indices)
            return (total >= min_val) and (total <= max_val)
        return _check

    @staticmethod
    def custom_condition(check_function):
        """
        Wraps a user-supplied function in a standard 'f(features) -> bool' format.

        Parameters
        ----------
        check_function : callable
            A user-defined function: check_function(features) -> bool

        Returns
        -------
        callable
            f(features) -> bool
        """
        def _check(features):
            return bool(check_function(features))
        return _check


# =============================================================================
# FeatureConstraint Class
# =============================================================================
class FeatureConstraint:
    """
    Encapsulates a single feature-based constraint with metadata.

    Parameters
    ----------
    check_func : Callable[[np.ndarray], bool]
        Function to evaluate the constraint.
    name : str, optional
        Identifier for the constraint.
    description : str, optional
        Detailed explanation.
    """
    def __init__(self, check_func, name=None, description=None):
        """
        Initializes a FeatureConstraint object.

        Parameters
        ----------
        check_func : callable
            The function to check constraint satisfaction.
        name : str, optional
            A name for the constraint, e.g. "Constraint#1".
        description : str, optional
            Additional details or rationale for the constraint.
        """
        if not callable(check_func):
            raise ValueError("check_func must be a callable function.")
        self.check_func = check_func
        self.name = name if name else "UnnamedConstraint"
        self.description = description if description else ""

    def is_valid(self, features: np.ndarray) -> bool:
        r"""
        Validate features against this constraint.

        Parameters
        ----------
        features : np.ndarray
            Feature vector \(\mathbf{x}\).

        Returns
        -------
        bool
            True if \(f(\mathbf{x})=1\); False otherwise.
        """
        return self.check_func(features)

    def __call__(self, features):
        """
        Evaluates the constraint on the given feature vector.

        Parameters
        ----------
        features : np.ndarray
            A 1D array of features to evaluate.

        Returns
        -------
        bool
            True if constraint is satisfied, False otherwise.
        """
        return self.check_func(features)

    def __repr__(self):
        return f"<FeatureConstraint(name={self.name}, desc={self.description[:30]}...)>"

# =============================================================================
# DesignOfExperiments
# =============================================================================
class DesignOfExperiments:
    """
    Manage multiple FeatureConstraints under logical modes.

    Attributes
    ----------
    constraints : List[FeatureConstraint]
    logic : str
        Either 'all' (conjunction) or 'any' (disjunction).
    """

    def __init__(self, design=None, logic: str = "all", constraints: List[FeatureConstraint]=[] ):
        """
        Initialize with logic mode.

        Parameters
        ----------
        logic : {'all','any'}
        """
        valid_modes = ["all", "any"]
        if logic not in valid_modes:
            raise ValueError(
                f"Invalid logic='{logic}'. Must be one of {valid_modes}."
            )
        self.constraints: List[FeatureConstraint] = constraints
        self.logic = logic
        self.design = design

    def add(self, constraint: FeatureConstraint):
        """
        Add a FeatureConstraint.

        Parameters
        ----------
        constraint : FeatureConstraint
        """
        self.add_constraint(constraint)

    def add_constraint(self, constraint: FeatureConstraint):
        """
        Adds a FeatureConstraint to the DOE manager.
        """
        if not isinstance(constraint, FeatureConstraint):
            raise TypeError("Only FeatureConstraint objects can be added.")
        self.constraints.append(constraint)

    def validate(self, features: np.ndarray) -> bool:
        """
        Check if features satisfy combined constraints.

        Returns
        -------
        bool
            - 'all': True if all constraints hold.  
            - 'any': True if at least one holds.
        """
        if self.logic == "all":
            return all(constraint(features) for constraint in self.constraints)
        elif self.logic == "any":
            return any(constraint(features) for constraint in self.constraints)
        return False

    def generate(self, ):
        r"""
        Generate design points via design_func and filter by constraints.

        Parameters
        ----------
        design_func : Callable[[Callable], np.ndarray]
            A function that accepts a validator and returns candidate points.

        Returns
        -------
        np.ndarray
            Valid design points \(D\).  
            After generation, \(D = \{\mathbf{x}: validate(\mathbf{x})=True\}\).
        """
        design_points = self.design( self.validate )
        self.design_points = design_points

        return self.design_points

    def __repr__(self):
        return (f"<DesignOfExperiments(logic={self.logic}, "
                f"num_constraints={len(self.constraints)})>")

# -------------------------------------------------------------------------
# 1. Full Factorial Design
# -------------------------------------------------------------------------
def design_full_factorial(levels_per_factor: List[List[float]], discrete_design: bool=True) -> np.ndarray:
    r"""
    Full factorial design: Cartesian product of levels.

    Let factor i have levels \(L_i\).  The design is
    .. math::
       D = L_1 \times L_2 \times \cdots \times L_n,
       \quad |D| = \prod_{i=1}^n |L_i|.

    Parameters
    ----------
    levels_per_factor : List[List[float]]
        Levels for each factor.
    discrete_design : bool

    Returns
    -------
    Callable
    """
    from itertools import product
    def func(validate):
        # Cartesian product to get all combinations
        raw_design = list(product(*levels_per_factor))

        # Convert to numpy array
        all_points = np.array(raw_design, dtype=int if discrete_design else float)
        # Filter out points that do not meet constraints
        valid_points = []
        for point in all_points:
            if validate(point):
                valid_points.append(point)

        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 2. Fractional Factorial Design (Basic 2-level example)
# -------------------------------------------------------------------------
def generate_fractional_factorial(n_factors: int, 
                                  fraction: float = 0.5, 
                                  discrete_design: bool=True) -> np.ndarray:
    """
    Generates a simple 2-level fractional factorial design for n_factors.
    By default, it takes only a fraction (e.g., 1/2) of the full factorial 
    to reduce the number of runs. (This is a simplistic approach; real 
    fractional factorial designs often use aliasing structures or 
    orthogonal arrays.)

    The 2-level design is assumed to be [-1, +1] for each factor.

    Parameters
    ----------
    n_factors : int
        Number of factors (dimensions).
    fraction : float, optional
        Fraction of the full factorial to keep. Must be between 0 and 1.
        Default is 0.5 (i.e., half-fraction).

    Returns
    -------
    np.ndarray
        Valid points in the fractional design that pass the constraints.
    """
    def func(validate):
        if not (0 < fraction <= 1):
            raise ValueError("fraction must be between 0 and 1.")

        from itertools import product
        
        # Full factorial 2^n: [-1, +1]
        levels = [-1.0, 1.0]
        full_design = list(product(levels, repeat=n_factors))
        full_design = np.array(full_design, dtype=int if discrete_design else float)

        # Shuffle and pick only a subset (the fraction)
        np.random.shuffle(full_design)
        n_subset = int(len(full_design) * fraction)
        subset_design = full_design[:n_subset]

        # Filter out points that do not meet constraints
        valid_points = [pt for pt in subset_design if validate(pt)]
        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 3. Central Composite Design (CCD)
# -------------------------------------------------------------------------
def generate_central_composite(n_factors: int,
                               alpha: float = None,
                               center_points: int = 1, 
                               discrete_design: bool=True) -> np.ndarray:
    """
    Generates a Central Composite Design (CCD) for n_factors using:
      - A 2-level factorial portion
      - Axial (star) points at ±alpha
      - Center points repeated 'center_points' times
      
    Typically, alpha is chosen such that the design is rotatable. 
    For a full-factorial of 2^n, a common choice is 
        alpha = (2^(n_factors/4)) 
    but it can vary depending on the design objective.

    Parameters
    ----------
    n_factors : int
        Number of design factors.
    alpha : float, optional
        The distance of the axial points from the center. If None, 
        uses 2^(n_factors/4) for approximate rotatability.
    center_points : int, optional
        How many times to replicate the center point. Default is 1.

    Returns
    -------
    np.ndarray
        Valid points from the CCD that satisfy constraints.
    """
    def func(validate):
        if alpha is None:
            alpha = 2 ** (n_factors / 4.0)  # approximate rotatable design

        from itertools import product
        # 1) Factorial portion (±1 for each factor)
        factorial_pts = list(product([-1.0, 1.0], repeat=n_factors))
        
        # 2) Axial points: one factor at ±alpha, rest are 0
        axial_pts = []
        for i in range(n_factors):
            for sign in [-1, 1]:
                pt = [0.0] * n_factors
                pt[i] = sign * alpha
                axial_pts.append(pt)

        # 3) Center points: all zeros, repeated
        center_pts = [[0.0] * n_factors for _ in range(center_points)]

        # Combine into a single array
        design = np.array(factorial_pts + axial_pts + center_pts, dtype=int if discrete_design else float)

        # Filter by constraints
        valid_points = [pt for pt in design if validate(pt)]

        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 4. Box-Behnken Design
# -------------------------------------------------------------------------
def generate_box_behnken(n_factors: int,
                        discrete_design: bool=True) -> np.ndarray:
    """
    Generates a Box-Behnken design (BBD) for n_factors. 
    This design is formed by combinations of middle levels 
    in the faces of an n-dimensional cube, plus the center point.
    
    In general, a Box-Behnken design does not include corners (±1, ±1, etc.),
    so it is useful when extreme points are infeasible. This simplified 
    version uses three levels: -1, 0, +1.
    
    For n_factors >= 3, the number of runs is 2n(n - 1) + center. 
    In practice, one might replicate the center point, but for 
    simplicity, we'll include a single center point here.

    Parameters
    ----------
    n_factors : int
        Number of factors.

    Returns
    -------
    np.ndarray
        Valid design points that satisfy constraints.
    """
    def func(validate):
        if n_factors < 2:
            raise ValueError("Box-Behnken typically requires n_factors >= 2.")

        # For a basic demonstration, we will:
        # 1) Generate all permutations of [-1, 0, +1] for each pair of factors.
        # 2) Ensure exactly two factors are at ±1 and all others are at 0, for each "block".
        # 3) Add the center point (0,0,...,0) once.

        # This is a simplified approach. 
        # A general Box-Behnken is typically built systematically for each pair of factors.

        design = []
        levels = [-1.0, 0.0, +1.0]

        # For each pair of factors (i, j), vary those two in [-1, +1, 0], 
        # while the rest are 0
        for i in range(n_factors):
            for j in range(i+1, n_factors):
                # Each combination for (i, j) with others = 0
                # We only take combinations where i and j are not both 0 
                # at the same time, to avoid duplicating center 
                # or missing edge combos. For Box-Behnken, 
                # typically factor pairs cycle through combos.
                for level_i in [-1.0, +1.0]:
                    for level_j in [-1.0, +1.0]:
                        pt = [0.0]*n_factors
                        pt[i] = level_i
                        pt[j] = level_j
                        design.append(pt)

        # Add center point
        center_pt = [0.0]*n_factors
        design.append(center_pt)

        design = np.array(design, dtype=int if discrete_design else float)

        # Filter by constraints
        valid_points = [pt for pt in design if validate(pt)]
        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 5. Plackett-Burman Design (Simplified Approach)
# -------------------------------------------------------------------------
def generate_plackett_burman(n_factors: int,
                            discrete_design: bool=True) -> np.ndarray:
    """
    Generates a simplified Plackett-Burman design for main effects screening.
    Typically, these designs come in multiples of 4 (e.g., 4, 8, 12, 16 factors).
    A full general solution requires a specific set of orthogonal arrays. 
    Here, we implement a minimal approach for demonstration only.

    Parameters
    ----------
    n_factors : int
        Number of factors. Usually a multiple of 4 for a proper 
        Plackett-Burman array.

    Returns
    -------
    np.ndarray
        Valid points from the design that pass constraints.
    """
    # For demonstration, let's do a basic approach if n_factors == 4 or 8.
    # In real usage, you'd either implement or import a specialized 
    # function for general PB designs.

    # Minimal set for n_factors=4:
    #  1   1   1  -1
    #  1  -1   1   1
    #  1   1  -1   1
    # -1   1   1   1
    # This is 4 runs for 4 factors.

    # Minimal set for n_factors=8 (example):
    # A standard 12-run array is often used. For simplicity, we do 8 runs for 8 factors.
    
    # If not recognized n_factors, raise an error or degrade to a full factorial approach.
    def func(validate):
        if n_factors == 4:
            design = np.array([
                [ 1,  1,  1, -1],
                [ 1, -1,  1,  1],
                [ 1,  1, -1,  1],
                [-1,  1,  1,  1]
            ], dtype=int if discrete_design else float)
        elif n_factors == 8:
            design = np.array([
                [ 1,  1,  1,  1, -1, -1, -1,  1],
                [ 1,  1, -1, -1,  1,  1, -1,  1],
                [ 1, -1,  1, -1,  1, -1,  1,  1],
                [ 1, -1, -1,  1, -1,  1,  1,  1],
                [-1,  1,  1, -1,  1, -1,  1,  1],
                [-1,  1, -1,  1, -1,  1,  1,  1],
                [-1, -1,  1,  1,  1,  1, -1,  1],
                [-1, -1, -1, -1, -1, -1, -1,  1]
            ], dtype=int if discrete_design else float)
        else:
            raise NotImplementedError(
                f"No example Plackett-Burman design implemented for {n_factors} factors."
            )

        # Filter by constraints
        valid_points = [pt for pt in design if validate(pt)]
        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 6. Taguchi Orthogonal Design (Illustrative)
# -------------------------------------------------------------------------
def generate_taguchi_orthogonal(array_name: str = "L4",
                                discrete_design: bool=True) -> np.ndarray:
    """
    Generates a simplified Taguchi orthogonal array design. 
    Common arrays are L4, L8, L9, L12, L16, L18, L32, etc. 
    This function provides only a minimal demonstration.

    Parameters
    ----------
    array_name : str, optional
        Name of a known Taguchi array. Defaults to "L4".
    
    Returns
    -------
    np.ndarray
        Valid points that pass constraints.
    """
    # Example: L4(2^3) Orthogonal Array
    # Typically used for up to 3 factors at 2 levels each.
    # Runs: 4. Factors: up to 3. 
    # Array structure (factors in columns):
    #   run | f1 | f2 | f3
    #   1   |  1 |  1 |  1
    #   2   |  1 | -1 | -1
    #   3   | -1 |  1 | -1
    #   4   | -1 | -1 |  1
    def func(validate):
        if array_name.upper() == "L4":
            design = np.array([
                [ 1,  1,  1],
                [ 1, -1, -1],
                [-1,  1, -1],
                [-1, -1,  1]
            ], dtype=int if discrete_design else float)
        else:
            raise NotImplementedError(
                f"Taguchi array '{array_name}' not implemented in this example."
            )

        # Filter by constraints
        valid_points = [pt for pt in design if validate(pt)]
        return np.array(valid_points)

    return func

# -------------------------------------------------------------------------
# 7. Mixture Design (Simplex-Lattice)
# -------------------------------------------------------------------------
def generate_mixture_design(n_components: int, 
                            grid_resolution: int = 3,
                            discrete_design: bool=True) -> np.ndarray:
    """
    Generates a simple simplex-lattice mixture design for n_components,
    where each factor corresponds to the proportion of a component.
    The sum of all n_components must be 1.0 for each point.
    
    This design discretizes the simplex with a given grid resolution.
    For instance, if grid_resolution=2 and n_components=3, 
    you get points at increments of 1/2 in the simplex (triangle).

    Parameters
    ----------
    n_components : int
        Number of mixture components (factors).
    grid_resolution : int
        Number of increments per component in [0,1] 
        subject to sum(component_i)=1.

    Returns
    -------
    np.ndarray
        Valid mixture points (each row sums to 1) that satisfy constraints.
    """
    # We can systematically generate all integer combinations 
    # of length n_components that sum to grid_resolution, 
    # then normalize by grid_resolution to get fractions that sum to 1.
    def func(validate):
        from itertools import product

        valid_points = []

        for combo in product(range(grid_resolution+1), repeat=n_components):
            if sum(combo) == grid_resolution:
                pt = np.array(combo, dtype=float) / float(grid_resolution)
                pt = np.array(pt, dtype= int if discrete_design else float)
                # Check constraints
                if validate(pt):
                    valid_points.append(pt)

        return np.array(valid_points)

# -------------------------------------------------------------------------
# 8. Latin Hypercube Sampling (LHS)
# -------------------------------------------------------------------------
def generate_latin_hypercube(n_factors: int, 
                             n_samples: int,
                             low: float = 0.0, 
                             high: float = 1.0,
                             max_iterations: int = 10000,
                             discrete_design: bool=True) -> np.ndarray:
    """
    Generates a Latin Hypercube Sample (LHS) in n_factors dimensions,
    returning n_samples points, each factor within [low, high].
    
    LHS attempts to sample the space so that each factor is 
    stratified into n_samples intervals, guaranteeing coverage 
    along each dimension.

    Parameters
    ----------
    n_factors : int
        Number of factors (dimensions).
    n_samples : int
        Number of LHS points to generate.
    low : float, optional
        Lower bound for each factor. Defaults to 0.0
    high : float, optional
        Upper bound for each factor. Defaults to 1.0
    max_iterations : int, optional
        Maximum trials to get valid points after constraint filtering.

    Returns
    -------
    np.ndarray
        Valid LHS points that meet constraints.
    """
    # Start with a basic LHS procedure
    # 1) Divide each dimension into n_samples intervals.
    # 2) Shuffle the order in each dimension.
    # 3) Pick a random point in each interval along that dimension.
    def func(validate):

        def lhs(dim, samples):
            # Basic LHS logic: each dimension is divided into 'samples' intervals
            # We'll shuffle them to ensure random positioning
            result = np.zeros((samples, dim))
            temp = np.zeros((samples))
            for i in range(dim):
                # Create random perm of [0..samples-1]
                perm = np.random.permutation(samples)
                # For each sample, choose a random location within the interval
                for j in range(samples):
                    temp[j] = (perm[j] + random.random()) / samples
                result[:, i] = temp
            return result

        attempts = 0
        valid_points = []
        while len(valid_points) < n_samples and attempts < max_iterations:
            # Generate a new LHS set
            lhs_samples = lhs(n_factors, n_samples)
            # Scale from [0,1] to [low, high]
            scaled_samples = low + lhs_samples * (high - low)
            scaled_samples = np.array(scaled_samples, dtype= int if discrete_design else float)
            # Filter points with constraints
            candidate_points = [pt for pt in scaled_samples if validate(pt)]
            valid_points.extend(candidate_points)
            attempts += 1

        if len(valid_points) < n_samples:
            print(f"Warning: Only generated {len(valid_points)} valid points "
                  f"out of requested {n_samples} after {attempts} attempts.")

        return np.array(valid_points[:n_samples])
    
    return func

# -------------------------------------------------------------------------
# 9. Space Filling Design (Greedy Maximin Approach)
# -------------------------------------------------------------------------
def generate_space_filling(n_factors: int,
                           n_points: int,
                           bounds: Optional[List[Tuple[float, float]]] = None,
                           candidate_multiplier: int = 50,
                           discrete_design: bool=True,
                           avoid_repetitions: bool=True,
                             ) -> Callable[[Callable[[np.ndarray], bool]], np.ndarray]:
    r"""
    Generate a space-filling design using a greedy maximin algorithm.

    The algorithm proceeds in three phases:

    1. **Candidate Pool Sampling**  
       Sample an initial pool of
       :math:`N = C \times n\_points`
       points uniformly from the hyper-rectangle
       :math:`\prod_{i=1}^d [b_i^{\min}, b_i^{\max}]`,
       where :math:`d = n\_factors` and
       :math:`C = \texttt{candidate_multiplier}`.  
       If `bounds` is `None`, defaults to :math:`[0,1]^d`.

    2. **Feasibility Filtering**  
       Retain only those points satisfying the user-supplied validator
       :math:`v(\mathbf{x})`, i.e.
       .. math::
          X = \{\mathbf{x}\in\mathbb{R}^d : v(\mathbf{x}) = 1\}.

    3. **Greedy Maximin Selection**  
       From :math:`X`, iteratively build the final set
       :math:`S = \{s_1, \dots, s_{n\_points}\}` by:
       .. math::
          s_1 \;\text{is arbitrary},\quad
          s_{k+1} = \arg\max_{x \in X\setminus S_k}
                     \min_{s\in S_k} \|x - s\|_2,
       until :math:`|S| = n\_points`.  This maximizes the minimum pairwise
       distance among selected points.

    Parameters
    ----------
    n_factors : int
        Dimensionality :math:`d` of the design space.
    n_points : int
        Number of final points to select.
    bounds : list of (float, float), optional
        Lower and upper bounds for each dimension; length must equal `n_factors`.
        Defaults to `[(0.0, 1.0)] * d`.
    candidate_multiplier : int
        Pool size factor :math:`C` (so pool size = `C * n_points`).
    discrete_design : bool
        If True, sample integer points in each interval; otherwise sample continuous.
    avoid_repetitions : bool
        If True, enforce uniqueness when building the candidate pool.

    Returns
    -------
    Callable[[Callable[[np.ndarray], bool]], np.ndarray]
        A function `f(v)` that, given a boolean validator `v(x)`, returns
        the selected design points as an array of shape `(n_points, d)`.

    Raises
    ------
    ValueError
        If `bounds` is provided and its length ≠ `n_factors`.
    """
    if bounds is None:
        bounds = [(0.0, 1.0)] * n_factors
    elif len(bounds) != n_factors:
        raise ValueError("Length of 'bounds' must equal n_factors.")
        
    def func(validate):
        candidate_count = candidate_multiplier * n_points
        candidates = []
        max_attempts = candidate_count * 10
        attempts = 0

        # Generate candidate pool of valid points
        unique_candidates = set()
        while len(candidates) < candidate_count and attempts < max_attempts:
            if discrete_design:
                candidate = np.array([random.randint(low, high) for (low, high) in bounds], dtype=int)
            else:
                candidate = np.array([random.uniform(low, high) for (low, high) in bounds], dtype=float)

            if validate(candidate):
                if avoid_repetitions:
                    candidate_tuple = tuple(candidate)  
                    if candidate_tuple not in unique_candidates:
                        unique_candidates.add(candidate_tuple)
                        candidates.append(candidate)
                else:
                    candidates.append(candidate)

            attempts += 1

        if len(candidates) < n_points:
            warnings.warn("Not enough valid candidate points were generated.")
            #raise RuntimeError("Not enough valid candidate points were generated.")

        candidates = np.array(candidates)

        # Greedy maximin selection: iteratively select the candidate 
        # that maximizes the minimum distance to the already selected points.
        selected = [candidates[0]]
        remaining = list(candidates[1:])
        
        while len(selected) < n_points and len(remaining)>0 :
            best_candidate = None
            best_distance = -1
            for candidate in remaining:
                distances = [np.linalg.norm(candidate - s) for s in selected]
                min_distance = min(distances)
                if min_distance > best_distance:
                    best_distance = min_distance
                    best_candidate = candidate

            if best_distance > 0:
                selected.append(best_candidate)
            remaining = [candidate for candidate in remaining if not np.array_equal(candidate, best_candidate)]

        return np.array(selected)

    return func

# =============================================================================
# Example Usage
# =============================================================================
if __name__ == "__main__":
    # Define constraints
    constraint1 = FeatureConstraint(
        check_func=ConstraintGenerator.greater_or_equal(0, 5.0),
        name="Feature0 >= 5",
        description="The first variable must be at least 5."
    )
    constraint2 = FeatureConstraint(
        check_func=ConstraintGenerator.sum_in_range( [0,1,2], 128, 128),
        name="Feature1 < 10",
        description="The second variable must be less than 10."
    )

    # Create a ConstraintManager with 'all' logic
    DoE = DesignOfExperiments(design=generate_space_filling(
                n_factors=5, 
                n_points=100, 
                bounds=[    [80,120], 
                            [0,20], 
                            [0,20], 
                            [40,80], 
                            [90,100], ] ), 
                logic="all", )
    
    DoE.add(constraint1)
    DoE.add(constraint2)

    # Instantiate DesignSpace without fixed bounds; feasibility is determined solely by the constraints.
    s = DoE.generate()

    # Generate 20 valid design points
    try:
        valid_design_points = DoE.generate()
        print("Valid design points:")
        for point in valid_design_points:
            print(point)
    except RuntimeError as e:
        print(str(e))
