# SPDX-FileCopyrightText: 2025 - 2026 Rahil Miten Doshi, Friedrich-Alexander-Universität Erlangen-Nürnberg
# SPDX-FileCopyrightText: 2026 Matthias Markl, Friedrich-Alexander-Universität Erlangen-Nürnberg
# SPDX-License-Identifier: BSD-3-Clause

import logging
import numpy as np
from typing import Union, Tuple, Dict
import pandas as pd
from pathlib import Path
from materforge.parsing.config.yaml_keys import FILE_PATH_KEY, DEPENDENCY_COLUMN_KEY, PROPERTY_COLUMN_KEY
from materforge.data.constants import ProcessingConstants, FileConstants

logger = logging.getLogger(__name__)


def load_property_data(file_config: Dict[str, Union[str, int]], header: bool = True) -> Tuple[np.ndarray, np.ndarray]:
    """Reads dependency and property data from a file.

    Args:
        file_config: Dictionary containing file configuration with keys:
            - file_path: Path to data file
            - dependency_column: Dependency column name or index
            - property_column: Property column name or index
        header: Whether the file contains a header row.
    Returns:
        Tuple of (dependency_array, property_array) as numpy arrays.
    Raises:
        FileNotFoundError: If the specified file does not exist.
        ValueError: If data validation fails or file format is unsupported.
        PermissionError: If the file cannot be read due to permissions.
    """
    _validate_file_config(file_config)
    file_path = Path(file_config[FILE_PATH_KEY])
    dep_col = file_config[DEPENDENCY_COLUMN_KEY]
    prop_col = file_config[PROPERTY_COLUMN_KEY]
    if not file_path.exists():
        raise FileNotFoundError(f"File not found: {file_path}")
    if not file_path.is_file():
        raise ValueError(f"Path is not a file: {file_path}")
    file_extension = file_path.suffix.lower()
    if file_extension not in FileConstants.SUPPORTED_EXTENSIONS:
        raise ValueError(f"Unsupported file type: '{file_extension}'. "
                         f"Supported types are: {FileConstants.SUPPORTED_EXTENSIONS}")
    file_size_mb = file_path.stat().st_size / (1024 * 1024)
    if file_size_mb > FileConstants.MAX_FILE_SIZE_MB:
        raise ValueError(f"File size ({file_size_mb:.2f} MB) exceeds the maximum limit "
                         f"of {FileConstants.MAX_FILE_SIZE_MB} MB.")
    try:
        if file_extension == '.xlsx':
            df = _read_excel_file(file_path, header, dep_col, prop_col)
        elif file_extension == '.csv':
            df = _read_csv_file(file_path, header, dep_col, prop_col)
        else:  # .txt files
            return _read_text_file(file_path, header, dep_col, prop_col)
    except PermissionError as e:
        raise PermissionError(f"Permission denied reading file {file_path}: {str(e)}") from e
    except Exception as e:
        raise ValueError(f"Error reading file {file_path}: {str(e)}") from e
    dep_array, prop_array = _extract_data_columns(df, dep_col, prop_col, str(file_path))
    dep_array, prop_array = _clean_and_validate_data(dep_array, prop_array, str(file_path))
    return dep_array, prop_array


def _validate_file_config(file_config: Dict) -> None:
    """Validates the file configuration dictionary."""
    required_keys = {FILE_PATH_KEY, DEPENDENCY_COLUMN_KEY, PROPERTY_COLUMN_KEY}
    missing_keys = required_keys - set(file_config.keys())
    if missing_keys:
        raise ValueError(f"Missing required configuration keys: {missing_keys}")
    if not file_config[FILE_PATH_KEY]:
        raise ValueError("File path cannot be empty")


def _read_excel_file(file_path: Path, header: bool, dep_col: Union[str, int],
                     prop_col: Union[str, int]) -> pd.DataFrame:
    """Reads an Excel file with proper error handling."""
    try:
        return pd.read_excel(
            file_path,
            header=0 if header else None,
            na_values=FileConstants.NA_VALUES,
            converters={
                dep_col: lambda x: pd.to_numeric(x, errors='coerce'),
                prop_col: lambda x: pd.to_numeric(x, errors='coerce')
            } if header else None
        )
    except ImportError as e:
        raise ValueError("Excel file support requires openpyxl. Install with: pip install openpyxl") from e


def _read_csv_file(file_path: Path, header: bool, dep_col: Union[str, int],
                   prop_col: Union[str, int]) -> pd.DataFrame:
    """Reads a CSV file with proper error handling."""
    return pd.read_csv(
        file_path,
        header=0 if header else None,
        na_values=FileConstants.NA_VALUES,
        encoding=FileConstants.DEFAULT_ENCODING,
        converters={
            dep_col: lambda x: pd.to_numeric(x, errors='coerce'),
            prop_col: lambda x: pd.to_numeric(x, errors='coerce')
        } if header else None
    )


def _read_text_file(file_path: Path, header: bool, dep_col: Union[str, int],
                    prop_col: Union[str, int]) -> Tuple[np.ndarray, np.ndarray]:
    """Reads a text file with comprehensive error handling."""
    try:
        if header:
            with open(file_path, 'r', encoding=FileConstants.DEFAULT_ENCODING) as f:
                header_line = f.readline().strip()
                if not header_line:
                    raise ValueError("File appears to be empty or has no header")
                column_names = header_line.split()
            data = pd.read_csv(
                file_path,
                sep=r'\s+',
                skiprows=1,
                header=None,
                na_values=FileConstants.NA_VALUES,
                encoding=FileConstants.DEFAULT_ENCODING,
                engine='python'
            ).values
            if data.size == 0:
                raise ValueError("No data found in file after header")
            dep_idx = _get_column_index(dep_col, column_names, data.shape[1], "dependency")
            prop_idx = _get_column_index(prop_col, column_names, data.shape[1], "property")
        else:
            data = pd.read_csv(
                file_path,
                sep=r'\s+',
                header=None,
                na_values=FileConstants.NA_VALUES,
                encoding=FileConstants.DEFAULT_ENCODING,
                engine='python'
            ).values
            if isinstance(dep_col, str) or isinstance(prop_col, str):
                raise ValueError("Column names specified, but file has no header row")
            dep_idx, prop_idx = dep_col, prop_col
        dep = data[:, dep_idx]
        prop = data[:, prop_idx]
        return dep, prop
    except pd.errors.EmptyDataError as e:
        raise ValueError(f"No data found in text file: {str(e)}") from e
    except Exception as e:
        raise ValueError(f"Error processing text file: {str(e)}") from e


def _extract_data_columns(df: pd.DataFrame, dep_col: Union[str, int], prop_col: Union[str, int],
                          file_path: str) -> Tuple[np.ndarray, np.ndarray]:
    """Extracts dependency and property columns from a DataFrame."""
    if df.empty:
        raise ValueError(f"No data found in file: {file_path}")
    dep_series = _extract_column(df, dep_col, "dependency", file_path)
    prop_series = _extract_column(df, prop_col, "property", file_path)
    dep_array, prop_array = _convert_to_numeric_arrays(dep_series, prop_series, file_path)
    return dep_array, prop_array


def _extract_column(df: pd.DataFrame, col_identifier: Union[str, int],
                    col_type: str, file_path: str) -> pd.Series:
    """Extracts a single column from a DataFrame with proper error handling."""
    if isinstance(col_identifier, str):
        if col_identifier not in df.columns:
            available_cols = ', '.join(df.columns.astype(str))
            raise ValueError(f"{col_type.capitalize()} column '{col_identifier}' not found in {file_path}. "
                             f"Available columns: {available_cols}")
        return df[col_identifier]
    else:
        if col_identifier >= len(df.columns):
            raise ValueError(f"{col_type.capitalize()} column index {col_identifier} out of bounds "
                             f"(file has {len(df.columns)} columns)")
        return df.iloc[:, col_identifier]


def _convert_to_numeric_arrays(dep_series: pd.Series, prop_series: pd.Series,
                               file_path: str) -> Tuple[np.ndarray, np.ndarray]:
    """Converts pandas Series to numeric numpy arrays with validation."""
    try:
        dep_numeric = pd.to_numeric(dep_series, errors='coerce')
        prop_numeric = pd.to_numeric(prop_series, errors='coerce')
        dep_array = np.asarray(dep_numeric, dtype=np.float64)
        prop_array = np.asarray(prop_numeric, dtype=np.float64)
        if not np.issubdtype(dep_array.dtype, np.number):
            raise ValueError(f"Dependency column could not be converted to numeric type. "
                             f"Got dtype: {dep_array.dtype}")
        if not np.issubdtype(prop_array.dtype, np.number):
            raise ValueError(f"Property column could not be converted to numeric type. "
                             f"Got dtype: {prop_array.dtype}")
        dep_nan_count = np.sum(np.isnan(dep_array))
        prop_nan_count = np.sum(np.isnan(prop_array))
        if dep_nan_count > 0:
            logger.warning("Dependency column has %d NaN values after conversion", dep_nan_count)
        if prop_nan_count > 0:
            logger.warning("Property column has %d NaN values after conversion", prop_nan_count)
        return dep_array, prop_array
    except Exception as e:
        raise ValueError(f"Failed to convert data to numeric format in {file_path}: {str(e)}") from e


def _get_column_index(col_identifier: Union[str, int], column_names: list,
                      num_cols: int, col_type: str) -> int:
    """Gets column index from name or validates a numeric index."""
    if isinstance(col_identifier, str):
        try:
            return column_names.index(col_identifier)
        except ValueError:
            raise ValueError(f"{col_type.capitalize()} column '{col_identifier}' not found. "
                             f"Available columns: {', '.join(column_names)}")
    else:
        if col_identifier >= num_cols:
            raise ValueError(f"{col_type.capitalize()} column index {col_identifier} "
                             f"out of bounds (file has {num_cols} columns)")
        return col_identifier


def _clean_and_validate_data(dep_array: np.ndarray, prop_array: np.ndarray,
                             file_path: str) -> Tuple[np.ndarray, np.ndarray]:
    """Validates data quality and removes missing values."""
    if len(dep_array) == 0 or len(prop_array) == 0:
        raise ValueError(f"No valid data found in file: {file_path}")
    dep_nan_mask = np.isnan(dep_array)
    prop_nan_mask = np.isnan(prop_array)
    any_nan_mask = dep_nan_mask | prop_nan_mask
    if np.any(any_nan_mask):
        nan_count = np.sum(any_nan_mask)
        total_count = len(dep_array)
        nan_percentage = (nan_count / total_count) * 100
        logger.warning("Found %d rows (%.1f%%) with missing values in %s",
                       nan_count, nan_percentage, file_path)
        if nan_percentage > ProcessingConstants.MAX_MISSING_VALUE_PERCENTAGE:
            raise ValueError(f"Too many missing values ({nan_percentage:.1f}%) in file: {file_path}. "
                             "Please clean the data or check the file format.")
        valid_mask = ~any_nan_mask
        dep_array = dep_array[valid_mask]
        prop_array = prop_array[valid_mask]
        logger.info("Removed %d rows with missing values. Remaining data points: %d",
                    nan_count, len(dep_array))
    if len(dep_array) < ProcessingConstants.MIN_DATA_POINTS:
        raise ValueError(f"Insufficient valid data points ({len(dep_array)}) after cleaning. "
                         f"Minimum required: {ProcessingConstants.MIN_DATA_POINTS}")
    dep_array, prop_array = _remove_duplicate_entries(dep_array, prop_array)
    dep_array, prop_array = _ensure_ascending_order(dep_array, prop_array)
    return dep_array, prop_array


def _remove_duplicate_entries(dep_array: np.ndarray, prop_array: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
    """Removes duplicate dependency entries, keeping the first occurrence."""
    unique_dep, unique_indices = np.unique(dep_array, return_index=True)
    if len(unique_dep) < len(dep_array):
        duplicate_count = len(dep_array) - len(unique_dep)
        logger.warning("Found %d duplicate dependency entries. Removing duplicates.", duplicate_count)
        unique_indices = np.sort(unique_indices)
        dep_array = dep_array[unique_indices]
        prop_array = prop_array[unique_indices]
    return dep_array, prop_array


def _ensure_ascending_order(dep_array: np.ndarray, prop_array: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
    """Ensures the dependency array is in ascending order."""
    if not np.all(np.diff(dep_array) > 0):
        logger.info("Sorting data by dependency variable")
        sort_indices = np.argsort(dep_array)
        dep_array = dep_array[sort_indices]
        prop_array = prop_array[sort_indices]
    return dep_array, prop_array
