"""Dependency-free descriptive statistics; no significance or causal claims."""
import csv
import hashlib
import math
from pathlib import Path
from statistics import correlation, fmean, linear_regression


class InputError(ValueError):
    """An input cannot support the requested analysis."""


def digest(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def read_csv(path):
    with Path(path).open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        headers = reader.fieldnames
        if not headers or len(headers) != len(set(headers)) or any(not h for h in headers):
            raise InputError("CSV needs nonempty, unique column names.")
        rows = list(reader)
    if not rows:
        raise InputError("CSV has no data rows.")
    for line, row in enumerate(rows, 2):
        if None in row or any(v is None for v in row.values()):
            raise InputError(f"CSV row {line} has the wrong number of fields.")
    return rows


def number(value, context):
    if value is None or str(value).strip().lower() in {"", "na", "n/a", "n.d.", "-"}:
        return None
    try:
        result = float(value)
    except (ValueError, TypeError) as exc:
        raise InputError(f"{context}: expected a number or an explicit missing value; got {value!r}.") from exc
    if not math.isfinite(result):
        raise InputError(f"{context}: NaN and infinity are not valid measurements.")
    return result


def ranks(values):
    """One-based average ranks for ties."""
    ordered = sorted(range(len(values)), key=values.__getitem__)
    result = [0.0] * len(values)
    start = 0
    while start < len(ordered):
        end = start + 1
        while end < len(ordered) and values[ordered[end]] == values[ordered[start]]:
            end += 1
        rank = (start + 1 + end) / 2
        for i in ordered[start:end]:
            result[i] = rank
        start = end
    return result


def paired(rows, x_column, y_column, id_column):
    for field in (x_column, y_column, id_column):
        if field not in rows[0]:
            raise InputError(f"Missing column: {field}")
    included, omitted, seen = [], [], set()
    for index, row in enumerate(rows, 2):
        identity = row[id_column].strip()
        if not identity or identity in seen:
            raise InputError(f"CSV row {index}: identifier must be nonempty and unique: {identity!r}.")
        seen.add(identity)
        x = number(row[x_column], f"row {identity}, {x_column}")
        y = number(row[y_column], f"row {identity}, {y_column}")
        if x is None or y is None:
            omitted.append({"id": identity, "missing": [c for c, v in ((x_column, x), (y_column, y)) if v is None]})
        else:
            included.append({"id": identity, "x": x, "y": y})
    return included, omitted


def summarize(rows, x_column, y_column, id_column="compound_id", direction="negative"):
    if direction not in {"negative", "positive"}:
        raise InputError("Expected direction must be negative or positive.")
    points, omitted = paired(rows, x_column, y_column, id_column)
    if len(points) < 3:
        raise InputError("At least three complete pairs are required; missing values are not zero-filled.")
    x, y = [p["x"] for p in points], [p["y"] for p in points]
    if len(set(x)) < 2 or len(set(y)) < 2:
        raise InputError("Correlation is undefined for a constant descriptor or response.")
    r = correlation(x, y)
    slope, intercept = linear_regression(x, y)
    sign = -1 if direction == "negative" else 1
    concordant, discordant, tied = [], [], []
    for i, left in enumerate(points):
        for right in points[i + 1:]:
            product = (left["x"] - right["x"]) * (left["y"] - right["y"]) * sign
            pair = [left["id"], right["id"]]
            (tied if product == 0 else concordant if product > 0 else discordant).append(pair)
    folds = []
    for i, point in enumerate(points):
        tx, ty = x[:i] + x[i + 1:], y[:i] + y[i + 1:]
        fold = {"held_out_id": point["id"], "observed": point["y"], "mean_prediction": fmean(ty)}
        if len(set(tx)) > 1:
            a, b = linear_regression(tx, ty)
            fold["linear_prediction"] = a * point["x"] + b
        else:
            fold["linear_prediction"] = None
        fold["training_r2"] = correlation(tx, ty) ** 2 if len(set(tx)) > 1 and len(set(ty)) > 1 else None
        folds.append(fold)
    evaluated = [f for f in folds if f["linear_prediction"] is not None]
    def errors(key):
        residuals = [f[key] - f["observed"] for f in evaluated]
        return {"mae": fmean(abs(v) for v in residuals), "rmse": math.sqrt(fmean(v * v for v in residuals))} if residuals else {"mae": None, "rmse": None}
    linear, baseline = errors("linear_prediction"), errors("mean_prediction")
    return {
        "descriptor": x_column, "response": y_column, "expected_direction": direction,
        "input_rows": len(rows), "n": len(points), "included_ids": [p["id"] for p in points],
        "missing": omitted, "pearson_r": r, "r_squared": r * r,
        "spearman_rho": correlation(ranks(x), ranks(y)), "slope": slope, "intercept": intercept,
        "ranking": {"concordant": len(concordant), "discordant": len(discordant), "tied": len(tied),
                    "total_pairs": len(concordant) + len(discordant) + len(tied),
                    "discordant_fraction_of_untied": len(discordant) / (len(concordant) + len(discordant)) if concordant or discordant else None,
                    "discordant_pairs": discordant},
        "leave_one_out": {"evaluated_folds": len(evaluated), "total_folds": len(folds), "linear": linear,
                          "training_mean_baseline": baseline, "folds": folds},
        "points": points,
    }


def compare_table(rows, index_column, left_column, right_column, minimum=None, maximum=None, expected="greater"):
    if expected not in {"greater", "less"}:
        raise InputError("Table comparison must be greater or less.")
    if any(v is not None and not math.isfinite(v) for v in (minimum, maximum)):
        raise InputError("Table interval bounds must be finite numbers.")
    if minimum is not None and maximum is not None and minimum > maximum:
        raise InputError("Minimum index cannot exceed maximum index.")
    points, missing = paired(rows, left_column, right_column, index_column)
    if missing:
        raise InputError("Table reconciliation requires both values at every selected row; missing values cannot prove a comparison.")
    comparisons, indices = [], set()
    for point in points:
        index = number(point["id"], index_column)
        if index is None:
            raise InputError("Table index must be numeric.")
        if index in indices:
            raise InputError(f"Duplicate numeric table index: {index}.")
        indices.add(index)
        if (minimum is not None and index < minimum) or (maximum is not None and index > maximum):
            continue
        delta = point["y"] - point["x"]
        agrees = delta > 0 if expected == "greater" else delta < 0
        comparisons.append({"index": index, "left": point["x"], "right": point["y"], "right_minus_left": delta, "agrees": agrees})
    if not comparisons:
        raise InputError("No table rows fall in the requested interval.")
    return {"index_column": index_column, "left_column": left_column, "right_column": right_column,
            "expected_right": expected, "minimum": minimum, "maximum": maximum,
            "n": len(comparisons), "agreeing": sum(p["agrees"] for p in comparisons),
            "counterexamples": [p for p in comparisons if not p["agrees"]], "rows": comparisons,
            "interpretation": "A numerical source-consistency check, not a verdict about experimental truth."}
