"""
CSV validation utilities based on declarative rules.
"""

from __future__ import annotations

import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

import yaml


@dataclass
class CheckResult:
    name: str
    type: str
    status: str  # "pass" or "fail"
    message: str
    details: Dict[str, Any]


@dataclass
class ValidationSummary:
    checks_total: int
    checks_passed: int
    checks_failed: int
    row_count: int


@dataclass
class ValidationResult:
    summary: ValidationSummary
    checks: List[CheckResult]


def run_validation(csv_path: Path, rules_path: Path) -> ValidationResult:
    """
    Run validation checks defined in a YAML rules file against a CSV.
    """
    rows = _read_csv(csv_path)
    rules = _load_rules(rules_path)
    check_results: List[CheckResult] = []

    for rule in rules:
        result = _run_check(rule, rows)
        check_results.append(result)

    passed = sum(1 for r in check_results if r.status == "pass")
    failed = len(check_results) - passed
    summary = ValidationSummary(
        checks_total=len(check_results),
        checks_passed=passed,
        checks_failed=failed,
        row_count=len(rows),
    )
    return ValidationResult(summary=summary, checks=check_results)


def write_result_json(result: ValidationResult, path: Path) -> None:
    payload = {
        "summary": {
            "checks_total": result.summary.checks_total,
            "checks_passed": result.summary.checks_passed,
            "checks_failed": result.summary.checks_failed,
            "row_count": result.summary.row_count,
        },
        "checks": [
            {
                "name": c.name,
                "type": c.type,
                "status": c.status,
                "message": c.message,
                "details": c.details,
            }
            for c in result.checks
        ],
    }
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def _read_csv(path: Path) -> List[Dict[str, str]]:
    with path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise ValueError(f"CSV {path} is missing a header row.")
        return [dict(row) for row in reader]


def _load_rules(path: Path) -> List[Dict[str, Any]]:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict) or "checks" not in data:
        raise ValueError("Rules file must contain a top-level 'checks' list.")
    checks = data.get("checks") or []
    if not isinstance(checks, list):
        raise ValueError("'checks' must be a list.")
    normalized: List[Dict[str, Any]] = []
    for idx, entry in enumerate(checks):
        if not isinstance(entry, dict):
            raise ValueError(f"Check #{idx+1} must be a mapping.")
        if "name" not in entry or "type" not in entry:
            raise ValueError(f"Check #{idx+1} missing 'name' or 'type'.")
        normalized.append(entry)
    return normalized


def _run_check(rule: Dict[str, Any], rows: List[Dict[str, str]]) -> CheckResult:
    check_type = str(rule.get("type", "")).strip()
    name = str(rule.get("name", check_type))
    try:
        if check_type == "row_count_min":
            return _check_row_count_min(name, rows, rule)
        if check_type == "row_count_ratio":
            return _check_row_count_ratio(name, rows, rule)
        if check_type == "column_min":
            return _check_column_bound(name, rows, rule, bound_type="min")
        if check_type == "column_max":
            return _check_column_bound(name, rows, rule, bound_type="max")
        if check_type == "column_sum_range":
            return _check_column_sum_range(name, rows, rule)
        if check_type == "unique":
            return _check_unique(name, rows, rule)
        if check_type == "not_null":
            return _check_not_null(name, rows, rule)
        if check_type == "enum":
            return _check_enum(name, rows, rule)
        return _fail(name, check_type, f"Unsupported check type '{check_type}'.")
    except Exception as exc:
        return _fail(name, check_type, f"Error running check: {exc}")


def _check_row_count_min(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    minimum = rule.get("min")
    if minimum is None:
        return _fail(name, "row_count_min", "Missing 'min' in rule.")
    row_count = len(rows)
    if row_count >= minimum:
        return _pass(name, "row_count_min", f"Row count {row_count} >= {minimum}.", {})
    return _fail(name, "row_count_min", f"Row count {row_count} < {minimum}.", {"row_count": row_count})


def _check_row_count_ratio(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    baseline_path_raw = rule.get("baseline")
    min_ratio = rule.get("min_ratio")
    if baseline_path_raw is None or min_ratio is None:
        return _fail(name, "row_count_ratio", "Missing 'baseline' or 'min_ratio' in rule.")
    baseline_path = Path(baseline_path_raw)
    if not baseline_path.exists():
        return _fail(name, "row_count_ratio", f"Baseline file {baseline_path} not found.")
    baseline_rows = _read_csv(baseline_path)
    current = len(rows)
    baseline = len(baseline_rows)
    if baseline == 0:
        return _fail(name, "row_count_ratio", "Baseline row count is zero; cannot compute ratio.")
    ratio = current / baseline
    if ratio >= min_ratio:
        return _pass(
            name,
            "row_count_ratio",
            f"Row count ratio {ratio:.2f} >= {min_ratio}. (current={current}, baseline={baseline})",
            {"ratio": ratio, "current": current, "baseline": baseline},
        )
    return _fail(
        name,
        "row_count_ratio",
        f"Row count ratio {ratio:.2f} < {min_ratio}. (current={current}, baseline={baseline})",
        {"ratio": ratio, "current": current, "baseline": baseline},
    )


def _check_column_bound(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any], bound_type: str) -> CheckResult:
    column = rule.get("column")
    bound_value = rule.get(bound_type)
    if not column or bound_value is None:
        return _fail(name, f"column_{bound_type}", f"Missing 'column' or '{bound_type}' in rule.")
    values = _coerce_numeric([row.get(column, "") for row in rows])
    if not values:
        return _fail(name, f"column_{bound_type}", f"No numeric values found in column '{column}'.")
    if bound_type == "min":
        violators = [val for val in values if val < bound_value]
        if not violators:
            return _pass(name, "column_min", f"All values in '{column}' >= {bound_value}.", {})
        return _fail(
            name,
            "column_min",
            f"{len(violators)} value(s) in '{column}' below {bound_value}.",
            {"violations": violators[:5]},
        )
    else:
        violators = [val for val in values if val > bound_value]
        if not violators:
            return _pass(name, "column_max", f"All values in '{column}' <= {bound_value}.", {})
        return _fail(
            name,
            "column_max",
            f"{len(violators)} value(s) in '{column}' above {bound_value}.",
            {"violations": violators[:5]},
        )


def _check_column_sum_range(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    column = rule.get("column")
    min_sum = rule.get("min")
    max_sum = rule.get("max")
    if not column or min_sum is None or max_sum is None:
        return _fail(name, "column_sum_range", "Missing 'column', 'min', or 'max' in rule.")
    values = _coerce_numeric([row.get(column, "") for row in rows])
    total = sum(values)
    if total < min_sum or total > max_sum:
        return _fail(
            name,
            "column_sum_range",
            f"Sum of '{column}' = {total:.2f} outside [{min_sum}, {max_sum}].",
            {"sum": total},
        )
    return _pass(
        name,
        "column_sum_range",
        f"Sum of '{column}' = {total:.2f} within [{min_sum}, {max_sum}].",
        {"sum": total},
    )


def _check_unique(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    column = rule.get("column")
    if not column:
        return _fail(name, "unique", "Missing 'column' in rule.")
    seen = {}
    duplicates = {}
    for idx, row in enumerate(rows, start=2):
        val = (row.get(column) or "").strip()
        if not val:
            continue
        if val in seen:
            duplicates.setdefault(val, []).append(idx)
        else:
            seen[val] = idx
    if not duplicates:
        return _pass(name, "unique", f"No duplicates in '{column}'.", {})
    sample = {k: v[:3] for k, v in list(duplicates.items())[:5]}
    return _fail(
        name,
        "unique",
        f"Found {len(duplicates)} duplicate value(s) in '{column}'.",
        {"duplicates": sample},
    )


def _check_not_null(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    column = rule.get("column")
    if not column:
        return _fail(name, "not_null", "Missing 'column' in rule.")
    blanks = [idx for idx, row in enumerate(rows, start=2) if not (row.get(column) or "").strip()]
    if not blanks:
        return _pass(name, "not_null", f"All values present in '{column}'.", {})
    return _fail(
        name,
        "not_null",
        f"{len(blanks)} blank value(s) in '{column}'.",
        {"rows": blanks[:10]},
    )


def _check_enum(name: str, rows: List[Dict[str, str]], rule: Dict[str, Any]) -> CheckResult:
    column = rule.get("column")
    allowed = rule.get("allowed")
    if not column or not isinstance(allowed, list):
        return _fail(name, "enum", "Missing 'column' or 'allowed' list in rule.")
    allowed_set = {str(a) for a in allowed}
    invalid: Dict[str, List[int]] = {}
    for idx, row in enumerate(rows, start=2):
        raw = row.get(column)
        if raw is None:
            continue
        val = str(raw)
        if val not in allowed_set:
            invalid.setdefault(val, []).append(idx)
    if not invalid:
        return _pass(name, "enum", f"All values in '{column}' are allowed.", {})
    sample = {k: v[:3] for k, v in list(invalid.items())[:5]}
    return _fail(
        name,
        "enum",
        f"{len(invalid)} invalid value(s) in '{column}'.",
        {"invalid_values": sample},
    )


def _coerce_numeric(values: List[str]) -> List[float]:
    nums: List[float] = []
    for val in values:
        if val is None:
            continue
        text = str(val).replace(",", "").replace("$", "").strip()
        if not text:
            continue
        try:
            nums.append(float(text))
        except ValueError:
            continue
    return nums


def _pass(name: str, check_type: str, message: str, details: Dict[str, Any]) -> CheckResult:
    return CheckResult(name=name, type=check_type, status="pass", message=message, details=details)


def _fail(name: str, check_type: str, message: str, details: Optional[Dict[str, Any]] = None) -> CheckResult:
    return CheckResult(name=name, type=check_type, status="fail", message=message, details=details or {})
