"""Intersection operations for ellipsoids and hyperplanes.

Phase 2C-i  : hpintersection (exact ellipsoid–hyperplane slice)
Phase 2C-ii : intersection_ea, intersection_ia (fusion-parameter methods)
Phase 2C-iii: ellintersection_ia, ellunion_ea (SDP via CVXPY)

Reference
---------
MATLAB Ellipsoidal Toolbox v3,
``@ellipsoid/hpintersection.m``, ``intersection_ea.m``, ``intersection_ia.m``,
``ellintersection_ia.m``, ``ellunion_ea.m``.
"""

from __future__ import annotations

from typing import Sequence

import numpy as np
import scipy.linalg
import scipy.optimize

from ..core.config import ABS_TOL
from ..core.ellipsoid import Ellipsoid
from ..core.hyperplane import Hyperplane
from ..core.helpers import ell_inv, ell_valign, regularize

__all__ = [
    "hpintersection",
    "intersection_ea",
    "intersection_ia",
    "ellintersection_ia",
    "ellunion_ea",
    "intersect",
    "isinside",
]


# =====================================================================
# Phase 2C-i: Exact ellipsoid–hyperplane intersection
# =====================================================================

def hpintersection(E: Ellipsoid, H: Hyperplane) -> Ellipsoid:
    """Compute the intersection of an ellipsoid with a hyperplane.

    The result is an (n-1)-dimensional ellipsoid embedded in R^n
    that lies on the hyperplane H and inside E.

    Returns an empty ellipsoid if E and H do not intersect.

    Parameters
    ----------
    E : Ellipsoid
    H : Hyperplane

    Returns
    -------
    Ellipsoid
        The intersection ellipsoid (possibly degenerate/empty).
    """
    if E.is_empty() or H.is_empty():
        raise ValueError("hpintersection: both arguments must be non-empty.")

    q, Q = E.parameters()
    assert q is not None and Q is not None
    n = q.shape[0]

    v, c = H.parameters()
    assert v is not None
    if v.shape[0] != n:
        raise ValueError(
            f"hpintersection: hyperplane dimension ({v.shape[0]}) "
            f"does not match ellipsoid dimension ({n})."
        )

    # Check if intersection exists via distance
    from .distance import distance_to_hyperplane
    if distance_to_hyperplane(E, H) > ABS_TOL:
        return Ellipsoid()

    # Ensure c >= 0
    if c < 0:
        v = -v
        c = -c

    # Rotate so that hyperplane normal aligns with e1 = [1, 0, ..., 0]
    e1 = np.zeros(n)
    e1[0] = 1.0
    T = ell_valign(v, e1)

    # Translate so the hyperplane passes through the origin in rotated coords
    f = (c * T @ v) / float(v @ v)

    # Apply transformation to ellipsoid: T*E - f
    E_rot = T @ E - f
    q_r = E_rot.center
    Q_r = E_rot.shape
    assert q_r is not None and Q_r is not None

    if np.linalg.matrix_rank(Q_r, tol=ABS_TOL) < n:
        Q_r = regularize(Q_r)

    W = ell_inv(Q_r)
    W = 0.5 * (W + W.T)

    # Extract blocks of W for slicing at x_1 = 0
    w = W[1:, 0].copy()         # (n-1,)
    w11 = W[0, 0]               # scalar
    W22 = W[1:, 1:]             # (n-1, n-1)
    W22_inv = ell_inv(W22)
    W22_inv = 0.5 * (W22_inv + W22_inv.T)

    q1 = q_r[0]
    h = q1 ** 2 * (w11 - float(w @ W22_inv @ w))

    # Center and shape of the sliced ellipsoid (in rotated frame)
    z = q_r.copy()
    z[0] = 0.0
    z[1:] = q_r[1:] + q1 * W22_inv @ w

    Z = np.zeros((n, n))
    Z[1:, 1:] = (1.0 - h) * W22_inv
    Z = 0.5 * (Z + Z.T)

    I_slice = Ellipsoid(z, Z)

    # Rotate back: T^{-1} * (I_slice + f)
    T_inv = np.linalg.inv(T)
    result = T_inv @ (I_slice + f)
    return result


# =====================================================================
# Phase 2C-ii: Fusion-parameter intersection (ellipsoid–ellipsoid EA)
# =====================================================================

def _ell_fusionlambda(
    a: float,
    q1: np.ndarray,
    Q1: np.ndarray,
    q2: np.ndarray,
    Q2: np.ndarray,
    n: int,
) -> float:
    """Objective whose root in (0,1) gives the minimal-volume fusion ellipsoid.

    Ported from MATLAB ``auxiliary/ell_fusionlambda.m``.
    """
    X = a * Q1 + (1.0 - a) * Q2
    Y = np.linalg.inv(X)
    Y = 0.5 * (Y + Y.T)
    k = 1.0 - a * (1.0 - a) * float((q2 - q1) @ Q2 @ Y @ Q1 @ (q2 - q1))
    q = Y @ (a * Q1 @ q1 + (1.0 - a) * Q2 @ q2)

    det_X = np.linalg.det(X)
    trace_term = float(np.trace(det_X * Y @ (Q1 - Q2)))

    qterm = float(
        2.0 * q @ Q1 @ q1
        - 2.0 * q @ Q2 @ q2
        + q @ (Q2 - Q1) @ q
        - q1 @ Q1 @ q1
        + q2 @ Q2 @ q2
    )

    f = k * det_X * trace_term - n * (det_X ** 2) * qterm
    return f


def intersection_ea(
    E1: Ellipsoid,
    E2: Ellipsoid | Hyperplane,
) -> Ellipsoid:
    """External ellipsoidal approximation of the intersection E1 ∩ E2.

    For two ellipsoids, finds the minimal-volume ellipsoid containing
    their intersection by optimizing a fusion parameter λ ∈ [0,1].

    For an ellipsoid and a halfspace (defined by a hyperplane), converts
    the halfspace to an artificial ellipsoid and applies the same fusion.

    Returns an empty ellipsoid if the intersection is empty.

    Parameters
    ----------
    E1 : Ellipsoid
    E2 : Ellipsoid or Hyperplane

    Returns
    -------
    Ellipsoid
    """
    if E1.is_empty():
        raise ValueError("intersection_ea: E1 must be non-empty.")
    if isinstance(E2, Hyperplane) and E2.is_empty():
        raise ValueError("intersection_ea: E2 must be non-empty.")
    if isinstance(E2, Ellipsoid) and E2.is_empty():
        raise ValueError("intersection_ea: E2 must be non-empty.")

    q1, Q1 = E1.parameters()
    assert q1 is not None and Q1 is not None
    n = q1.shape[0]

    # Prepare Q1^{-1}
    sd1, rk1 = E1.dimension()
    Q1_work = Q1.copy()
    if rk1 < sd1:
        Q1_work = regularize(Q1_work)
    Q1_inv = ell_inv(Q1_work)
    Q1_inv = 0.5 * (Q1_inv + Q1_inv.T)

    if isinstance(E2, Hyperplane):
        return _intersection_ea_halfspace(E1, E2, q1, Q1_inv, n)
    else:
        return _intersection_ea_ellipsoid(E1, E2, q1, Q1_inv, n)


def _intersection_ea_ellipsoid(
    E1: Ellipsoid,
    E2: Ellipsoid,
    q1: np.ndarray,
    Q1_inv: np.ndarray,
    n: int,
) -> Ellipsoid:
    """EA intersection of two ellipsoids via fusion-parameter optimization."""
    if E1 == E2:
        return E1

    from .distance import distance_to_point
    # Quick non-intersection check: if E2's center is outside E1 and vice versa
    # Use the support function for a more robust check (distance-based would need CVXPY)
    # For now, proceed optimistically; the fusion will produce a valid result
    # or we detect empty intersection via the lambda search.

    q2, Q2 = E2.parameters()
    assert q2 is not None and Q2 is not None

    sd2, rk2 = E2.dimension()
    Q2_work = Q2.copy()
    if rk2 < sd2:
        Q2_work = regularize(Q2_work)
    Q2_inv = ell_inv(Q2_work)
    Q2_inv = 0.5 * (Q2_inv + Q2_inv.T)

    # Find optimal lambda via root-finding on ell_fusionlambda
    try:
        a_star = scipy.optimize.brentq(
            _ell_fusionlambda,
            1e-10,
            1.0 - 1e-10,
            args=(q1, Q1_inv, q2, Q2_inv, n),
            xtol=ABS_TOL,
        )
    except ValueError:
        # No root found — pick the endpoint with smaller determinant
        if np.linalg.det(Q1_inv) > np.linalg.det(Q2_inv):
            a_star = 1.0 - 1e-10
        else:
            a_star = 1e-10

    a = a_star
    X = a * Q1_inv + (1.0 - a) * Q2_inv
    X = 0.5 * (X + X.T)
    Y = ell_inv(X)
    Y = 0.5 * (Y + Y.T)
    k = 1.0 - a * (1.0 - a) * float(
        (q2 - q1) @ Q2_inv @ Y @ Q1_inv @ (q2 - q1)
    )
    q = Y @ (a * Q1_inv @ q1 + (1.0 - a) * Q2_inv @ q2)
    Q_result = (1.0 + ABS_TOL) * k * Y
    Q_result = 0.5 * (Q_result + Q_result.T)

    # Check PSD
    min_eig = float(np.min(np.linalg.eigvalsh(Q_result)))
    if min_eig < -ABS_TOL:
        return Ellipsoid()

    # Clamp tiny negatives
    if min_eig < 0:
        Q_result = Q_result - min_eig * np.eye(n)

    return Ellipsoid(q, Q_result)


def _intersection_ea_halfspace(
    E1: Ellipsoid,
    H: Hyperplane,
    q1: np.ndarray,
    Q1_inv: np.ndarray,
    n: int,
) -> Ellipsoid:
    """EA intersection of an ellipsoid with a halfspace defined by H."""
    from .distance import distance_to_hyperplane

    v, c = (-H).parameters()
    assert v is not None
    nrm = np.sqrt(float(v @ v))
    if nrm < ABS_TOL:
        return E1
    v = v / nrm
    c = c / nrm

    # If center is in the right halfspace and E doesn't cross H, return E1 as-is
    if float(v @ q1) > c and distance_to_hyperplane(E1, H) > ABS_TOL:
        return E1
    # If center is in the wrong halfspace and E doesn't cross H, empty
    if float(v @ q1) < c and distance_to_hyperplane(E1, H) > ABS_TOL:
        return Ellipsoid()

    # Get hp intersection to define the artificial ellipsoid
    hp_ell = hpintersection(E1, H)
    if hp_ell.is_empty():
        return E1

    q_hp, _ = hp_ell.parameters()
    assert q_hp is not None
    h = 2.0 * np.sqrt(E1.max_eig())
    q2 = q_hp + h * v
    Q2_inv = np.outer(v, v) / (h ** 2)

    # Fusion
    try:
        a_star = scipy.optimize.brentq(
            _ell_fusionlambda,
            1e-10,
            1.0 - 1e-10,
            args=(q1, Q1_inv, q2, Q2_inv, n),
            xtol=ABS_TOL,
        )
    except ValueError:
        a_star = 1.0 - 1e-10

    a = a_star
    X = a * Q1_inv + (1.0 - a) * Q2_inv
    X = 0.5 * (X + X.T)
    Y = ell_inv(X)
    Y = 0.5 * (Y + Y.T)
    k = 1.0 - a * (1.0 - a) * float(
        (q2 - q1) @ Q2_inv @ Y @ Q1_inv @ (q2 - q1)
    )
    q = Y @ (a * Q1_inv @ q1 + (1.0 - a) * Q2_inv @ q2)
    Q_result = (1.0 + ABS_TOL) * k * Y
    Q_result = 0.5 * (Q_result + Q_result.T)

    min_eig = float(np.min(np.linalg.eigvalsh(Q_result)))
    if min_eig < -ABS_TOL:
        return Ellipsoid()
    if min_eig < 0:
        Q_result = Q_result - min_eig * np.eye(n)

    return Ellipsoid(q, Q_result)


# =====================================================================
# Phase 2C-ii: Internal approximation (ellipsoid–halfspace, closed form)
# =====================================================================

def intersection_ia(
    E1: Ellipsoid,
    E2: Ellipsoid | Hyperplane,
) -> Ellipsoid:
    """Internal ellipsoidal approximation of the intersection E1 ∩ E2.

    For two ellipsoids, delegates to :func:`ellintersection_ia`.
    For an ellipsoid and a halfspace, uses a closed-form formula.

    Returns an empty ellipsoid if the intersection is empty.

    Parameters
    ----------
    E1 : Ellipsoid
    E2 : Ellipsoid or Hyperplane

    Returns
    -------
    Ellipsoid
    """
    if E1.is_empty():
        raise ValueError("intersection_ia: E1 must be non-empty.")

    if isinstance(E2, Ellipsoid):
        if E2.is_empty():
            raise ValueError("intersection_ia: E2 must be non-empty.")
        if E1 == E2:
            return E1
        return ellintersection_ia([E1, E2])

    if isinstance(E2, Hyperplane):
        if E2.is_empty():
            raise ValueError("intersection_ia: E2 must be non-empty.")
        return _intersection_ia_halfspace(E1, E2)

    raise TypeError(f"intersection_ia: unsupported type {type(E2)}.")


def _intersection_ia_halfspace(E1: Ellipsoid, H: Hyperplane) -> Ellipsoid:
    """IA of ellipsoid intersected with a halfspace (closed form)."""
    from .distance import distance_to_hyperplane

    q1, Q1 = E1.parameters()
    assert q1 is not None and Q1 is not None
    n = q1.shape[0]

    sd1, rk1 = E1.dimension()
    Q1_work = Q1.copy()
    if rk1 < sd1:
        Q1_work = regularize(Q1_work)
    Q1_inv = ell_inv(Q1_work)
    Q1_inv = 0.5 * (Q1_inv + Q1_inv.T)

    v, c = (-H).parameters()
    assert v is not None
    nrm = np.sqrt(float(v @ v))
    if nrm < ABS_TOL:
        return E1
    v = v / nrm
    c = c / nrm

    if float(v @ q1) > c and distance_to_hyperplane(E1, H) > ABS_TOL:
        return E1
    if float(v @ q1) < c and distance_to_hyperplane(E1, H) > ABS_TOL:
        return Ellipsoid()

    # Get hpintersection and support boundary
    hp_ell = hpintersection(E1, H)
    if hp_ell.is_empty():
        return E1

    q_hp, _ = hp_ell.parameters()
    assert q_hp is not None
    _, x_bnd = E1.rho(v)
    h = 2.0 * np.sqrt(E1.max_eig())
    q2 = q_hp + h * v
    Q2_inv = np.outer(v, v) / (h ** 2)

    # Fusion weights from the containment conditions
    e1_val = float((q1 - q_hp) @ Q1_inv @ (q1 - q_hp))
    e2_val = float((q2 - x_bnd) @ Q2_inv @ (q2 - x_bnd))

    denom = 1.0 - e1_val * e2_val
    if abs(denom) < ABS_TOL:
        return Ellipsoid()

    a1 = (1.0 - e2_val) / denom
    a2 = (1.0 - e1_val) / denom

    Q_comb = a1 * Q1_inv + a2 * Q2_inv
    Q_comb = 0.5 * (Q_comb + Q_comb.T)
    q_new = ell_inv(Q_comb) @ (a1 * Q1_inv @ q1 + a2 * Q2_inv @ q2)

    scale = 1.0 - (
        a1 * float(q1 @ Q1_inv @ q1)
        + a2 * float(q2 @ Q2_inv @ q2)
        - float(q_new @ Q_comb @ q_new)
    )
    if scale < ABS_TOL:
        return Ellipsoid()

    Q_result = ell_inv(Q_comb / scale)
    Q_result = (1.0 - ABS_TOL) * 0.5 * (Q_result + Q_result.T)

    min_eig = float(np.min(np.linalg.eigvalsh(Q_result)))
    if min_eig < -ABS_TOL:
        return Ellipsoid()
    if min_eig < 0:
        Q_result = Q_result - min_eig * np.eye(n)

    return Ellipsoid(q_new, Q_result)


# =====================================================================
# Phase 2C-iii: SDP-based operations (CVXPY)
# =====================================================================

def ellintersection_ia(ellipsoids: Sequence[Ellipsoid]) -> Ellipsoid:
    """Maximum-volume ellipsoid inside the intersection of given ellipsoids.

    Solves an SDP:  maximize log det(B)  subject to LMI containment
    constraints for each input ellipsoid.

    Requires CVXPY to be installed.

    Parameters
    ----------
    ellipsoids : sequence of Ellipsoid
        All must be non-empty and of the same dimension.

    Returns
    -------
    Ellipsoid
        The maximum-volume inscribed ellipsoid, or empty if infeasible.
    """
    try:
        import cvxpy as cp
    except ImportError as exc:
        raise ImportError(
            "ellintersection_ia requires CVXPY. Install with: pip install cvxpy"
        ) from exc

    if len(ellipsoids) < 2:
        raise ValueError("ellintersection_ia: need at least two ellipsoids.")
    if any(e.is_empty() for e in ellipsoids):
        raise ValueError("ellintersection_ia: all ellipsoids must be non-empty.")

    dims = [e.dimension()[0] for e in ellipsoids]
    if len(set(dims)) != 1:
        raise ValueError("ellintersection_ia: all ellipsoids must have the same dimension.")
    n = dims[0]
    M = len(ellipsoids)

    B = cp.Variable((n, n), symmetric=True)
    d = cp.Variable((n, 1))
    lambdas = cp.Variable(M, nonneg=True)

    constraints = []
    for i, E in enumerate(ellipsoids):
        q_i, Q_i = E.parameters()
        assert q_i is not None and Q_i is not None

        sd, rk = E.dimension()
        Q_work = Q_i.copy()
        if rk < sd:
            Q_work = regularize(Q_work)

        A_i = ell_inv(Q_work)
        A_i = 0.5 * (A_i + A_i.T)
        b_i = -A_i @ q_i
        c_i = float(q_i @ A_i @ q_i) - 1.0

        zz = np.zeros((n, 1))
        q_col = (Q_work @ b_i).reshape(-1, 1)

        # LMI: [[-lambda_i - c_i + b_i^T Q b_i,  0^T,         (d + Q b_i)^T],
        #        [0,                               lambda_i*I,   B           ],
        #        [d + Q b_i,                       B,            Q           ]]  >> 0
        top_left = -lambdas[i] - c_i + float(b_i @ Q_work @ b_i)
        block = cp.bmat([
            [cp.reshape(top_left, (1, 1), order='F'), zz.T, (d + q_col).T],
            [zz, lambdas[i] * np.eye(n), B],
            [d + q_col, B, Q_work],
        ])
        constraints.append(block >> 0)

    objective = cp.Maximize(cp.log_det(B))
    problem = cp.Problem(objective, constraints)

    try:
        problem.solve(solver=cp.SCS, verbose=False, max_iters=5000)
    except cp.SolverError:
        return Ellipsoid()

    if problem.status in ("infeasible", "infeasible_inaccurate"):
        return Ellipsoid()

    B_val = B.value
    d_val = d.value

    if B_val is None or d_val is None:
        return Ellipsoid()

    if np.linalg.matrix_rank(B_val, tol=ABS_TOL) < n:
        B_val = regularize(B_val)

    P = B_val @ B_val.T
    P = (1.0 - ABS_TOL) * 0.5 * (P + P.T)

    min_eig = float(np.min(np.linalg.eigvalsh(P)))
    if min_eig < -ABS_TOL:
        return Ellipsoid()
    if min_eig < 0:
        P = P - min_eig * np.eye(n)

    return Ellipsoid(d_val.ravel(), P)


def ellunion_ea(ellipsoids: Sequence[Ellipsoid]) -> Ellipsoid:
    """Minimum-volume ellipsoid containing the union of given ellipsoids.

    Solves an SDP:  minimize -log det(A)  subject to LMI containment
    constraints for each input ellipsoid.

    Requires CVXPY to be installed.

    Parameters
    ----------
    ellipsoids : sequence of Ellipsoid
        All must be non-empty and of the same dimension.

    Returns
    -------
    Ellipsoid
        The minimum-volume bounding ellipsoid, or empty if infeasible.
    """
    try:
        import cvxpy as cp
    except ImportError as exc:
        raise ImportError(
            "ellunion_ea requires CVXPY. Install with: pip install cvxpy"
        ) from exc

    if len(ellipsoids) < 2:
        raise ValueError("ellunion_ea: need at least two ellipsoids.")
    if any(e.is_empty() for e in ellipsoids):
        raise ValueError("ellunion_ea: all ellipsoids must be non-empty.")

    dims = [e.dimension()[0] for e in ellipsoids]
    if len(set(dims)) != 1:
        raise ValueError("ellunion_ea: all ellipsoids must have the same dimension.")
    n = dims[0]
    M = len(ellipsoids)

    A = cp.Variable((n, n), symmetric=True)
    b = cp.Variable((n, 1))
    taus = cp.Variable(M, nonneg=True)

    constraints = []
    zz = np.zeros((n, n))

    for i, E in enumerate(ellipsoids):
        q_i, Q_i = E.parameters()
        assert q_i is not None and Q_i is not None

        sd, rk = E.dimension()
        Q_work = Q_i.copy()
        if rk < sd:
            Q_work = regularize(Q_work)

        Qi_inv = ell_inv(Q_work)
        Qi_inv = 0.5 * (Qi_inv + Qi_inv.T)
        bb_i = (-Qi_inv @ q_i).reshape(-1, 1)
        cc_i = float(q_i @ Qi_inv @ q_i) - 1.0

        # LMI:  [[A - tau_i*Qi_inv,     b - tau_i*bb_i,   0  ],
        #         [(b - tau_i*bb_i)^T,   -1 - tau_i*cc_i,  b^T],
        #         [0,                     b,               -A  ]]  << 0
        block = cp.bmat([
            [A - taus[i] * Qi_inv, b - taus[i] * bb_i, zz],
            [(b - taus[i] * bb_i).T, cp.reshape(-1.0 - taus[i] * cc_i, (1, 1), order='F'), b.T],
            [zz, b, -A],
        ])
        constraints.append(block << 0)

    objective = cp.Maximize(cp.log_det(A))
    problem = cp.Problem(objective, constraints)

    try:
        problem.solve(solver=cp.SCS, verbose=False, max_iters=5000)
    except cp.SolverError:
        return Ellipsoid()

    if problem.status in ("infeasible", "infeasible_inaccurate"):
        return Ellipsoid()

    A_val = A.value
    b_val = b.value

    if A_val is None or b_val is None:
        return Ellipsoid()

    A_val = 0.5 * (A_val + A_val.T)
    B_val = np.real(scipy.linalg.sqrtm(A_val))
    P = ell_inv(B_val.T @ B_val)
    P = (1.0 + ABS_TOL) * 0.5 * (P + P.T)

    A_inv = ell_inv(A_val)
    p = -A_inv @ b_val.ravel()

    min_eig = float(np.min(np.linalg.eigvalsh(P)))
    if min_eig < -ABS_TOL:
        return Ellipsoid()
    if min_eig < 0:
        P = P - min_eig * np.eye(n)

    return Ellipsoid(p, P)


# =====================================================================
# Phase 2D: intersect / isinside  (QCQP via CVXPY)
# =====================================================================

def intersect(
    E_list: Sequence[Ellipsoid],
    X: Ellipsoid | Hyperplane,
    mode: str = "u",
) -> int:
    """Check if the union or intersection of ellipsoids intersects *X*.

    Parameters
    ----------
    E_list : sequence of Ellipsoid
        The set of ellipsoids whose union (``mode='u'``) or intersection
        (``mode='i'``) is tested.
    X : Ellipsoid or Hyperplane
        The object to test against.
    mode : {'u', 'i'}
        ``'u'`` — union of *E_list* (default).
        ``'i'`` — intersection of *E_list*.

    Returns
    -------
    int
        ``1``  if the intersection/union of *E_list* does intersect *X*.
        ``0``  if the intersection is empty.
        ``-1`` if the problem is infeasible (e.g. intersection of
        *E_list* is empty when ``mode='i'``).

    Notes
    -----
    * Union mode reduces to pairwise distance checks.
    * Intersection mode uses a QCQP (requires CVXPY) when
      ``len(E_list) > 1``.

    Reference: MATLAB ``@ellipsoid/intersect.m``.
    """
    mode = mode.lower()
    if mode not in ("u", "i"):
        raise ValueError("intersect: mode must be 'u' or 'i'.")

    if any(e.is_empty() for e in E_list):
        raise ValueError("intersect: all ellipsoids in E_list must be non-empty.")

    if isinstance(X, Ellipsoid) and X.is_empty():
        raise ValueError("intersect: X must be non-empty.")
    if isinstance(X, Hyperplane) and X.is_empty():
        raise ValueError("intersect: X must be non-empty.")

    # ── Union mode or single-ellipsoid shortcut ──────────────────────
    if mode == "u" or len(E_list) == 1:
        from .distance import distance_to_point, distance_to_hyperplane, distance_to_ellipsoid

        for E in E_list:
            if isinstance(X, Ellipsoid):
                d = distance_to_ellipsoid(E, X)
            elif isinstance(X, Hyperplane):
                d = distance_to_hyperplane(E, X)
            else:
                raise TypeError(f"intersect: unsupported type {type(X)}.")

            if d <= ABS_TOL:
                return 1
        return 0

    # ── Intersection mode (QCQP) ────────────────────────────────────
    if isinstance(X, Ellipsoid):
        return _intersect_qcqp_ell(E_list, X)
    elif isinstance(X, Hyperplane):
        return _intersect_qcqp_hp(E_list, X)
    else:
        raise TypeError(f"intersect: unsupported type {type(X)}.")


def _intersect_qcqp_ell(
    E_list: Sequence[Ellipsoid],
    X: Ellipsoid,
) -> int:
    """QCQP: does the intersection of *E_list* intersect ellipsoid *X*?

    min  (x - q)^T Q^{-1} (x - q)
    s.t. (x - q_i)^T Q_i^{-1} (x - q_i) <= 1   for each E_i
    Result: 1 if opt_val <= 1, 0 otherwise, -1 if infeasible.
    """
    import cvxpy as cp

    q, Q = X.parameters()
    assert q is not None and Q is not None
    n = q.shape[0]

    sd, rk = X.dimension()
    Q_work = regularize(Q) if rk < n else Q.copy()
    Qi = ell_inv(Q_work)
    Qi = 0.5 * (Qi + Qi.T)

    x = cp.Variable(n)
    objective = cp.quad_form(x - q, Qi)
    constraints = []

    for E in E_list:
        q_i, Q_i = E.parameters()
        assert q_i is not None and Q_i is not None
        sd_i, rk_i = E.dimension()
        Q_i_w = regularize(Q_i) if rk_i < n else Q_i.copy()
        Qi_i = ell_inv(Q_i_w)
        Qi_i = 0.5 * (Qi_i + Qi_i.T)
        constraints.append(cp.quad_form(x - q_i, Qi_i) <= 1.0)

    prob = cp.Problem(cp.Minimize(objective), constraints)
    try:
        prob.solve(solver=cp.SCS, eps=1e-9, max_iters=10000)
    except cp.SolverError:
        return -1

    if prob.status in ("infeasible", "infeasible_inaccurate"):
        return -1

    if prob.value is None:
        return -1

    return 1 if prob.value < 1.0 + ABS_TOL else 0


def _intersect_qcqp_hp(
    E_list: Sequence[Ellipsoid],
    H: Hyperplane,
) -> int:
    """QCQP: does the intersection of *E_list* intersect hyperplane *H*?

    First check feasibility of the intersection of E_list.  Then add the
    hyperplane equality constraint v^T x = c and check feasibility again.
    """
    import cvxpy as cp

    v, c = H.parameters()
    assert v is not None
    n = v.shape[0]

    if c < 0:
        c = -c
        v = -v

    x = cp.Variable(n)
    objective = cp.Constant(0)
    constraints = []

    for E in E_list:
        q_i, Q_i = E.parameters()
        assert q_i is not None and Q_i is not None
        sd_i, rk_i = E.dimension()
        Q_i_w = regularize(Q_i) if rk_i < n else Q_i.copy()
        Qi_i = ell_inv(Q_i_w)
        Qi_i = 0.5 * (Qi_i + Qi_i.T)
        constraints.append(cp.quad_form(x - q_i, Qi_i) <= 1.0)

    # First check if ellipsoid intersection is non-empty
    prob_feas = cp.Problem(cp.Minimize(objective), constraints)
    try:
        prob_feas.solve(solver=cp.SCS, eps=1e-9, max_iters=10000)
    except cp.SolverError:
        return -1

    if prob_feas.status in ("infeasible", "infeasible_inaccurate"):
        return -1

    # Now add hyperplane constraint and check feasibility
    constraints.append(v @ x == c)
    prob_hp = cp.Problem(cp.Minimize(objective), constraints)
    try:
        prob_hp.solve(solver=cp.SCS, eps=1e-9, max_iters=10000)
    except cp.SolverError:
        return 0

    if prob_hp.status in ("infeasible", "infeasible_inaccurate"):
        return 0

    return 1


def isinside(
    E1_list: Sequence[Ellipsoid],
    E2_list: Sequence[Ellipsoid],
    mode: str = "u",
) -> int:
    """Check if the intersection of *E1_list* contains the union/intersection of *E2_list*.

    Parameters
    ----------
    E1_list : sequence of Ellipsoid
        The "container" — their intersection defines the containing set.
    E2_list : sequence of Ellipsoid
        The "contained" set.  Interpreted as a union (``mode='u'``) or
        intersection (``mode='i'``).
    mode : {'u', 'i'}
        ``'u'`` — check if the *union* of *E2_list* lies inside the
        intersection of *E1_list* (default).
        ``'i'`` — check if the *intersection* of *E2_list* lies inside.

    Returns
    -------
    int
        ``1``  if containment holds.
        ``0``  if containment does not hold.
        ``-1`` if the problem is infeasible (e.g. intersection of
        *E2_list* is empty when ``mode='i'``).

    Notes
    -----
    * Union mode reduces to checking that *every* E2_j is contained in
      *every* E1_i (via ``is_bigger`` after centering).
    * Intersection mode uses a QCQP (requires CVXPY): for each E1_i,
      maximize (x - q_i)^T Q_i^{-1} (x - q_i) subject to x in all E2_j.
      If the max <= 1 for every E1_i, containment holds.

    Reference: MATLAB ``@ellipsoid/isinside.m``.
    """
    mode = mode.lower()
    if mode not in ("u", "i"):
        raise ValueError("isinside: mode must be 'u' or 'i'.")

    if any(e.is_empty() for e in E1_list):
        raise ValueError("isinside: all ellipsoids in E1_list must be non-empty.")
    if any(e.is_empty() for e in E2_list):
        raise ValueError("isinside: all ellipsoids in E2_list must be non-empty.")

    # ── Union mode: every E2_j inside every E1_i ────────────────────
    if mode == "u":
        for E1 in E1_list:
            for E2 in E2_list:
                if not _ell_contains(E1, E2):
                    return 0
        return 1

    # ── Intersection mode: single E2 shortcut ───────────────────────
    if len(E2_list) == 1:
        for E1 in E1_list:
            if not _ell_contains(E1, E2_list[0]):
                return 0
        return 1

    # ── Intersection mode (QCQP) ────────────────────────────────────
    for E1 in E1_list:
        result = _isinside_qcqp(E2_list, E1)
        if result < 1:
            return result
    return 1


def _ell_contains(E_outer: Ellipsoid, E_inner: Ellipsoid) -> bool:
    """Check if E_outer contains E_inner using support functions.

    E_inner ⊆ E_outer iff ρ(l | E_inner) ≤ ρ(l | E_outer) for all l.
    We search for the worst-case direction using strategic starting
    points and numerical optimisation (no CVXPY required).
    """
    q_o, Q_o = E_outer.parameters()
    q_i, Q_i = E_inner.parameters()
    assert q_o is not None and Q_o is not None
    assert q_i is not None and Q_i is not None

    n = q_o.shape[0]
    if q_i.shape[0] != n:
        raise ValueError("isinside: dimension mismatch.")

    sd_o, rk_o = E_outer.dimension()
    Q_o_w = regularize(Q_o) if rk_o < n else Q_o.copy()
    sd_i, rk_i = E_inner.dimension()
    Q_i_w = regularize(Q_i) if rk_i < n else Q_i.copy()

    def _rho_gap(l_raw: np.ndarray) -> float:
        """ρ(l | E_inner) − ρ(l | E_outer).  Positive ⇒ inner pokes out."""
        nrm = np.linalg.norm(l_raw)
        if nrm < 1e-15:
            return 0.0
        l = l_raw / nrm
        r_in = float(l @ q_i) + np.sqrt(max(float(l @ Q_i_w @ l), 0.0))
        r_out = float(l @ q_o) + np.sqrt(max(float(l @ Q_o_w @ l), 0.0))
        return r_in - r_out

    # ── Strategic directions ──
    candidates: list[np.ndarray] = []

    d = q_i - q_o
    if np.linalg.norm(d) > 1e-15:
        candidates.extend([d, -d])

    for k in range(n):
        e = np.zeros(n)
        e[k] = 1.0
        candidates.extend([e, -e])

    for Q in (Q_o_w, Q_i_w):
        _, V = np.linalg.eigh(Q)
        for k in range(n):
            candidates.extend([V[:, k], -V[:, k]])

    for l0 in candidates:
        if _rho_gap(l0) > ABS_TOL:
            return False

    # ── Numerical search for worst-case direction ──
    rng = np.random.default_rng(42)
    for _ in range(max(20, 10 * n)):
        l0 = rng.standard_normal(n)
        res = scipy.optimize.minimize(
            lambda l: -_rho_gap(l), l0,
            method="Nelder-Mead",
            options={"xatol": 1e-12, "fatol": 1e-12, "maxiter": 2000},
        )
        if -res.fun > ABS_TOL:
            return False

    return True


def _isinside_qcqp(
    E2_list: Sequence[Ellipsoid],
    E1: Ellipsoid,
) -> int:
    """S-procedure: does E1 contain the intersection of E2_list?

    Uses the multi-constraint S-procedure (LMI).  There exist λ_j >= 0 such
    that the matrix

        [[Qo_inv - Σ λ_j Qj_inv,  -(Qo_inv qo - Σ λ_j Qj_inv qj)],
         [...,                      qo^T Qo_inv qo - 1 - Σ λ_j (qj^T Qj_inv qj - 1)]]

    is negative semidefinite.

    Result: 1 if LMI feasible (containment), 0 otherwise, -1 if infeasible.
    """
    import cvxpy as cp

    q, Q = E1.parameters()
    assert q is not None and Q is not None
    n = q.shape[0]

    sd, rk = E1.dimension()
    Q_work = regularize(Q) if rk < n else Q.copy()
    Qi = ell_inv(Q_work)
    Qi = 0.5 * (Qi + Qi.T)

    M = len(E2_list)
    lams = cp.Variable(M, nonneg=True)

    A_block = Qi  # starts with Qo_inv
    b_const = -Qi @ q  # -Qo_inv @ qo
    c_const = float(q @ Qi @ q) - 1.0

    inv_data = []
    for E2 in E2_list:
        q_j, Q_j = E2.parameters()
        assert q_j is not None and Q_j is not None
        sd_j, rk_j = E2.dimension()
        Q_j_w = regularize(Q_j) if rk_j < n else Q_j.copy()
        Qj_inv = ell_inv(Q_j_w)
        Qj_inv = 0.5 * (Qj_inv + Qj_inv.T)
        inv_data.append((q_j, Qj_inv, float(q_j @ Qj_inv @ q_j) - 1.0))

    # Build M = [[A, b], [b^T, c]] with A = Qi - Σ λ_j Qj_inv, etc.
    A_sum = A_block
    b_sum = b_const.reshape(-1, 1)
    c_sum = c_const

    for j, (q_j, Qj_inv, cj) in enumerate(inv_data):
        A_sum = A_sum - lams[j] * Qj_inv
        b_sum = b_sum + lams[j] * (Qj_inv @ q_j).reshape(-1, 1)
        c_sum = c_sum - lams[j] * cj

    M_mat = cp.bmat([
        [A_sum, b_sum],
        [b_sum.T, cp.reshape(c_sum, (1, 1), order='F')],
    ])

    prob = cp.Problem(cp.Minimize(0), [M_mat << 0])
    try:
        prob.solve(solver=cp.SCS, eps=1e-9, max_iters=10000)
    except cp.SolverError:
        return -1

    if prob.status in ("infeasible", "infeasible_inaccurate"):
        return 0

    if prob.status in ("optimal", "optimal_inaccurate"):
        return 1

    return -1
