"""Parse RDS files containing Seurat objects into SeuratObject models."""

from __future__ import annotations

import warnings
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd
from scipy import sparse

from readseurat._logging import logger
from readseurat.convert.errors import SeuratFormatError
from readseurat.convert.seurat.model import Assay, Graph, Reduction, SeuratObject
from readseurat.rdata import read_rds


def parse_rds_to_seurat_object(
    rds_path: str | Path,
    *,
    suppress_warnings: bool = True,
) -> SeuratObject:
    """Parse an RDS file containing a Seurat object into a SeuratObject model.

    This function reads an RDS file containing a Seurat object and converts it
    into the h5seurat SeuratObject data model. It handles both Seurat v4 and v5
    formats, with appropriate version-specific parsing logic.

    Args:
        rds_path: Path to the RDS file containing a Seurat object.
        suppress_warnings: Whether to suppress rdata parsing warnings. Default is True.

    Returns:
        A SeuratObject instance containing all parsed data from the RDS file.

    Raises:
        SeuratFormatError: If the RDS file doesn't contain a valid Seurat object
            or if required fields are missing.
        FileNotFoundError: If the RDS file doesn't exist.

    Examples:
        >>> seurat_obj = parse_rds_to_seurat_object("pbmc3k.rds")
        >>> print(seurat_obj.version)
        5.0.0
        >>> print(seurat_obj.active_assay)
        RNA
        >>> print(list(seurat_obj.assays.keys()))
        ['RNA']
    """
    rds_path = Path(rds_path)
    if not rds_path.exists():
        raise FileNotFoundError(f"RDS file not found: {rds_path}")

    # Read the RDS file using rdata
    if suppress_warnings:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            rds_obj = read_rds(str(rds_path))
    else:
        rds_obj = read_rds(str(rds_path))

    # Validate that we have a Seurat object
    if not hasattr(rds_obj, "__dict__"):
        raise SeuratFormatError("RDS file does not contain a valid Seurat object (no __dict__ attribute)")

    obj_dict = rds_obj.__dict__

    # Extract version information
    version = _extract_version(obj_dict)

    # Parse based on version
    major_version = int(version.split(".")[0]) if version else 5

    # Extract basic metadata
    project = _extract_project_name(obj_dict)
    active_assay = _extract_active_assay(obj_dict)
    cell_names = _extract_cell_names(rds_obj, obj_dict)
    meta_data = _extract_meta_data(obj_dict, cell_names)

    # Extract assays (version-dependent)
    assays = _extract_assays(rds_obj, obj_dict, major_version)

    # Extract reductions
    reductions = _extract_reductions(obj_dict)

    # Extract graphs
    graphs = _extract_graphs(obj_dict)

    # Extract images, misc, tools, and commands
    images = _extract_images(obj_dict)
    misc = _extract_misc(obj_dict)
    tools = _extract_tools(obj_dict)
    commands = _extract_commands(obj_dict)

    return SeuratObject(
        project=project,
        active_assay=active_assay,
        version=version,
        cell_names=cell_names,
        meta_data=meta_data,
        assays=assays,
        reductions=reductions,
        graphs=graphs,
        images=images,
        misc=misc,
        tools=tools,
        commands=commands,
    )


def _extract_version(obj_dict: dict[str, Any]) -> str:
    """Extract Seurat version from the object."""
    version = obj_dict.get("version")
    if version is None:
        warnings.warn("No version found in Seurat object, assuming v5.0.0")
        return "5.0.0"

    # Handle different version formats
    # Often it's a list containing a numpy array: [array([5, 0, 0])]
    if isinstance(version, list) and len(version) > 0:
        version = version[0]

    if isinstance(version, np.ndarray):
        # Version is a numpy array
        version_list = version.tolist()
        version_str = ".".join(str(int(v)) for v in version_list)
        return version_str
    elif isinstance(version, (list, tuple)):
        # Version is a list or tuple
        version_str = ".".join(str(v) for v in version)
        return version_str
    elif hasattr(version, "__iter__") and not isinstance(version, str):
        # Version might be another iterable
        try:
            version_str = ".".join(str(v) for v in version)
            return version_str
        except (TypeError, ValueError):
            pass

    version_str = str(version)
    # Clean up if it's a string representation of an array like "[5 0 0]"
    if version_str.startswith("[") and version_str.endswith("]"):
        # Parse the string representation
        inner = version_str.strip("[]").strip()
        parts = [p.strip() for p in inner.split()]
        version_str = ".".join(parts)

    return version_str


def _extract_project_name(obj_dict: dict[str, Any]) -> str:
    """Extract project name from the object."""
    project = obj_dict.get("project.name")
    if project is None:
        warnings.warn("No project.name found in Seurat object, using 'SeuratProject'")
        return "SeuratProject"

    # Handle list/array formats
    if isinstance(project, (list, tuple)) and len(project) > 0:
        project = project[0]
    if isinstance(project, np.ndarray):
        project = project.item() if project.size == 1 else str(project[0])

    return str(project)


def _extract_active_assay(obj_dict: dict[str, Any]) -> str:
    """Extract active assay name from the object."""
    active_assay = obj_dict.get("active.assay")
    if active_assay is None:
        # Try to get first assay name
        assays = obj_dict.get("assays", {})
        if assays:
            active_assay = list(assays.keys())[0]
            warnings.warn(f"No active.assay found, using first assay: {active_assay}")
        else:
            active_assay = "RNA"
            warnings.warn("No active.assay found, defaulting to 'RNA'")

    # Handle list/array formats
    if isinstance(active_assay, (list, tuple)) and len(active_assay) > 0:
        active_assay = active_assay[0]
    if isinstance(active_assay, np.ndarray):
        active_assay = active_assay.item() if active_assay.size == 1 else str(active_assay[0])

    return str(active_assay)


def _extract_cell_names(rds_obj: Any, obj_dict: dict[str, Any]) -> np.ndarray:
    """Extract cell names from the Seurat object."""
    # Try multiple locations for cell names

    # First, try the assays
    assays: dict[str, Any] = obj_dict.get("assays", {})
    if assays:
        first_assay_name = list(assays.keys())[0]
        first_assay = rds_obj.assays[first_assay_name]

        if hasattr(first_assay, "__dict__"):
            assay_dict = first_assay.__dict__
            # Seurat v5: cells in assay
            cells = assay_dict.get("cells")
            if cells is not None:
                if isinstance(cells, dict) and "dim_0" in cells:
                    cell_list = cells["dim_0"]
                    # Handle if it's an array of arrays or nested structure
                    if isinstance(cell_list, (list, np.ndarray)):
                        # Flatten if needed and convert to strings
                        if len(cell_list) > 0 and isinstance(cell_list[0], (list, np.ndarray)):
                            # Nested structure - this shouldn't happen, fall through
                            pass
                        else:
                            return np.array([str(c) for c in cell_list])

    # Fallback: extract from meta.data row names
    meta_data = obj_dict.get("meta.data")
    if meta_data is not None and isinstance(meta_data, pd.DataFrame):
        if len(meta_data.index) > 0:
            return np.array(meta_data.index.tolist())

    raise SeuratFormatError("Could not extract cell names from Seurat object")


def _extract_meta_data(obj_dict: dict[str, Any], cell_names: np.ndarray) -> pd.DataFrame:
    """Extract cell metadata from the object."""
    meta_data = obj_dict.get("meta.data")

    if meta_data is None:
        warnings.warn("No meta.data found, creating empty DataFrame")
        return pd.DataFrame(index=cell_names)

    if not isinstance(meta_data, pd.DataFrame):
        warnings.warn("meta.data is not a DataFrame, creating empty DataFrame")
        return pd.DataFrame(index=cell_names)

    # Ensure index matches cell names
    if len(meta_data) != len(cell_names):
        warnings.warn(
            f"meta.data length ({len(meta_data)}) doesn't match cell_names "
            f"length ({len(cell_names)}), using cell_names as index"
        )
        meta_data = meta_data.copy()
        meta_data.index = cell_names

    return meta_data


def _extract_assays(
    rds_obj: Any,
    obj_dict: dict[str, Any],
    major_version: int,
) -> dict[str, Assay]:
    """Extract assays from the Seurat object."""
    assays_dict: dict[str, Any] = obj_dict.get("assays", {})

    if not assays_dict:
        raise SeuratFormatError("No assays found in Seurat object")

    parsed_assays: dict[str, Assay] = {}

    for assay_name in assays_dict.keys():
        assay_obj = rds_obj.assays[assay_name]

        if major_version >= 5:
            parsed_assays[assay_name] = _parse_assay_v5(assay_name, assay_obj)
        else:
            parsed_assays[assay_name] = _parse_assay_v4(assay_name, assay_obj)

    return parsed_assays


def _parse_assay_v5(assay_name: str, assay_obj: Any) -> Assay:
    """Parse a Seurat v5 Assay5 object."""
    if not hasattr(assay_obj, "__dict__"):
        raise SeuratFormatError(f"Assay {assay_name} has no __dict__ attribute")

    assay_dict: dict = assay_obj.__dict__

    # Extract features
    features = _extract_features(assay_dict)

    # Extract key (default pattern for assay)
    key = assay_dict.get("key", f"{assay_name.lower()}_")
    if not isinstance(key, str):
        key = str(key)

    # Extract layers (v5 uses layers instead of separate slots)
    layers = assay_dict.get("layers", {})

    # Extract data matrix (primary matrix)
    data = _extract_layer_matrix(layers, "data", "counts")
    if data is None:
        raise SeuratFormatError(f"Assay {assay_name} has no data or counts layer")

    # Extract counts if separate from data
    counts = _extract_layer_matrix(layers, "counts")

    # Extract scale.data
    scale_data = _extract_scale_data(layers)

    # Extract variable features
    variable_features = _extract_variable_features(assay_dict)

    # Extract feature metadata
    meta_features = _extract_meta_features(assay_dict, features)

    # scaled features (features used in scaling)
    scaled_features = None
    if scale_data is not None:
        # Try to get from metadata or infer
        scaled_feats = assay_dict.get("scaled.features")
        if scaled_feats is not None and hasattr(scaled_feats, "__iter__"):
            scaled_features = np.array(list(scaled_feats))

    # Misc data
    misc = assay_dict.get("misc", {})
    if not isinstance(misc, dict):
        misc = {}

    return Assay(
        name=assay_name,
        features=features,
        data=data,
        key=key,
        counts=counts,
        scale_data=scale_data,
        scaled_features=scaled_features,
        meta_features=meta_features,
        variable_features=variable_features,
        misc=misc,
    )


def _parse_assay_v4(assay_name: str, assay_obj: Any) -> Assay:
    """Parse a Seurat v4 Assay object."""
    if not hasattr(assay_obj, "__dict__"):
        raise SeuratFormatError(f"Assay {assay_name} has no __dict__ attribute")

    assay_dict: dict = assay_obj.__dict__

    # Extract features
    features = _extract_features(assay_dict)

    # Extract key
    key = assay_dict.get("key", f"{assay_name.lower()}_")
    if not isinstance(key, str):
        key = str(key)

    # In v4, data matrices are in separate slots
    counts = _extract_matrix_slot(assay_dict, "counts")
    data = _extract_matrix_slot(assay_dict, "data")

    if data is None and counts is None:
        raise SeuratFormatError(f"Assay {assay_name} has no data or counts")

    # Use data if available, otherwise counts
    primary_data = data if data is not None else counts
    assert primary_data is not None, "primary_data should not be None after check"

    # Extract scale.data
    scale_data_raw = _extract_matrix_slot(assay_dict, "scale.data")
    # Convert sparse scale_data to dense array
    scale_data: np.ndarray | None = None
    if scale_data_raw is not None:
        if sparse.issparse(scale_data_raw):
            scale_data = scale_data_raw.toarray()  # type: ignore[attr-defined]
        else:
            scale_data = np.asarray(scale_data_raw)

    # Extract variable features
    variable_features = _extract_variable_features(assay_dict)

    # Extract feature metadata
    meta_features = _extract_meta_features(assay_dict, features)

    # scaled features
    scaled_features = None
    if scale_data is not None:
        scaled_feats = assay_dict.get("scaled.features")
        if scaled_feats is not None and hasattr(scaled_feats, "__iter__"):
            scaled_features = np.array(list(scaled_feats))

    # Misc data
    misc = assay_dict.get("misc", {})
    if not isinstance(misc, dict):
        misc = {}

    return Assay(
        name=assay_name,
        features=features,
        data=primary_data,
        key=key,
        counts=counts,
        scale_data=scale_data,
        scaled_features=scaled_features,
        meta_features=meta_features,
        variable_features=variable_features,
        misc=misc,
    )


def _extract_features(assay_dict: dict[str, Any]) -> np.ndarray:
    """Extract feature names from an assay."""
    features = assay_dict.get("features")

    if features is None:
        # Try alternative names
        features = assay_dict.get("feature.names")

    if features is None:
        raise SeuratFormatError("No features found in assay")

    # Handle different feature formats
    if isinstance(features, dict):
        # Seurat v5 format: {"dim_0": [...]}
        if "dim_0" in features:
            features = features["dim_0"]
        else:
            raise SeuratFormatError("Features dict has no 'dim_0' key")

    if hasattr(features, "__iter__") and not isinstance(features, str):
        return np.array(list(features))

    raise SeuratFormatError(f"Invalid features format: {type(features)}")


def _extract_layer_matrix(
    layers: dict[str, Any],
    *layer_names: str,
) -> sparse.spmatrix | np.ndarray | None:
    """Extract a matrix from Seurat v5 layers, trying multiple layer names."""
    for layer_name in layer_names:
        layer = layers.get(layer_name)
        if layer is not None:
            # Check if it's a dgCMatrix (sparse)
            if hasattr(layer, "x") and hasattr(layer, "i") and hasattr(layer, "p"):
                return _convert_dgc_matrix(layer)

            # Check if it's already a sparse matrix
            if sparse.issparse(layer):
                return layer

            # Check if it's a numpy array (dense)
            if isinstance(layer, np.ndarray):
                return layer

            # Try to convert to numpy array
            try:
                return np.array(layer)
            except (ValueError, TypeError):
                warnings.warn(f"Could not convert layer '{layer_name}' to matrix")
                continue
    return None


def _extract_scale_data(layers: dict[str, Any]) -> np.ndarray | None:
    """Extract scale.data from layers."""
    scale_data = layers.get("scale.data")
    if scale_data is None:
        return None

    # scale.data is usually a dense matrix
    if isinstance(scale_data, np.ndarray):
        return scale_data

    # Might be sparse, convert to dense
    if sparse.issparse(scale_data):
        return scale_data.toarray()

    # Might be dgCMatrix
    if hasattr(scale_data, "x") and hasattr(scale_data, "i") and hasattr(scale_data, "p"):
        sparse_mat = _convert_dgc_matrix(scale_data)
        return sparse_mat.toarray()

    return np.array(scale_data)


def _extract_matrix_slot(
    assay_dict: dict[str, Any],
    slot_name: str,
) -> sparse.spmatrix | np.ndarray | None:
    """Extract a matrix from an assay slot (v4 format)."""
    matrix = assay_dict.get(slot_name)

    if matrix is None:
        return None

    # Check if it's a dgCMatrix
    if hasattr(matrix, "x") and hasattr(matrix, "i") and hasattr(matrix, "p"):
        return _convert_dgc_matrix(matrix)

    # Check if it's already a sparse matrix
    if sparse.issparse(matrix):
        return matrix

    # Check if it's a numpy array
    if isinstance(matrix, np.ndarray):
        return matrix

    # Try to convert to numpy array
    try:
        return np.array(matrix)
    except (ValueError, TypeError):
        warnings.warn(f"Could not convert {slot_name} to matrix, returning None")
        return None


def _convert_dgc_matrix(dgc_matrix: Any) -> sparse.csc_matrix:
    """Convert an R dgCMatrix to scipy sparse CSC matrix."""
    # If it's already a sparse matrix (from the constructor), return it
    if sparse.issparse(dgc_matrix):
        if not sparse.isspmatrix_csc(dgc_matrix):
            return sparse.csc_matrix(dgc_matrix)
        return dgc_matrix

    # Legacy path: if it's an S4 object with x, i, p attributes
    if hasattr(dgc_matrix, "x") and hasattr(dgc_matrix, "i") and hasattr(dgc_matrix, "p"):
        data = np.array(dgc_matrix.x)
        indices = np.array(dgc_matrix.i, dtype=np.int32)
        indptr = np.array(dgc_matrix.p, dtype=np.int32)

        # Get shape
        if hasattr(dgc_matrix, "Dim"):
            shape = tuple(dgc_matrix.Dim)
        else:
            # Infer shape
            n_cols = len(indptr) - 1
            n_rows = int(indices.max()) + 1 if len(indices) > 0 else 0
            shape = (n_rows, n_cols)

        return sparse.csc_matrix((data, indices, indptr), shape=shape)

    # If it's a dense array that came from a bad conversion, issue a warning and convert
    if isinstance(dgc_matrix, np.ndarray):
        import warnings

        warnings.warn(
            "dgCMatrix was converted to dense array during RDS parsing. "
            "Converting back to sparse, but this is inefficient. "
            "This is a known issue with the rdata library.",
            stacklevel=2,
        )
        return sparse.csc_matrix(dgc_matrix)

    # Final fallback
    logger.debug("dgc_matrix type: %s", type(dgc_matrix))
    logger.debug("issparse? %s", sparse.issparse(dgc_matrix))
    logger.debug("Has 'x'? %s", hasattr(dgc_matrix, "x"))
    logger.debug("Has 'i'? %s", hasattr(dgc_matrix, "i"))
    logger.debug("Has 'p'? %s", hasattr(dgc_matrix, "p"))
    raise SeuratFormatError("Invalid dgCMatrix format")


def _extract_variable_features(assay_dict: dict[str, Any]) -> np.ndarray | None:
    """Extract variable features from an assay."""
    var_features = assay_dict.get("var.features")

    if var_features is None:
        var_features = assay_dict.get("variable.features")

    if var_features is None:
        return None

    if hasattr(var_features, "__iter__") and not isinstance(var_features, str):
        return np.array(list(var_features))

    return None


def _extract_meta_features(
    assay_dict: dict[str, Any],
    features: np.ndarray,
) -> pd.DataFrame | None:
    """Extract feature metadata from an assay."""
    meta_features = assay_dict.get("meta.features")

    if meta_features is None:
        meta_features = assay_dict.get("meta.data")

    if meta_features is None:
        return None

    if isinstance(meta_features, pd.DataFrame):
        return meta_features

    return None


def _extract_reductions(obj_dict: dict[str, Any]) -> dict[str, Reduction]:
    """Extract dimensional reductions from the Seurat object."""
    reductions_dict: dict = obj_dict.get("reductions", {})

    if not reductions_dict:
        return {}

    parsed_reductions: dict[str, Reduction] = {}

    for reduc_name, reduc_obj in reductions_dict.items():
        if not hasattr(reduc_obj, "__dict__"):
            warnings.warn(f"Reduction {reduc_name} has no __dict__, skipping")
            continue

        reduc_dict: dict = reduc_obj.__dict__

        # Extract cell embeddings
        cell_embeddings = reduc_dict.get("cell.embeddings")
        if cell_embeddings is None:
            warnings.warn(f"Reduction {reduc_name} has no cell.embeddings, skipping")
            continue

        cell_embeddings = np.asarray(cell_embeddings)

        # Extract feature loadings
        feature_loadings = reduc_dict.get("feature.loadings")
        if feature_loadings is not None:
            feature_loadings = np.asarray(feature_loadings)

        # Extract projected loadings
        feature_loadings_projected = reduc_dict.get("feature.loadings.projected")
        if feature_loadings_projected is not None:
            feature_loadings_projected = np.asarray(feature_loadings_projected)

        # Extract key
        key = reduc_dict.get("key", f"{reduc_name}_")
        if not isinstance(key, str):
            key = str(key)

        # Extract active assay
        active_assay = reduc_dict.get("assay.used", [])
        if isinstance(active_assay, str):
            active_assay = [active_assay]
        elif not isinstance(active_assay, list):
            active_assay = list(active_assay) if hasattr(active_assay, "__iter__") else []

        # Extract global flag
        global_flag = reduc_dict.get("global", True)
        if not isinstance(global_flag, bool):
            global_flag = bool(global_flag)

        # Extract misc and jackstraw
        misc = reduc_dict.get("misc", {})
        if not isinstance(misc, dict):
            misc = {}

        jackstraw = reduc_dict.get("jackstraw")
        if jackstraw is not None and not isinstance(jackstraw, dict):
            jackstraw = {}

        parsed_reductions[reduc_name] = Reduction(
            name=reduc_name,
            active_assay=active_assay,
            key=key,
            global_=global_flag,
            cell_embeddings=cell_embeddings,
            feature_loadings=feature_loadings,
            feature_loadings_projected=feature_loadings_projected,
            misc=misc,
            jackstraw=jackstraw,
        )

    return parsed_reductions


def _extract_graphs(obj_dict: dict[str, Any]) -> dict[str, Graph]:
    """Extract graphs from the Seurat object."""
    graphs_dict: dict = obj_dict.get("graphs", {})

    if not graphs_dict:
        return {}

    parsed_graphs: dict[str, Graph] = {}

    for graph_name, graph_obj in graphs_dict.items():
        # Convert numpy string to regular string
        graph_name = str(graph_name)

        # Graph might be a sparse matrix directly or an object
        matrix = None
        assay_used = None

        if sparse.issparse(graph_obj):
            matrix = graph_obj
        elif hasattr(graph_obj, "x") and hasattr(graph_obj, "i") and hasattr(graph_obj, "p"):
            # dgCMatrix - either direct attributes or in __dict__
            matrix = _convert_dgc_matrix(graph_obj)
            # Try to get assay.used if available
            if hasattr(graph_obj, "__dict__"):
                assay_used = graph_obj.__dict__.get("assay.used")
            elif hasattr(graph_obj, "assay.used"):
                assay_used = getattr(graph_obj, "assay.used", None)
        elif hasattr(graph_obj, "__dict__"):
            graph_dict = graph_obj.__dict__
            # Check if __dict__ has dgCMatrix attributes
            if "x" in graph_dict and "i" in graph_dict and "p" in graph_dict:
                # Create a simple object with these attributes for conversion
                class DgcHolder:
                    pass

                holder = DgcHolder()
                holder.x = graph_dict["x"]  # type: ignore[attr-defined]
                holder.i = graph_dict["i"]  # type: ignore[attr-defined]
                holder.p = graph_dict["p"]  # type: ignore[attr-defined]
                holder.Dim = graph_dict.get("Dim")  # type: ignore[attr-defined]
                matrix = _convert_dgc_matrix(holder)
            else:
                # Try to extract matrix from common keys
                for key in ["graph", "matrix", "data"]:
                    if key in graph_dict:
                        candidate = graph_dict[key]
                        if sparse.issparse(candidate):
                            matrix = candidate
                            break
                        elif hasattr(candidate, "x"):
                            # dgCMatrix
                            matrix = _convert_dgc_matrix(candidate)
                            break

            assay_used = graph_dict.get("assay.used")
            if assay_used is not None:
                assay_used = str(assay_used)

        if matrix is None:
            warnings.warn(f"Could not extract matrix from graph {graph_name}, skipping")
            continue

        # Ensure it's a sparse matrix
        if not sparse.issparse(matrix):
            matrix = sparse.csc_matrix(matrix)

        parsed_graphs[graph_name] = Graph(
            name=graph_name,
            matrix=matrix,
            assay_used=assay_used,
        )

    return parsed_graphs


def _extract_images(obj_dict: dict[str, Any]) -> dict[str, Any]:
    """Extract spatial images from the Seurat object."""
    images = obj_dict.get("images", {})

    if not isinstance(images, dict):
        return {}

    return images


def _extract_misc(obj_dict: dict[str, Any]) -> dict[str, Any]:
    """Extract misc data from the Seurat object."""
    misc = obj_dict.get("misc", {})

    if not isinstance(misc, dict):
        return {}

    return misc


def _extract_tools(obj_dict: dict[str, Any]) -> dict[str, Any]:
    """Extract tools data from the Seurat object."""
    tools = obj_dict.get("tools", {})

    if not isinstance(tools, dict):
        return {}

    return tools


def _extract_commands(obj_dict: dict[str, Any]) -> dict[str, Any]:
    """Extract commands data from the Seurat object."""
    commands = obj_dict.get("commands", {})

    if not isinstance(commands, dict):
        return {}

    return commands
