# -*- coding: utf-8 -*-
"""LSCP (Locally Selective Combination in Parallel) ensemble.

LSCP selects competent detectors in the *local region* of each test sample.

Reference
---------
Zhao, Y. et al., 2019. LSCP: Locally Selective Combination in Parallel Outlier
Ensembles. SDM (and related preprints).

Notes
-----
This is a native PyImgAno implementation inspired by the original LSCP design,
but implemented with minimal dependencies (NumPy + scikit-learn) and adapted
to the `pyimgano` detector contract.
"""

from __future__ import annotations

from collections import Counter
from typing import Iterable, List, Optional, Sequence

import numpy as np
from numpy.typing import NDArray
from sklearn.neighbors import KDTree
from sklearn.utils import check_array

from ..utils.random_state import check_random_state
from .baseml import BaseVisionDetector
from .core_feature_base import CoreFeatureDetector
from .registry import register_model

_MAX_INT = int(np.iinfo(np.int32).max)


def _pearson_corr(a: NDArray[np.float64], b: NDArray[np.float64]) -> float:
    a = np.asarray(a, dtype=np.float64).reshape(-1)
    b = np.asarray(b, dtype=np.float64).reshape(-1)
    if a.shape[0] != b.shape[0] or a.shape[0] < 2:
        return 0.0

    a0 = a - float(np.mean(a))
    b0 = b - float(np.mean(b))
    denom = float(np.sqrt(np.sum(a0 * a0) * np.sum(b0 * b0)))
    if denom <= 0.0:
        return 0.0
    return float(np.dot(a0, b0) / denom)


def _zscore_standardize(
    train_scores: NDArray[np.float64],
    test_scores: NDArray[np.float64],
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
    mu = np.mean(train_scores, axis=0)
    sigma = np.std(train_scores, axis=0)
    sigma = np.where(sigma > 0.0, sigma, 1.0)
    return (train_scores - mu) / sigma, (test_scores - mu) / sigma


def _generate_feature_subspace(
    rng: np.random.Generator,
    *,
    n_features: int,
    min_features: int,
    max_features: int,
) -> NDArray[np.int64]:
    if n_features < 1:
        raise ValueError("n_features must be >= 1")
    if min_features < 1:
        raise ValueError("min_features must be >= 1")
    if max_features < min_features:
        raise ValueError("max_features must be >= min_features")
    if max_features > n_features:
        raise ValueError("max_features must be <= n_features")

    k = int(rng.integers(min_features, max_features + 1))
    return rng.choice(n_features, size=k, replace=False).astype(np.int64, copy=False)


class CoreLSCP:
    """LSCP core operating on feature matrices."""

    _legacy_attr_aliases = {"X_train_": "x_train_"}

    def __init__(
        self,
        detector_list: Sequence[object],
        *,
        local_region_size: int = 30,
        local_max_features: float = 1.0,
        n_bins: int = 10,
        random_state: int | np.random.Generator | None = None,
        contamination: float = 0.1,
        local_region_iterations: int = 20,
        local_min_features: float = 0.5,
    ) -> None:
        self.detector_list = list(detector_list)
        self.n_clf = int(len(self.detector_list))
        self.local_region_size = int(local_region_size)
        self.local_max_features = float(local_max_features)
        self.n_bins = int(n_bins)
        self.random_state = random_state
        self.contamination = float(contamination)
        self.local_region_iterations = int(local_region_iterations)
        self.local_min_features = float(local_min_features)

        self.x_train_: NDArray[np.float64] | None = None
        self.train_scores_: NDArray[np.float64] | None = None
        self.decision_scores_: NDArray[np.float64] | None = None

    def __getattr__(self, name: str):
        alias = type(self)._legacy_attr_aliases.get(name)
        if alias is not None:
            return getattr(self, alias)
        raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}")

    def __setattr__(self, name: str, value) -> None:
        alias = type(self)._legacy_attr_aliases.get(name)
        super().__setattr__(alias or name, value)

    def fit(self, x, y=None):  # noqa: ANN001, ANN201 - sklearn-like API
        del y  # kept for sklearn-like API compatibility
        if self.n_clf < 2:
            raise ValueError("detector_list must contain at least 2 base detectors")

        x = check_array(x, ensure_2d=True, dtype=np.float64)
        self.x_train_ = x

        train_scores = np.zeros((x.shape[0], self.n_clf), dtype=np.float64)
        for k, det in enumerate(self.detector_list):
            fit = getattr(det, "fit", None)
            decision = getattr(det, "decision_function", None)
            if not callable(fit) or not callable(decision):
                raise TypeError("Each base detector must implement fit() and decision_function()")

            det.fit(x)
            scores = getattr(det, "decision_scores_", None)
            if scores is None:
                raise RuntimeError("Base detector did not set decision_scores_ during fit")
            scores_np = np.asarray(scores, dtype=np.float64).reshape(-1)
            if scores_np.shape[0] != x.shape[0]:
                raise ValueError("Base detector returned unexpected decision_scores_ length")
            train_scores[:, k] = scores_np

        self.train_scores_ = train_scores
        self.decision_scores_ = np.asarray(self.decision_function(x), dtype=np.float64)
        return self

    def decision_function(self, x):  # noqa: ANN001, ANN201 - sklearn-like API
        if self.x_train_ is None or self.train_scores_ is None:
            raise RuntimeError("Detector must be fitted before calling decision_function")

        x = check_array(x, ensure_2d=True, dtype=np.float64)
        if x.shape[1] != self.x_train_.shape[1]:
            raise ValueError(
                f"Number of features must match training data. "
                f"Model n_features={self.x_train_.shape[1]}, input n_features={x.shape[1]}."
            )

        return self._get_decision_scores(x)

    def _get_decision_scores(self, x_test: NDArray[np.float64]) -> NDArray[np.float64]:
        assert self.x_train_ is not None
        assert self.train_scores_ is not None

        _, _ = map(int, self.x_train_.shape)

        # Compute local regions for each test sample (indices into training set).
        local_regions = self._get_local_regions(x_test)

        # Collect test scores from all detectors.
        test_scores = np.zeros((x_test.shape[0], self.n_clf), dtype=np.float64)
        for k, det in enumerate(self.detector_list):
            test_scores[:, k] = np.asarray(det.decision_function(x_test), dtype=np.float64).reshape(
                -1
            )

        train_scores_norm, test_scores_norm = _zscore_standardize(self.train_scores_, test_scores)

        # Global pseudo-labels for training: max of standardized detector scores.
        training_pseudo = np.max(train_scores_norm, axis=1).reshape(-1)

        pred = np.zeros((x_test.shape[0],), dtype=np.float64)
        for i, region in enumerate(local_regions):
            region_idx = np.asarray(region, dtype=np.int64)
            if region_idx.size < 2:
                # Fallback: use global region.
                region_idx = np.arange(self.x_train_.shape[0], dtype=np.int64)

            local_pseudo = training_pseudo[region_idx]
            local_train_scores = train_scores_norm[region_idx, :]

            corr = np.zeros((self.n_clf,), dtype=np.float64)
            for d in range(self.n_clf):
                corr[d] = _pearson_corr(local_pseudo, local_train_scores[:, d])
            if np.isnan(corr).any():
                corr = np.nan_to_num(corr)

            competent = self._get_competent_detectors(corr)
            pred[i] = float(np.mean(test_scores_norm[i, competent]))

        return pred

    def _get_local_regions(self, x_test: NDArray[np.float64]) -> List[List[int]]:
        assert self.x_train_ is not None

        rng = check_random_state(self.random_state)

        n_train, n_features = map(int, self.x_train_.shape)
        if n_train == 0:
            raise ValueError("Empty training set")

        # Clamp parameters.
        local_region_size = int(max(1, min(self.local_region_size, n_train)))
        local_min = max(1, int(n_features * self.local_min_features))
        local_max = max(local_min, int(np.ceil(n_features * self.local_max_features)))
        local_max = min(local_max, n_features)

        # Collect neighbor indices across randomized subspaces.
        local_region_list: List[List[int]] = [[] for _ in range(x_test.shape[0])]

        for _ in range(self.local_region_iterations):
            feats = _generate_feature_subspace(
                rng, n_features=n_features, min_features=local_min, max_features=local_max
            )

            tree = KDTree(self.x_train_[:, feats], leaf_size=40)
            _, inds = tree.query(x_test[:, feats], k=local_region_size, return_distance=True)
            for j in range(x_test.shape[0]):
                local_region_list[j].extend([int(x) for x in inds[j]])

        threshold = int(self.local_region_iterations / 2)
        final_regions: List[List[int]] = []
        for j in range(x_test.shape[0]):
            counts = Counter(local_region_list[j])
            current = [idx for idx, c in counts.items() if c > threshold]

            # Ensure region is not degenerate.
            dec = 0
            while len(current) < 2 and threshold - dec > 0:
                dec += 1
                current = [idx for idx, c in counts.items() if c > (threshold - dec)]

            if not current:
                current = list(range(local_region_size))
            final_regions.append(current)

        return final_regions

    def _get_competent_detectors(self, corr_scores: NDArray[np.float64]) -> NDArray[np.int64]:
        scores = np.asarray(corr_scores, dtype=np.float64).reshape(-1)
        n_clf = int(scores.shape[0])
        if scores.size == 0:
            return np.asarray([0], dtype=np.int64)
        if np.allclose(scores, scores[0], rtol=1e-12, atol=1e-12):
            return np.asarray([int(np.argmax(scores))], dtype=np.int64)

        n_bins = int(self.n_bins)
        if n_bins < 1:
            n_bins = 1
        if n_bins > n_clf:
            n_bins = n_clf

        try:
            hist, edges = np.histogram(scores, bins=n_bins)
        except ValueError:
            return np.asarray([int(np.argmax(scores))], dtype=np.int64)
        if hist.size == 0:
            return np.asarray([int(np.argmax(scores))], dtype=np.int64)

        max_bin = int(np.argmax(hist))
        lo = float(edges[max_bin])
        hi = float(edges[max_bin + 1])
        candidates = np.nonzero((scores >= lo) & (scores <= hi))[0]
        if candidates.size == 0:
            candidates = np.asarray([int(np.argmax(scores))], dtype=np.int64)
        return candidates.astype(np.int64, copy=False)


def _default_lscp_detectors(
    *,
    contamination: float,
    random_state: Optional[int],
) -> list[object]:
    """Reasonable, cheap default base detectors for LSCP.

    Notes
    -----
    Imported lazily to avoid circular imports at module import time.
    """

    from .ecod import CoreECOD
    from .hbos import CoreHBOS
    from .iforest import CoreIForest
    from .knn import CoreKNN

    rs = random_state
    c = float(contamination)
    return [
        CoreKNN(contamination=c, n_neighbors=10, method="largest"),
        CoreKNN(contamination=c, n_neighbors=20, method="largest"),
        CoreHBOS(contamination=c, n_bins=10),
        CoreECOD(contamination=c),
        CoreIForest(contamination=c, n_estimators=100, random_state=rs),
    ]


@register_model(
    "core_lscp",
    tags=("classical", "core", "features", "ensemble", "lscp"),
    metadata={
        "description": "Core LSCP ensemble on feature matrices (native wrapper)",
        "input": "features",
        "paper": "Zhao et al., LSCP (SDM 2019)",
        "year": 2019,
    },
)
class CoreLSCPModel(CoreFeatureDetector):
    """Core (feature-matrix) LSCP ensemble with BaseDetector thresholding."""

    def __init__(
        self,
        *,
        detector_list: Sequence[object] | None = None,
        contamination: float = 0.1,
        local_region_size: int = 30,
        local_max_features: float = 1.0,
        n_bins: int = 10,
        random_state: Optional[int] = None,
        local_region_iterations: int = 20,
        local_min_features: float = 0.5,
        **kwargs,
    ) -> None:
        if detector_list is None:
            detector_list = _default_lscp_detectors(
                contamination=float(contamination), random_state=random_state
            )
        if len(detector_list) < 2:
            raise ValueError("CoreLSCPModel requires at least 2 base detectors.")
        if kwargs:
            unknown = ", ".join(sorted(kwargs))
            raise TypeError(f"Unknown LSCP parameters: {unknown}")

        self._backend_kwargs = {
            "detector_list": list(detector_list),
            "contamination": float(contamination),
            "local_region_size": int(local_region_size),
            "local_max_features": float(local_max_features),
            "n_bins": int(n_bins),
            "random_state": random_state,
            "local_region_iterations": int(local_region_iterations),
            "local_min_features": float(local_min_features),
        }
        super().__init__(contamination=float(contamination))

    def _build_detector(self):
        return CoreLSCP(**self._backend_kwargs)


@register_model(
    "core_lscp_spec",
    tags=("classical", "core", "features", "ensemble", "lscp"),
    metadata={
        "description": "Core LSCP ensemble with JSON-friendly base-detector specs",
        "input": "features",
        "paper": "Zhao et al., LSCP (SDM 2019)",
        "year": 2019,
    },
)
class CoreLSCPSpecModel(CoreFeatureDetector):
    """Core LSCP wrapper that accepts base-detector specs.

    `detector_specs` can be:
    - model name strings (e.g. "core_ecod")
    - dict specs {"name": "...", "kwargs": {...}}
    - already-instantiated detector objects
    """

    def __init__(
        self,
        *,
        detector_specs: Sequence[object] | None = None,
        contamination: float = 0.1,
        local_region_size: int = 30,
        local_max_features: float = 1.0,
        n_bins: int = 10,
        random_state: Optional[int] = None,
        local_region_iterations: int = 20,
        local_min_features: float = 0.5,
        **kwargs,
    ) -> None:
        if detector_specs is None:
            detector_specs = _default_lscp_detectors(
                contamination=float(contamination), random_state=random_state
            )
        if len(detector_specs) < 2:
            raise ValueError("CoreLSCPSpecModel requires at least 2 base detectors.")
        if kwargs:
            unknown = ", ".join(sorted(kwargs))
            raise TypeError(f"Unknown LSCP parameters: {unknown}")

        self._specs = list(detector_specs)
        self._core_kwargs = {
            "local_region_size": int(local_region_size),
            "local_max_features": float(local_max_features),
            "n_bins": int(n_bins),
            "random_state": random_state,
            "contamination": float(contamination),
            "local_region_iterations": int(local_region_iterations),
            "local_min_features": float(local_min_features),
        }
        super().__init__(contamination=float(contamination))

    def _build_detector(self):
        from pyimgano.models.ensemble_spec import resolve_model_specs

        dets = resolve_model_specs(self._specs, default_contamination=float(self.contamination))
        return CoreLSCP(detector_list=dets, **self._core_kwargs)


@register_model(
    "vision_lscp",
    tags=("vision", "classical", "ensemble", "lscp"),
    metadata={"description": "LSCP - locally selective combination ensemble (native)"},
)
class VisionLSCP(BaseVisionDetector):
    """Vision-friendly LSCP wrapper using project feature extractors."""

    def __init__(
        self,
        *,
        feature_extractor=None,
        contamination: float = 0.1,
        detector_list: Sequence[object] | None = None,
        local_region_size: int = 30,
        local_max_features: float = 1.0,
        local_region_iterations: int = 20,
        local_min_features: float = 0.5,
        n_bins: int = 10,
        random_state: Optional[int] = None,
        **kwargs,
    ) -> None:
        if detector_list is None:
            detector_list = _default_lscp_detectors(
                contamination=float(contamination), random_state=random_state
            )
        if len(detector_list) < 2:
            raise ValueError("VisionLSCP requires at least 2 base detectors.")

        if kwargs:
            # Keep forward-compat: accept extra kwargs without failing, but
            # avoid silently changing behavior by pretending to support them.
            unknown = ", ".join(sorted(kwargs))
            raise TypeError(f"Unknown LSCP parameters: {unknown}")

        self._detector_kwargs = {
            "detector_list": list(detector_list),
            "local_region_size": int(local_region_size),
            "local_max_features": float(local_max_features),
            "local_region_iterations": int(local_region_iterations),
            "local_min_features": float(local_min_features),
            "n_bins": int(n_bins),
            "random_state": random_state,
            "contamination": float(contamination),
        }
        super().__init__(contamination=contamination, feature_extractor=feature_extractor)

    def _build_detector(self):
        return CoreLSCP(**self._detector_kwargs)

    def fit(self, x: Iterable[str], y=None):
        return super().fit(x, y=y)

    def decision_function(self, x):
        return super().decision_function(x)


@register_model(
    "vision_lscp_spec",
    tags=("vision", "classical", "ensemble", "lscp"),
    metadata={"description": "LSCP ensemble with JSON-friendly base-detector specs"},
)
class VisionLSCPSpec(BaseVisionDetector):
    """Vision-friendly LSCP wrapper that accepts detector specs.

    `detector_specs` can be:
    - model name strings (e.g. "core_ecod")
    - dict specs {"name": "...", "kwargs": {...}}
    - already-instantiated detector objects
    """

    def __init__(
        self,
        *,
        feature_extractor=None,
        contamination: float = 0.1,
        detector_specs: Sequence[object] | None = None,
        local_region_size: int = 30,
        local_max_features: float = 1.0,
        local_region_iterations: int = 20,
        local_min_features: float = 0.5,
        n_bins: int = 10,
        random_state: Optional[int] = None,
        **kwargs,
    ) -> None:
        if detector_specs is None:
            detector_specs = _default_lscp_detectors(
                contamination=float(contamination), random_state=random_state
            )
        if len(detector_specs) < 2:
            raise ValueError("VisionLSCPSpec requires at least 2 base detectors.")

        if kwargs:
            unknown = ", ".join(sorted(kwargs))
            raise TypeError(f"Unknown LSCP parameters: {unknown}")

        self._specs = list(detector_specs)
        self._core_kwargs = {
            "local_region_size": int(local_region_size),
            "local_max_features": float(local_max_features),
            "n_bins": int(n_bins),
            "random_state": random_state,
            "contamination": float(contamination),
            "local_region_iterations": int(local_region_iterations),
            "local_min_features": float(local_min_features),
        }
        super().__init__(contamination=contamination, feature_extractor=feature_extractor)

    def _build_detector(self):
        from pyimgano.models.ensemble_spec import resolve_model_specs

        dets = resolve_model_specs(self._specs, default_contamination=float(self.contamination))
        return CoreLSCP(detector_list=dets, **self._core_kwargs)
