"""Post-load quality checks for ETL pipelines.

Validates loaded data against configurable quality rules including
row count, null rate, uniqueness, and custom expressions.

Usage:
    checker = QualityChecker([
        {"type": "row_count", "min": 100, "severity": "fail"},
        {"type": "null_rate", "fields": ["id"], "max_null_percent": 0},
    ])
    report = checker.run(loaded_records, load_result)
    if not report.passed:
        for failure in report.failures:
            print(failure.message)
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from pycharter.etl_generator.result import LoadResult

logger = logging.getLogger(__name__)


# =============================================================================
# ENUMS AND DATA CLASSES
# =============================================================================


class QualityCheckSeverity(Enum):
    """Severity level for quality check failures."""

    FAIL = "fail"
    WARN = "warn"


class QualityCheckStatus(Enum):
    """Status of a quality check execution."""

    PASSED = "passed"
    FAILED = "failed"
    SKIPPED = "skipped"


@dataclass(frozen=True, slots=True)
class QualityCheckResult:
    """Result from a single quality check.

    Attributes:
        check_name: Human-readable check name.
        check_type: Check type identifier (e.g. 'row_count').
        status: Whether the check passed, failed, or was skipped.
        severity: Whether failure should fail the pipeline or just warn.
        message: Descriptive message about the result.
        details: Additional context about the check.
    """

    check_name: str
    check_type: str
    status: QualityCheckStatus
    severity: QualityCheckSeverity
    message: str
    details: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class QualityReport:
    """Aggregated results from all quality checks.

    Attributes:
        checks: Tuple of individual check results.
        passed: True if no checks with severity=FAIL have failed.
    """

    checks: tuple[QualityCheckResult, ...] = ()
    passed: bool = True

    @property
    def failures(self) -> list[QualityCheckResult]:
        """All failed checks regardless of severity."""
        return [c for c in self.checks if c.status == QualityCheckStatus.FAILED]

    @property
    def warnings(self) -> list[QualityCheckResult]:
        """Failed checks with severity=WARN."""
        return [
            c
            for c in self.checks
            if c.status == QualityCheckStatus.FAILED
            and c.severity == QualityCheckSeverity.WARN
        ]

    @property
    def hard_failures(self) -> list[QualityCheckResult]:
        """Failed checks with severity=FAIL."""
        return [
            c
            for c in self.checks
            if c.status == QualityCheckStatus.FAILED
            and c.severity == QualityCheckSeverity.FAIL
        ]

    def to_dict(self) -> dict[str, Any]:
        """Convert report to a serializable dictionary."""
        return {
            "passed": self.passed,
            "total_checks": len(self.checks),
            "failed_count": len(self.failures),
            "warning_count": len(self.warnings),
            "checks": [
                {
                    "check_name": c.check_name,
                    "check_type": c.check_type,
                    "status": c.status.value,
                    "severity": c.severity.value,
                    "message": c.message,
                    "details": c.details,
                }
                for c in self.checks
            ],
        }


# =============================================================================
# BUILT-IN CHECKS
# =============================================================================

_SAFE_BUILTINS = {
    "len": len,
    "sum": sum,
    "min": min,
    "max": max,
    "any": any,
    "all": all,
    "abs": abs,
}


def check_row_count(
    loaded_records: list[dict[str, Any]],
    load_result: LoadResult,
    config: dict[str, Any],
) -> QualityCheckResult:
    """Check that the row count meets configured constraints.

    Config keys:
        min: Minimum row count.
        max: Maximum row count.
        exact: Exact expected count.
        expected: Expected count with tolerance.
        tolerance_percent: Allowed deviation from expected (default 0).
    """
    severity = _parse_severity(config)
    count = len(loaded_records)
    details: dict[str, Any] = {"actual_count": count}

    # Exact count
    if "exact" in config:
        expected = config["exact"]
        details["exact"] = expected
        if count != expected:
            return QualityCheckResult(
                check_name="row_count",
                check_type="row_count",
                status=QualityCheckStatus.FAILED,
                severity=severity,
                message=f"Row count {count} != expected {expected}",
                details=details,
            )

    # Expected with tolerance
    if "expected" in config:
        expected = config["expected"]
        tolerance = config.get("tolerance_percent", 0)
        delta = expected * tolerance / 100.0
        lower = expected - delta
        upper = expected + delta
        details["expected"] = expected
        details["tolerance_percent"] = tolerance
        if not (lower <= count <= upper):
            return QualityCheckResult(
                check_name="row_count",
                check_type="row_count",
                status=QualityCheckStatus.FAILED,
                severity=severity,
                message=(
                    f"Row count {count} outside expected range "
                    f"[{lower:.0f}, {upper:.0f}]"
                ),
                details=details,
            )

    # Min
    if "min" in config:
        min_val = config["min"]
        details["min"] = min_val
        if count < min_val:
            return QualityCheckResult(
                check_name="row_count",
                check_type="row_count",
                status=QualityCheckStatus.FAILED,
                severity=severity,
                message=f"Row count {count} < minimum {min_val}",
                details=details,
            )

    # Max
    if "max" in config:
        max_val = config["max"]
        details["max"] = max_val
        if count > max_val:
            return QualityCheckResult(
                check_name="row_count",
                check_type="row_count",
                status=QualityCheckStatus.FAILED,
                severity=severity,
                message=f"Row count {count} > maximum {max_val}",
                details=details,
            )

    return QualityCheckResult(
        check_name="row_count",
        check_type="row_count",
        status=QualityCheckStatus.PASSED,
        severity=severity,
        message=f"Row count {count} within constraints",
        details=details,
    )


def check_null_rate(
    loaded_records: list[dict[str, Any]],
    load_result: LoadResult,
    config: dict[str, Any],
) -> QualityCheckResult:
    """Check that null rates for specified fields are within limits.

    Config keys:
        fields: List of field names to check.
        max_null_percent: Maximum allowed null percentage (default 0.0).
    """
    severity = _parse_severity(config)
    fields = config.get("fields", [])
    max_null_pct = config.get("max_null_percent", 0.0)
    total = len(loaded_records)
    details: dict[str, Any] = {"max_null_percent": max_null_pct, "fields": {}}
    violations: list[str] = []

    if total == 0:
        return QualityCheckResult(
            check_name="null_rate",
            check_type="null_rate",
            status=QualityCheckStatus.PASSED,
            severity=severity,
            message="No records to check",
            details=details,
        )

    for f in fields:
        null_count = sum(1 for r in loaded_records if r.get(f) is None)
        null_pct = (null_count / total) * 100.0
        details["fields"][f] = {"null_count": null_count, "null_percent": null_pct}
        if null_pct > max_null_pct:
            violations.append(f"'{f}' has {null_pct:.1f}% nulls (max {max_null_pct}%)")

    if violations:
        return QualityCheckResult(
            check_name="null_rate",
            check_type="null_rate",
            status=QualityCheckStatus.FAILED,
            severity=severity,
            message=f"Null rate violations: {'; '.join(violations)}",
            details=details,
        )

    return QualityCheckResult(
        check_name="null_rate",
        check_type="null_rate",
        status=QualityCheckStatus.PASSED,
        severity=severity,
        message="All fields within null rate limits",
        details=details,
    )


def check_uniqueness(
    loaded_records: list[dict[str, Any]],
    load_result: LoadResult,
    config: dict[str, Any],
) -> QualityCheckResult:
    """Check that specified fields have unique values.

    Config keys:
        fields: List of field names that must be unique (individually).
    """
    severity = _parse_severity(config)
    fields = config.get("fields", [])
    details: dict[str, Any] = {"fields": {}}
    violations: list[str] = []

    for f in fields:
        values = [r.get(f) for r in loaded_records]
        # Filter out None values for uniqueness check
        non_null = [v for v in values if v is not None]
        unique_count = len(set(non_null))
        total_non_null = len(non_null)
        duplicate_count = total_non_null - unique_count
        details["fields"][f] = {
            "total": total_non_null,
            "unique": unique_count,
            "duplicates": duplicate_count,
        }
        if duplicate_count > 0:
            violations.append(f"'{f}' has {duplicate_count} duplicate(s)")

    if violations:
        return QualityCheckResult(
            check_name="uniqueness",
            check_type="uniqueness",
            status=QualityCheckStatus.FAILED,
            severity=severity,
            message=f"Uniqueness violations: {'; '.join(violations)}",
            details=details,
        )

    return QualityCheckResult(
        check_name="uniqueness",
        check_type="uniqueness",
        status=QualityCheckStatus.PASSED,
        severity=severity,
        message="All fields have unique values",
        details=details,
    )


def check_expression(
    loaded_records: list[dict[str, Any]],
    load_result: LoadResult,
    config: dict[str, Any],
) -> QualityCheckResult:
    """Evaluate a custom expression against loaded data.

    Config keys:
        expression: Python expression string to evaluate.
        description: Human-readable description of the check.

    Available variables in scope:
        row_count, loaded_count, failed_count, fields, records.
    Safe builtins: len, sum, min, max, any, all, abs.
    """
    severity = _parse_severity(config)
    expression = config.get("expression", "")
    description = config.get("description", expression)

    if not expression:
        return QualityCheckResult(
            check_name=description,
            check_type="expression",
            status=QualityCheckStatus.SKIPPED,
            severity=severity,
            message="No expression provided",
        )

    # Build safe evaluation scope
    scope: dict[str, Any] = {
        "__builtins__": _SAFE_BUILTINS,
        "row_count": len(loaded_records),
        "loaded_count": load_result.rows_loaded,
        "failed_count": load_result.rows_failed,
        "records": loaded_records,
        "fields": list(loaded_records[0].keys()) if loaded_records else [],
    }

    try:
        result = eval(expression, scope)  # noqa: S307
    except Exception as exc:
        logger.warning("Quality check expression failed: %s — %s", expression, exc)
        return QualityCheckResult(
            check_name=description,
            check_type="expression",
            status=QualityCheckStatus.SKIPPED,
            severity=severity,
            message=f"Expression evaluation error: {exc}",
            details={"expression": expression},
        )

    if result:
        return QualityCheckResult(
            check_name=description,
            check_type="expression",
            status=QualityCheckStatus.PASSED,
            severity=severity,
            message=f"Expression passed: {description}",
            details={"expression": expression},
        )

    return QualityCheckResult(
        check_name=description,
        check_type="expression",
        status=QualityCheckStatus.FAILED,
        severity=severity,
        message=f"Expression failed: {description}",
        details={"expression": expression},
    )


# =============================================================================
# QUALITY CHECKER
# =============================================================================

_CHECK_REGISTRY: dict[str, Any] = {
    "row_count": check_row_count,
    "null_rate": check_null_rate,
    "uniqueness": check_uniqueness,
    "expression": check_expression,
}


class QualityChecker:
    """Runs post-load structural quality checks against loaded data.

    Validates row count, null rates, uniqueness, and custom expressions
    after data has been loaded. All checks run even if earlier ones fail
    to provide maximum information per run.

    For contract-based quality scoring, see
    ``pycharter.quality.QualityCheck``. Preferred alias:
    ``PostLoadChecker``.

    Args:
        checks: List of check config dicts with at least a 'type' key.
        pipeline_name: Name of the pipeline (for logging).
    """

    def __init__(
        self,
        checks: list[dict[str, Any]],
        pipeline_name: str = "",
    ) -> None:
        self._checks = checks
        self._pipeline_name = pipeline_name

    def run(
        self,
        loaded_records: list[dict[str, Any]],
        load_result: LoadResult,
    ) -> QualityReport:
        """Execute all configured quality checks.

        Args:
            loaded_records: All records that were loaded.
            load_result: Result from the last load operation.

        Returns:
            QualityReport with results for each check.
        """
        results: list[QualityCheckResult] = []
        has_hard_failure = False

        for check_config in self._checks:
            check_type = check_config.get("type", "")
            check_fn = _CHECK_REGISTRY.get(check_type)

            if check_fn is None:
                logger.warning(
                    "Unknown quality check type: %s (pipeline: %s)",
                    check_type,
                    self._pipeline_name,
                )
                results.append(
                    QualityCheckResult(
                        check_name=check_type,
                        check_type=check_type,
                        status=QualityCheckStatus.SKIPPED,
                        severity=_parse_severity(check_config),
                        message=f"Unknown check type: {check_type}",
                    )
                )
                continue

            try:
                result = check_fn(loaded_records, load_result, check_config)
                results.append(result)
                if (
                    result.status == QualityCheckStatus.FAILED
                    and result.severity == QualityCheckSeverity.FAIL
                ):
                    has_hard_failure = True
                    logger.error(
                        "Quality check FAILED (pipeline=%s): %s",
                        self._pipeline_name,
                        result.message,
                    )
                elif result.status == QualityCheckStatus.FAILED:
                    logger.warning(
                        "Quality check WARNING (pipeline=%s): %s",
                        self._pipeline_name,
                        result.message,
                    )
            except Exception as exc:
                logger.warning(
                    "Quality check '%s' raised exception: %s",
                    check_type,
                    exc,
                )
                results.append(
                    QualityCheckResult(
                        check_name=check_type,
                        check_type=check_type,
                        status=QualityCheckStatus.SKIPPED,
                        severity=_parse_severity(check_config),
                        message=f"Check execution error: {exc}",
                    )
                )

        return QualityReport(
            checks=tuple(results),
            passed=not has_hard_failure,
        )


def create_quality_checker(
    quality_config: list[dict[str, Any]] | None,
    pipeline_name: str = "",
) -> QualityChecker | None:
    """Create a QualityChecker from config, or None if no checks configured.

    Args:
        quality_config: List of quality check configs, or None.
        pipeline_name: Pipeline name for logging.

    Returns:
        QualityChecker instance or None.
    """
    if not quality_config:
        return None
    return QualityChecker(quality_config, pipeline_name=pipeline_name)


# =============================================================================
# HELPERS
# =============================================================================


def _parse_severity(config: dict[str, Any]) -> QualityCheckSeverity:
    """Parse severity from check config, defaulting to FAIL."""
    raw = config.get("severity", "fail").lower()
    if raw == "warn":
        return QualityCheckSeverity.WARN
    return QualityCheckSeverity.FAIL


# Preferred alias — disambiguates from pycharter.quality.QualityCheck
PostLoadChecker = QualityChecker
