"""
Topological Data Analysis service.
Detects semantic "holes" in embedding space using persistent homology.
Fallback chain: Ripser → scikit-learn LOF
"""

import numpy as np
from sklearn.decomposition import PCA
from sklearn.neighbors import LocalOutlierFactor


def detect_holes(
    embeddings: np.ndarray,
    threshold: float = 0.15,
    max_holes: int = 5,
) -> list[dict]:
    """
    Detect topological holes in the embedding space.

    Returns a list of hole dicts sorted by persistence descending:
        [{birth, death, persistence, centroid_idx, nearby_indices}, ...]
    """
    n_samples = embeddings.shape[0]
    if n_samples < 3:
        return []

    # PCA reduce to manageable dimensions
    n_components = min(10, n_samples - 1, embeddings.shape[1])
    pca = PCA(n_components=n_components)
    reduced = pca.fit_transform(embeddings)

    # Try Ripser first (true persistent homology)
    try:
        return _detect_with_ripser(reduced, threshold, max_holes)
    except Exception:
        pass

    # Fallback: LOF-based gap detection
    return _detect_with_lof(reduced, embeddings, threshold, max_holes)


def _detect_with_ripser(
    reduced: np.ndarray, threshold: float, max_holes: int
) -> list[dict]:
    """Use Ripser for true persistent homology (H1 holes)."""
    from ripser import ripser

    result = ripser(reduced, maxdim=1, thresh=2.0)
    dgm = result["dgms"][1]  # H1 diagram (loops/holes)

    holes = []
    for i, (birth, death) in enumerate(dgm):
        if np.isinf(death):
            continue
        persistence = death - birth
        if persistence < threshold:
            continue

        # Find the centroid of this hole (approximate)
        centroid_idx = _find_centroid_for_hole(reduced, birth, death)
        nearby = _find_nearby_indices(reduced, centroid_idx, k=3)

        holes.append({
            "birth": float(birth),
            "death": float(death),
            "persistence": float(persistence),
            "centroid_idx": int(centroid_idx),
            "nearby_indices": nearby,
        })

    # Sort by persistence descending, limit to max_holes
    holes.sort(key=lambda h: h["persistence"], reverse=True)
    return holes[:max_holes]


def _detect_with_lof(
    reduced: np.ndarray,
    original: np.ndarray,
    threshold: float,
    max_holes: int,
) -> list[dict]:
    """
    Fallback: Use Local Outlier Factor to find sparse regions.
    Less mathematically rigorous but always works.
    """
    # For very small datasets (like our 4-doc test), we need n_neighbors to be very small, e.g. 2
    n_neighbors = max(1, min(2, len(reduced) - 1))
    
    # Provide a fallback if n_neighbors is 1 (LOF requires at least 1 neighbor, but works better with more)
    # Actually, LocalOutlierFactor requires n_neighbors >= 1.
    lof = LocalOutlierFactor(n_neighbors=n_neighbors, novelty=False)
    scores = -lof.fit_predict(reduced)  # Higher = more outlier-like

    # Negative LOF scores indicate outliers (sparse regions)
    lof_scores = -lof.negative_outlier_factor_

    # Find the most "sparse" regions (highest LOF scores)
    indices = np.argsort(lof_scores)[::-1]

    holes = []
    for rank, idx in enumerate(indices[:max_holes]):
        persistence = float(lof_scores[idx] - 1.0)  # Normalize
        if persistence < threshold:
            continue

        nearby = _find_nearby_indices(reduced, idx, k=3)
        holes.append({
            "birth": 0.0,
            "death": float(persistence),
            "persistence": float(persistence),
            "centroid_idx": int(idx),
            "nearby_indices": nearby,
        })

    holes.sort(key=lambda h: h["persistence"], reverse=True)
    return holes[:max_holes]


def _find_centroid_for_hole(reduced: np.ndarray, birth: float, death: float) -> int:
    """Find the point closest to the midpoint of a hole's birth-death range."""
    from sklearn.metrics import pairwise_distances
    dists = pairwise_distances(reduced)
    midpoint = (birth + death) / 2

    # Find pairs of points with distance closest to midpoint
    n = len(reduced)
    best_idx = 0
    best_diff = float("inf")
    for i in range(n):
        for j in range(i + 1, n):
            diff = abs(dists[i, j] - midpoint)
            if diff < best_diff:
                best_diff = diff
                best_idx = i
    return best_idx


def _find_nearby_indices(reduced: np.ndarray, center_idx: int, k: int = 3) -> list[int]:
    """Find k nearest neighbors to a given point index."""
    from sklearn.metrics import pairwise_distances
    dists = pairwise_distances(reduced[center_idx:center_idx + 1], reduced)[0]
    indices = np.argsort(dists)[1:k + 1]  # Skip self (distance=0)
    return [int(i) for i in indices]
