# tests/test_parity.py
"""Verify Python BANOS output matches Matlab BANOS output within tolerance.

WHY these tests matter
----------------------
BANOS is a dual-language package. Users who switch between Python and Matlab
must get the same numeric results. A divergence here means one implementation
has a bug or an unintentional algorithm difference.

FIXTURE FILE
------------
tests/fixtures/golden_matlab_v020.json — generated by:
    matlab -batch "run('tests/fixtures/matlab/generate_matlab_fixtures.m')"

The file contains all 10 recordings in a single JSON (same structure as
golden_python_current.json), keyed by "Recording_N".

TOLERANCE
---------
1e-6 (vs 1e-10 in regression tests) — floating-point implementations of
identical formulas in two languages can accumulate small differences.

NaN HANDLING
------------
Both Python and Matlab v0.2.0 return 1.0 for correct absence (both have no bouts)
and 0.0 for false detection (machine predicts bouts, GT has none). No NaN expected
for absent behaviors in v0.2.0. The _values_match() helper still handles None/NaN
for any residual edge cases during Matlab JSON encoding.

See also: tests/test_comparison_report.py for a visual HTML comparison table.
"""

import json
import math
from pathlib import Path

import pandas as pd
import pytest

from banos import aggregate_metrics, calculate_banos_for_each_file, preprocess_data

DATASET_DIR = Path(__file__).parent.parent / "data" / "dataset_human_vs_human"
MATLAB_FIXTURE = Path(__file__).parent / "fixtures" / "golden_matlab_v020.json"
TOLERANCE = 1e-6  # floating point tolerance for Matlab vs Python comparisons

METRICS = ["precision", "recall", "f1_score", "so", "tp", "ic"]

# Load MATLAB fixture once (skip all tests if absent)
if not MATLAB_FIXTURE.exists():
    pytest.skip(
        "Matlab fixture not found: tests/fixtures/golden_matlab_v020.json — "
        "run: matlab -batch \"run('tests/fixtures/matlab/generate_matlab_fixtures.m')\"",
        allow_module_level=True,
    )

with open(MATLAB_FIXTURE, encoding="utf-8") as _f:
    _MATLAB_ALL = json.load(_f)


def load_recording(n):
    """Load pred/gt DataFrames for recording N."""
    rec_dir = DATASET_DIR / f"Recording_{n}"
    pred_path = rec_dir / "humanAnnotation_2.csv"
    if not pred_path.exists():
        raise FileNotFoundError(f"No pred file found for Recording_{n}")
    pred = pd.read_csv(pred_path)
    gt_path = rec_dir / "humanAnnotation_1.csv"
    if not gt_path.exists():
        raise FileNotFoundError(f"No GT file found for Recording_{n}")
    return pred, pd.read_csv(gt_path)


def _values_match(actual, expected) -> bool:
    """Compare Python and Matlab metric values with NaN-awareness.

    Matlab encodes NaN via jsonencode as JSON null; Python's json.load() reads
    null as None. Both Python NaN and Matlab null/NaN are treated as equivalent
    'undefined' values — a behavior with no GT bouts in both implementations.
    """
    expected_nan = expected is None or (isinstance(expected, float) and math.isnan(expected))
    actual_nan = actual is None or (isinstance(actual, float) and math.isnan(actual))
    if expected_nan and actual_nan:
        return True
    if expected_nan != actual_nan:
        return False
    return abs(actual - expected) < TOLERANCE


@pytest.mark.parametrize("n", range(1, 11))
def test_parity_overall_metrics(n):
    """Python overall metrics must match Matlab output within 1e-6."""
    rec_name = f"Recording_{n}"
    if rec_name not in _MATLAB_ALL:
        pytest.skip(f"{rec_name} not in Matlab fixture")

    matlab_result = _MATLAB_ALL[rec_name]
    pred, gt = load_recording(n)
    data, _ = preprocess_data({rec_name: (pred.copy(), gt.copy())})
    file_metrics = calculate_banos_for_each_file(data)
    _, overall = aggregate_metrics(file_metrics)

    matlab_overall = matlab_result.get("overall_metrics", {})
    for metric in METRICS:
        if metric not in matlab_overall:
            pytest.skip(f"Metric {metric} not in Matlab fixture for {rec_name}")
        expected = matlab_overall[metric]
        actual = overall.get(metric)
        assert _values_match(actual, expected), (
            f"{rec_name} {metric}: Matlab={expected}, Python={actual} "
            f"(diff={abs(actual - expected):.2e} > tolerance {TOLERANCE:.0e})"
        )


@pytest.mark.parametrize("n", range(1, 11))
def test_parity_group_metrics(n):
    """Python per-behavior metrics must match Matlab output within 1e-6."""
    rec_name = f"Recording_{n}"
    if rec_name not in _MATLAB_ALL:
        pytest.skip(f"{rec_name} not in Matlab fixture")

    matlab_result = _MATLAB_ALL[rec_name]
    pred, gt = load_recording(n)
    data, _ = preprocess_data({rec_name: (pred.copy(), gt.copy())})
    file_metrics = calculate_banos_for_each_file(data)
    group, _ = aggregate_metrics(file_metrics)

    matlab_group = matlab_result.get("group_metrics", {})
    for behavior, metrics in matlab_group.items():
        if behavior not in group:
            pytest.skip(f"Behavior '{behavior}' not in Python output for {rec_name}")
        for metric in METRICS:
            if metric not in metrics:
                continue
            expected = metrics[metric]
            actual = group[behavior].get(metric)
            assert _values_match(actual, expected), (
                f"{rec_name} {behavior} {metric}: Matlab={expected}, Python={actual}"
            )
