"""Fingerprinting and data manipulation helpers for determinism tests.

Separated from conftest.py so they can be imported without relying on
pytest's conftest auto-discovery or the `tests.` package prefix.
"""
from __future__ import annotations

import csv
import hashlib
import json
import random
from pathlib import Path
from typing import Any

import pytest

# ---------------------------------------------------------------------------
# Capability detection
# ---------------------------------------------------------------------------

try:
    from kanoniv._native import parse as _parse  # noqa: F401

    HAS_NATIVE = True
except (ImportError, AttributeError):
    HAS_NATIVE = False

try:
    from kanoniv._native import reconcile_local  # noqa: F401

    HAS_RECONCILE = True
except (ImportError, AttributeError):
    HAS_RECONCILE = False

requires_native = pytest.mark.skipif(
    not HAS_NATIVE, reason="Native extension not built"
)

requires_reconcile = pytest.mark.skipif(
    not HAS_RECONCILE, reason="reconcile_local not available in native extension"
)

# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------

REPO_ROOT = Path(__file__).parents[4]  # oss/python-sdk/tests/determinism → repo root
SPEC_PATH = REPO_ROOT / "customer-spec.yaml"
SF_CSV_PATH = REPO_ROOT / "test-dataset" / "sf_contacts.csv"
STRIPE_CSV_PATH = REPO_ROOT / "test-dataset" / "stripe_customers.csv"
GOLDEN_DIR = REPO_ROOT / "oss" / "python-sdk" / "tests" / "contract" / "golden"


def golden_path(filename: str) -> str:
    """Return absolute path to a golden CSV file."""
    return str(GOLDEN_DIR / filename)


# ---------------------------------------------------------------------------
# Fingerprinting helpers
# ---------------------------------------------------------------------------


def decision_signature(d: dict[str, Any]) -> tuple:
    """UUID-independent tuple for set comparison of a single decision.

    Strips entity IDs and normalizes floats so that decisions from different
    runs (with different random UUIDs) can be compared by content.
    """
    rule_results = d.get("rule_results", [])
    rr_tuples = tuple(sorted(
        (
            rr.get("rule_name", ""),
            round(float(rr.get("score", 0)), 10),
            rr.get("matched", False),
            rr.get("explanation", ""),
        )
        for rr in rule_results
    ))
    # blocking_key is excluded: it's metadata about discovery path (which
    # blocking group found the pair first during par_iter), not decision content.
    # The same logical pair can be found via email or phone blocking group
    # depending on parallel execution order.
    return (
        round(float(d.get("confidence", 0)), 10),
        d.get("decision_type", ""),
        tuple(sorted(d.get("matched_on", []))),
        rr_tuples,
    )


def _normalize_golden_record(gr: dict[str, str]) -> tuple:
    """Normalize a golden record into a sorted tuple of (key, value) pairs."""
    return tuple(sorted(gr.items()))


def identity_fingerprint(result: Any) -> str:
    """SHA-256 fingerprint of the normalized identity graph.

    Normalizes away:
    - Random entity UUIDs in decisions and clusters
    - Rayon parallel iteration order (decisions sorted)
    - HashMap cluster ordering (cluster sizes sorted)

    Keeps:
    - Decision content (confidence, type, matched_on, blocking_key, rule_results)
    - Cluster size distribution
    - Golden record content
    - Aggregate metrics (cluster_count, merge_rate)
    """
    # Decisions: extract UUID-free signatures, sort for order independence
    decision_sigs = sorted(
        json.dumps(decision_signature(d), sort_keys=True, default=str)
        for d in result.decisions
    )

    # Clusters: only compare size distribution (UUIDs are random)
    cluster_sizes = sorted(len(c) for c in result.clusters)

    # Golden records: sorted tuples of (key, value)
    golden = sorted(
        json.dumps(_normalize_golden_record(gr), sort_keys=True)
        for gr in result.golden_records
    )

    canonical = json.dumps(
        {
            "cluster_sizes": cluster_sizes,
            "decisions": decision_sigs,
            "golden_records": golden,
            "cluster_count": result.cluster_count,
            "merge_rate": round(result.merge_rate, 10),
        },
        sort_keys=True,
    )

    return hashlib.sha256(canonical.encode()).hexdigest()


def compare_results_deep(a: Any, b: Any) -> tuple[bool, list[str]]:
    """Deep comparison of two ReconcileResults.

    Returns (identical, diffs_list) with human-readable diff messages.
    """
    diffs: list[str] = []

    if a.cluster_count != b.cluster_count:
        diffs.append(f"cluster_count: {a.cluster_count} vs {b.cluster_count}")

    mr_a = round(a.merge_rate, 10)
    mr_b = round(b.merge_rate, 10)
    if mr_a != mr_b:
        diffs.append(f"merge_rate: {mr_a} vs {mr_b}")

    if len(a.decisions) != len(b.decisions):
        diffs.append(f"decision_count: {len(a.decisions)} vs {len(b.decisions)}")

    sigs_a = set(decision_signature(d) for d in a.decisions)
    sigs_b = set(decision_signature(d) for d in b.decisions)
    only_a = sigs_a - sigs_b
    only_b = sigs_b - sigs_a
    if only_a:
        diffs.append(f"decisions only in A: {len(only_a)}")
    if only_b:
        diffs.append(f"decisions only in B: {len(only_b)}")

    sizes_a = sorted(len(c) for c in a.clusters)
    sizes_b = sorted(len(c) for c in b.clusters)
    if sizes_a != sizes_b:
        diffs.append(f"cluster_sizes: {sizes_a} vs {sizes_b}")

    gr_a = sorted(json.dumps(_normalize_golden_record(gr), sort_keys=True) for gr in a.golden_records)
    gr_b = sorted(json.dumps(_normalize_golden_record(gr), sort_keys=True) for gr in b.golden_records)
    if gr_a != gr_b:
        diffs.append(f"golden_records differ ({len(gr_a)} vs {len(gr_b)} records)")

    return (len(diffs) == 0, diffs)


# ---------------------------------------------------------------------------
# Data manipulation helpers
# ---------------------------------------------------------------------------


def shuffled_csv(original_path: str, tmp_path: Path, seed: int, name: str) -> str:
    """Read a CSV, shuffle rows with given seed, write to tmp_path, return path."""
    with open(original_path, newline="") as f:
        reader = csv.DictReader(f)
        fieldnames = reader.fieldnames or []
        rows = list(reader)

    random.Random(seed).shuffle(rows)

    out_path = tmp_path / f"{name}_shuffled_{seed}.csv"
    with open(out_path, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)

    return str(out_path)


def reformat_yaml(yaml_str: str, variant: str) -> str:
    """Return reformatted YAML that is semantically equivalent.

    Variants:
    - "extra_whitespace": adds blank lines and trailing spaces
    - "comments": adds inline/block comments
    - "quoting": changes bare values to quoted values
    - "reorder": reverses top-level section order (within YAML validity)
    """
    lines = yaml_str.splitlines(keepends=True)

    if variant == "extra_whitespace":
        result = []
        for line in lines:
            result.append(line)
            if line.strip() == "" or line.strip().endswith(":"):
                result.append("\n")
        return "".join(result)

    elif variant == "comments":
        result = []
        for line in lines:
            stripped = line.rstrip("\n")
            if stripped.strip() and not stripped.strip().startswith("#"):
                result.append(f"{stripped}  # determinism test comment\n")
            else:
                result.append(line)
        return "".join(result)

    elif variant == "quoting":
        result = []
        for line in lines:
            if ": " in line and not line.strip().startswith("#") and not line.strip().startswith("-"):
                key, _, value = line.partition(": ")
                value = value.strip()
                if value and not value.startswith('"') and not value.startswith("'"):
                    if not value.startswith("[") and not value.startswith("{"):
                        # Only quote string values — skip numbers and booleans
                        # to preserve YAML type semantics
                        try:
                            float(value)
                            result.append(line)
                            continue
                        except ValueError:
                            pass
                        if value.lower() in ("true", "false", "null", "~"):
                            result.append(line)
                            continue
                        line = f'{key}: "{value}"\n'
            result.append(line)
        return "".join(result)

    elif variant == "reorder":
        sections: list[list[str]] = []
        current: list[str] = []
        for line in lines:
            if line and not line[0].isspace() and not line.startswith("#") and current:
                sections.append(current)
                current = [line]
            else:
                current.append(line)
        if current:
            sections.append(current)

        if len(sections) > 1:
            header = sections[0]
            rest = sections[1:]
            rest.reverse()
            sections = [header] + rest

        return "".join(line for section in sections for line in section)

    return yaml_str
