"""Data validation utilities for jbqlab.

This module provides functions to validate input data before backtesting.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

if TYPE_CHECKING:
    from collections.abc import Sequence

__all__ = [
    "ValidationResult",
    "validate_dataframe",
    "validate_ohlcv",
    "check_data_quality",
    "fill_missing_dates",
    "detect_outliers",
]


@dataclass
class ValidationResult:
    """Result of data validation."""

    is_valid: bool
    errors: list[str]
    warnings: list[str]

    def __str__(self) -> str:
        """Human-readable validation summary."""
        if self.is_valid and not self.warnings:
            return "✓ Data validation passed"

        lines = []
        if self.errors:
            lines.append(f"✗ {len(self.errors)} error(s):")
            for e in self.errors:
                lines.append(f"  - {e}")
        if self.warnings:
            lines.append(f"⚠ {len(self.warnings)} warning(s):")
            for w in self.warnings:
                lines.append(f"  - {w}")

        return "\n".join(lines)


def validate_dataframe(
    df: pd.DataFrame,
    required_columns: Sequence[str] = ("close",),
    min_rows: int = 2,
) -> ValidationResult:
    """Validate a DataFrame for backtesting.

    Args:
        df: DataFrame to validate.
        required_columns: Columns that must be present.
        min_rows: Minimum number of rows required.

    Returns:
        ValidationResult with errors and warnings.
    """
    errors: list[str] = []
    warnings: list[str] = []

    # Check if it's a DataFrame
    if not isinstance(df, pd.DataFrame):
        errors.append(f"Expected DataFrame, got {type(df).__name__}")
        return ValidationResult(is_valid=False, errors=errors, warnings=warnings)

    # Check for empty DataFrame
    if df.empty:
        errors.append("DataFrame is empty")
        return ValidationResult(is_valid=False, errors=errors, warnings=warnings)

    # Check minimum rows
    if len(df) < min_rows:
        errors.append(f"DataFrame has {len(df)} rows, minimum {min_rows} required")

    # Check required columns
    missing_cols = set(required_columns) - set(df.columns)
    if missing_cols:
        errors.append(f"Missing required columns: {sorted(missing_cols)}")

    # Check index is datetime-like
    if not isinstance(df.index, pd.DatetimeIndex):
        warnings.append("Index is not DatetimeIndex - consider converting")

    # Check for duplicate indices
    if df.index.duplicated().any():
        n_dupes = df.index.duplicated().sum()
        errors.append(f"Found {n_dupes} duplicate index values")

    # Check index is sorted
    if not df.index.is_monotonic_increasing:
        warnings.append("Index is not sorted in ascending order")

    # Check for NaN values in required columns
    for col in required_columns:
        if col in df.columns:
            n_nan = df[col].isna().sum()
            if n_nan > 0:
                pct = 100 * n_nan / len(df)
                warnings.append(f"Column '{col}' has {n_nan} NaN values ({pct:.1f}%)")

    is_valid = len(errors) == 0
    return ValidationResult(is_valid=is_valid, errors=errors, warnings=warnings)


def validate_ohlcv(df: pd.DataFrame) -> ValidationResult:
    """Validate OHLCV data for consistency.

    Checks:
    - Required columns present
    - High >= Low
    - High >= Open, Close
    - Low <= Open, Close
    - Volume non-negative (if present)

    Args:
        df: DataFrame with OHLCV data.

    Returns:
        ValidationResult with errors and warnings.
    """
    # First do basic validation
    result = validate_dataframe(df, required_columns=["open", "high", "low", "close"])
    errors = list(result.errors)
    warnings = list(result.warnings)

    if not result.is_valid:
        return ValidationResult(is_valid=False, errors=errors, warnings=warnings)

    # Check OHLC relationships
    invalid_hl = df["high"] < df["low"]
    if invalid_hl.any():
        n = invalid_hl.sum()
        errors.append(f"{n} rows have High < Low")

    invalid_ho = df["high"] < df["open"]
    if invalid_ho.any():
        n = invalid_ho.sum()
        warnings.append(f"{n} rows have High < Open")

    invalid_hc = df["high"] < df["close"]
    if invalid_hc.any():
        n = invalid_hc.sum()
        warnings.append(f"{n} rows have High < Close")

    invalid_lo = df["low"] > df["open"]
    if invalid_lo.any():
        n = invalid_lo.sum()
        warnings.append(f"{n} rows have Low > Open")

    invalid_lc = df["low"] > df["close"]
    if invalid_lc.any():
        n = invalid_lc.sum()
        warnings.append(f"{n} rows have Low > Close")

    # Check volume if present
    if "volume" in df.columns:
        neg_vol = df["volume"] < 0
        if neg_vol.any():
            n = neg_vol.sum()
            errors.append(f"{n} rows have negative volume")

        zero_vol = df["volume"] == 0
        if zero_vol.any():
            n = zero_vol.sum()
            warnings.append(f"{n} rows have zero volume")

    is_valid = len(errors) == 0
    return ValidationResult(is_valid=is_valid, errors=errors, warnings=warnings)


@dataclass
class DataQualityReport:
    """Report on data quality metrics."""

    n_rows: int
    n_columns: int
    date_range: tuple[str, str]
    missing_dates: int
    nan_counts: dict[str, int]
    outlier_counts: dict[str, int]
    summary: str


def check_data_quality(
    df: pd.DataFrame,
    _price_col: str = "close",
    outlier_std: float = 4.0,
) -> DataQualityReport:
    """Generate a data quality report.

    Args:
        df: DataFrame to analyze.
        price_col: Column to use for return calculations.
        outlier_std: Standard deviations for outlier detection.

    Returns:
        DataQualityReport with quality metrics.
    """
    n_rows = len(df)
    n_columns = len(df.columns)

    # Date range
    if isinstance(df.index, pd.DatetimeIndex):
        start = df.index.min().strftime("%Y-%m-%d")
        end = df.index.max().strftime("%Y-%m-%d")
    else:
        start = str(df.index[0])
        end = str(df.index[-1])

    # Missing dates (business days)
    missing_dates = 0
    if isinstance(df.index, pd.DatetimeIndex):
        expected = pd.bdate_range(df.index.min(), df.index.max())
        actual = df.index.normalize()
        missing_dates = len(expected) - len(actual.intersection(expected))

    # NaN counts
    nan_counts = {col: int(df[col].isna().sum()) for col in df.columns}

    # Outlier counts (using returns)
    outlier_counts: dict[str, int] = {}
    for col in df.select_dtypes(include=[np.number]).columns:
        returns = df[col].pct_change().dropna()
        if len(returns) > 0:
            mean = returns.mean()
            std = returns.std()
            if std > 0:
                outliers = ((returns - mean).abs() > outlier_std * std).sum()
                outlier_counts[col] = int(outliers)

    # Summary
    lines = [
        "Data Quality Report",
        f"{'=' * 40}",
        f"Rows: {n_rows:,}",
        f"Columns: {n_columns}",
        f"Date Range: {start} to {end}",
        f"Missing Business Days: {missing_dates}",
        "",
        "NaN Values:",
    ]
    for col, cnt in nan_counts.items():
        if cnt > 0:
            lines.append(f"  {col}: {cnt}")
    if all(cnt == 0 for cnt in nan_counts.values()):
        lines.append("  None")

    lines.append("")
    lines.append(f"Outliers (>{outlier_std}σ returns):")
    for col, cnt in outlier_counts.items():
        if cnt > 0:
            lines.append(f"  {col}: {cnt}")
    if all(cnt == 0 for cnt in outlier_counts.values()):
        lines.append("  None")

    summary = "\n".join(lines)

    return DataQualityReport(
        n_rows=n_rows,
        n_columns=n_columns,
        date_range=(start, end),
        missing_dates=missing_dates,
        nan_counts=nan_counts,
        outlier_counts=outlier_counts,
        summary=summary,
    )


def fill_missing_dates(
    df: pd.DataFrame,
    method: str = "ffill",
    freq: str = "B",  # Business day
) -> pd.DataFrame:
    """Fill missing dates in a DataFrame.

    Args:
        df: DataFrame with DatetimeIndex.
        method: Fill method ('ffill', 'bfill', or 'interpolate').
        freq: Frequency for date range ('B' for business day, 'D' for daily).

    Returns:
        DataFrame with missing dates filled.

    Raises:
        ValueError: If index is not DatetimeIndex or invalid method.
    """
    if not isinstance(df.index, pd.DatetimeIndex):
        raise ValueError("DataFrame must have DatetimeIndex")

    if method not in ("ffill", "bfill", "interpolate"):
        raise ValueError(f"Invalid method: {method}")

    # Create complete date range
    full_range = pd.date_range(df.index.min(), df.index.max(), freq=freq)

    # Reindex
    df_filled = df.reindex(full_range)

    # Fill missing values
    if method == "interpolate":
        df_filled = df_filled.interpolate(method="linear")
    elif method == "ffill":
        df_filled = df_filled.ffill()
    else:
        df_filled = df_filled.bfill()

    return df_filled


def detect_outliers(
    series: pd.Series,
    method: str = "zscore",
    threshold: float = 3.0,
) -> pd.Series:
    """Detect outliers in a series.

    Args:
        series: Series to analyze.
        method: Detection method ('zscore', 'iqr', 'mad').
        threshold: Threshold for outlier detection.
            - zscore: number of standard deviations
            - iqr: multiplier for IQR
            - mad: multiplier for median absolute deviation

    Returns:
        Boolean Series indicating outliers.

    Raises:
        ValueError: If invalid method.
    """
    if method == "zscore":
        mean = series.mean()
        std = series.std()
        if std == 0:
            return pd.Series(False, index=series.index)
        z = (series - mean).abs() / std
        return z > threshold

    elif method == "iqr":
        q1 = series.quantile(0.25)
        q3 = series.quantile(0.75)
        iqr = q3 - q1
        lower = q1 - threshold * iqr
        upper = q3 + threshold * iqr
        return (series < lower) | (series > upper)

    elif method == "mad":
        median = series.median()
        mad = (series - median).abs().median()
        if mad == 0:
            return pd.Series(False, index=series.index)
        modified_z = 0.6745 * (series - median) / mad
        return modified_z.abs() > threshold

    else:
        raise ValueError(f"Invalid method: {method}. Use 'zscore', 'iqr', or 'mad'")
