import numpy as np
from sympy import lambdify, Matrix, Float
from numpy.polynomial.legendre import leggauss

def num_int(expr, x_sym, x_range, n_intervals=10000, points_per_interval=1):
    """
    Performs composite Gauss-Legendre numerical integration over a finite interval using internal helpers.

    Parameters:
        expr                : SymPy expression, list, or SymPy Matrix to integrate
        x_sym               : Symbolic integration variable
        x_range             : Integration interval (a, b)
        n_intervals         : Number of sub-intervals for domain decomposition (default=10000)
        points_per_interval : Number of Gauss points per sub-interval (default=1)

    Returns:
        sympy.Float or sympy.Matrix
            The numerical value of the integral.
    """

    # Generate Quadrature Nodes & Weights
    def _get_global_quadrature(a, b):
        # Reference Gauss Nodes/Weights for [-1, 1]
        xi_ref, w_ref = leggauss(points_per_interval)

        # Generate Sub-intervals
        edges = np.linspace(a, b, n_intervals + 1)

        all_nodes = []
        all_weights = []

        # Map to physical domain
        for i in range(n_intervals):
            x_start, x_end = edges[i], edges[i+1]
            half_width = 0.5 * (x_end - x_start)
            mid_point = 0.5 * (x_end + x_start)

            # Transform nodes and scale weights
            loc_nodes = mid_point + half_width * xi_ref
            loc_weights = half_width * w_ref 
            
            all_nodes.append(loc_nodes)
            all_weights.append(loc_weights)

        # Flatten arrays for vectorized evaluation
        return np.concatenate(all_nodes), np.concatenate(all_weights)

    # Helper: Integrate Single Expression
    def _integrate_element(sub_expr, nodes, weights):
        # Lambdify allows numpy array input for fast evaluation
        f = lambdify(x_sym, sub_expr, 'numpy')
        vals = f(nodes)
        
        # Handle constant expressions (lambdify returns scalar instead of array)
        if np.isscalar(vals):
            vals = np.full_like(nodes, vals)
            
        return Float(np.sum(vals * weights))

    #Main Execution
    
    # 1. Setup Domain
    start, end = float(x_range[0]), float(x_range[1])
    xi_global, wi_global = _get_global_quadrature(start, end)

    # 2. Normalize Input (List -> Matrix)
    if isinstance(expr, list):
        target_expr = Matrix(expr)
    else:
        target_expr = expr

    # 3. Process & Return
    if isinstance(target_expr, Matrix):
        rows, cols = target_expr.shape
        result = Matrix.zeros(rows, cols)
        
        for i in range(rows):
            for j in range(cols):
                result[i, j] = _integrate_element(target_expr[i, j], xi_global, wi_global)
        return result

    else:
        # Scalar Case
        return _integrate_element(target_expr, xi_global, wi_global)