"""Cross-capture analysis: the maths that turns several captures into a claim.

Everything here is checked against a planted answer rather than against a
recorded file, because the point of each function is that it recovers something
specific -- a scale factor, an oscillator frequency, a tone at a chosen fraction
of the sample rate -- and only a synthetic capture knows what that was.

The argument parsing is tested just as hard. `LABEL=path` has to survive a path
containing '=', and --band and --supply have to reject nonsense rather than
quietly produce a figure that means nothing.
"""

import argparse
import math

import numpy as np
import pytest

import capture
import characterize as ch
import compare
import rm3100
from synthetic import field_counts, write_capture

# A field well off the axes, so no component sits near zero except where a test
# puts one there deliberately.
FIELD = (-40000.0, 16000.0, 12000.0)


def make(path, rows=4096, cycle_count=100, dt=1 / 300.0, mean=FIELD,
         noise_nt=20.0, seed=0, tones=(), **kwargs):
    counts = field_counts(rows, mean, cycle_count, noise_nt, seed, tones)
    return capture.load(write_capture(path, rows=rows, dt=dt,
                                      cycle_count=cycle_count, counts=counts,
                                      **kwargs))


# --------------------------------------------------------------------------
# white_sd and band_stats
# --------------------------------------------------------------------------

def test_white_sd_recovers_the_sd_of_white_noise():
    v = np.random.default_rng(1).normal(0, 7.0, 200_000)
    assert ch.white_sd(v) == pytest.approx(7.0, rel=0.02)


def test_white_sd_ignores_a_ramp_that_dominates_the_plain_sd():
    """The whole reason it exists: sd measures drift, this does not."""
    rng = np.random.default_rng(2)
    noise = rng.normal(0, 5.0, 50_000)
    drifting = noise + np.linspace(0, 500, 50_000)
    assert drifting.std() > 100                    # sd is all ramp
    assert ch.white_sd(drifting) == pytest.approx(5.0, rel=0.03)


def test_white_sd_of_a_constant_is_zero_and_of_one_sample_is_defined():
    assert ch.white_sd(np.ones(500)) == 0.0
    assert ch.white_sd(np.array([1.0])) == 0.0


def test_band_stats_median_matches_the_white_noise_level():
    fs = 300.0
    v = np.random.default_rng(3).normal(0, 10.0, 100_000)
    # A flat spectrum of sd s over a one-sided band of fs/2 sits at this ASD.
    expected = 10.0 / np.sqrt(fs / 2)
    median, _, _, _ = ch.band_stats(v, fs, (3.0, 100.0))
    assert median == pytest.approx(expected, rel=0.05)


def test_band_stats_finds_a_planted_line_and_reports_its_frequency():
    fs, n = 300.0, 100_000
    t = np.arange(n) / fs
    v = np.random.default_rng(4).normal(0, 1.0, n) + 50 * np.cos(2 * np.pi * 40 * t)
    _, _, peak, peak_hz = ch.band_stats(v, fs, (3.0, 100.0))
    assert peak_hz == pytest.approx(40.0, abs=0.5)
    assert peak > 20


def test_band_stats_returns_nan_when_the_band_holds_no_bins():
    """A band above Nyquist has nothing in it, and must say so, not guess."""
    v = np.random.default_rng(5).normal(0, 1.0, 10_000)
    median, rms, peak, peak_hz = ch.band_stats(v, 300.0, (200.0, 250.0))
    assert all(math.isnan(x) for x in (median, rms, peak, peak_hz))


def test_band_for_scales_with_the_rate():
    lo, hi = ch.band_for(300.0)
    assert lo == ch.BAND_LO_HZ
    assert hi == pytest.approx(ch.BAND_NYQUIST_FRACTION * 150.0)


def test_the_band_stays_inside_the_decimation_filter_passband():
    """The constants have to hold this relationship or comparisons are biased.

    A band reaching past the anti-alias corner scores the decimated path partway
    down a rolloff, which reads as a quieter sensor rather than a narrower
    filter.
    """
    assert ch.BAND_NYQUIST_FRACTION / 2 < ch.DECIMATE_CUTOFF_FRACTION < 0.5


# --------------------------------------------------------------------------
# fir_lowpass
# --------------------------------------------------------------------------

def response(h, cycles_per_sample):
    n = np.arange(len(h)) - (len(h) - 1) / 2
    return abs(np.sum(h * np.exp(-2j * np.pi * cycles_per_sample * n)))


def test_fir_lowpass_has_unit_gain_at_dc():
    """Decimating must not rescale the field."""
    h = ch.fir_lowpass(0.1, 257)
    assert h.sum() == pytest.approx(1.0)
    assert response(h, 0.0) == pytest.approx(1.0)


def test_fir_lowpass_passes_below_the_corner_and_stops_above_it():
    h = ch.fir_lowpass(0.1, 513)
    assert response(h, 0.05) == pytest.approx(1.0, abs=0.01)
    assert response(h, 0.09) == pytest.approx(1.0, abs=0.02)
    # Blackman buys a deep stopband; anything near -74 dB or better will do.
    assert 20 * np.log10(response(h, 0.15)) < -60
    assert 20 * np.log10(response(h, 0.30)) < -60


def test_fir_lowpass_is_linear_phase():
    h = ch.fir_lowpass(0.1, 129)
    assert h == pytest.approx(h[::-1])


def test_fir_lowpass_forces_an_odd_length_so_the_delay_is_a_whole_sample():
    assert len(ch.fir_lowpass(0.1, 128)) == 129


@pytest.mark.parametrize("cutoff", [0.0, -0.1, 0.5, 0.6])
def test_fir_lowpass_rejects_a_cutoff_outside_the_open_unit_band(cutoff):
    with pytest.raises(ValueError, match="cutoff"):
        ch.fir_lowpass(cutoff, 129)


def test_fir_lowpass_rejects_a_length_too_short_to_filter():
    with pytest.raises(ValueError, match="too short"):
        ch.fir_lowpass(0.1, 1)


# --------------------------------------------------------------------------
# decimate
# --------------------------------------------------------------------------

def test_boxcar_decimation_divides_white_noise_sd_by_root_k():
    v = np.random.default_rng(6).normal(0, 12.0, 400_000)
    out = ch.decimate(v, 4, "boxcar")
    assert len(out) == 100_000
    assert out.std() == pytest.approx(12.0 / 2, rel=0.02)


def test_decimation_preserves_the_spectral_density_of_white_noise():
    """The claim the whole recommendation rests on: same floor, fewer samples."""
    fs = 300.0
    v = np.random.default_rng(7).normal(0, 12.0, 200_000)
    band = (3.0, 20.0)
    before, _, _, _ = ch.band_stats(v, fs, band)
    for method in ("boxcar", "fir"):
        after, _, _, _ = ch.band_stats(ch.decimate(v, 4, method), fs / 4, band)
        assert after == pytest.approx(before, rel=0.05), method


def test_decimate_by_one_is_a_no_op():
    v = np.random.default_rng(8).normal(0, 1.0, 100)
    assert ch.decimate(v, 1) == pytest.approx(v)


def test_the_fir_removes_an_out_of_band_tone_that_the_boxcar_folds_in():
    """The difference that matters: a boxcar has a poor stopband, so it aliases.

    A tone at 0.3 cycles/sample is above the decimated Nyquist of 0.125 and
    folds to 1/5 of the new rate. The chip's own integration is a boxcar, which
    is exactly why sampling slowly cannot reject what sampling fast and
    filtering can.
    """
    n = 40_000
    tone = 300.0 * np.cos(2 * np.pi * 0.3 * np.arange(n))
    v = np.random.default_rng(9).normal(0, 20.0, n) + tone

    folded = ch.sample_locked_lines(ch.decimate(v, 4, "boxcar"))
    assert any(l.numerator == 1 and l.period == 5 for l in folded)

    filtered = ch.sample_locked_lines(ch.decimate(v, 4, "fir"))
    assert not any(l.numerator == 1 and l.period == 5 for l in filtered)


@pytest.mark.parametrize("k", [0, -2])
def test_decimate_rejects_a_factor_below_one(k):
    with pytest.raises(ValueError, match="at least 1"):
        ch.decimate(np.zeros(100), k)


def test_decimate_rejects_an_unknown_method():
    with pytest.raises(ValueError, match="unknown decimation method"):
        ch.decimate(np.zeros(100), 2, "bilinear")


def test_decimate_refuses_rather_than_returning_filter_transient():
    """Too few samples for the filter is a failure, not a short answer."""
    with pytest.raises(ValueError, match="too few"):
        ch.decimate(np.zeros(200), 4, "fir")


def test_boxcar_refuses_when_there_is_not_even_one_full_group():
    with pytest.raises(ValueError, match="cannot be decimated"):
        ch.decimate(np.zeros(3), 4, "boxcar")


# --------------------------------------------------------------------------
# sample_locked_lines
# --------------------------------------------------------------------------

def test_sample_locked_lines_recovers_a_planted_tone_and_its_amplitude():
    n = 60_000
    v = (np.random.default_rng(10).normal(0, 20.0, n)
         + 3.0 * np.cos(2 * np.pi * 0.25 * np.arange(n)))
    found = ch.sample_locked_lines(v)
    quarter = [l for l in found if (l.numerator, l.period) == (1, 4)]
    assert quarter, "a 3 nT tone under 20 nT of noise should still be found"
    assert quarter[0].amplitude == pytest.approx(3.0, rel=0.15)
    assert quarter[0].sigma > 10


def test_sample_locked_lines_recovers_a_tone_at_nyquist():
    n = 60_000
    v = (np.random.default_rng(11).normal(0, 20.0, n)
         + 2.0 * (-1.0) ** np.arange(n))
    half = [l for l in ch.sample_locked_lines(v)
            if (l.numerator, l.period) == (1, 2)]
    assert half and half[0].amplitude == pytest.approx(2.0, rel=0.15)


@pytest.mark.parametrize("seed", range(6))
def test_sample_locked_lines_stays_silent_on_white_noise(seed):
    v = np.random.default_rng(100 + seed).normal(0, 20.0, 60_000)
    assert ch.sample_locked_lines(v) == []


def test_sample_locked_lines_is_not_fooled_by_drift():
    n = 60_000
    t = np.linspace(0, 1, n)
    v = np.random.default_rng(12).normal(0, 5.0, n) + 4000 * t ** 3 - 900 * t
    assert ch.sample_locked_lines(v) == []


def test_sample_locked_lines_reports_each_frequency_once():
    """A period-4 tone is also period-8 and period-12; those add nothing."""
    n = 60_000
    v = (np.random.default_rng(13).normal(0, 10.0, n)
         + 5.0 * np.cos(2 * np.pi * 0.25 * np.arange(n)))
    fractions = [(l.numerator, l.period) for l in ch.sample_locked_lines(v)]
    assert (2, 8) not in fractions and (3, 12) not in fractions
    assert len(fractions) == len(set(fractions))
    # Every reported fraction is in lowest terms.
    assert all(math.gcd(j, p) == 1 for j, p in fractions)


def test_sample_locked_lines_returns_strongest_first():
    n = 60_000
    index = np.arange(n)
    v = (np.random.default_rng(14).normal(0, 10.0, n)
         + 6.0 * np.cos(2 * np.pi * 0.25 * index)
         + 2.0 * np.cos(2 * np.pi * index / 3))
    found = ch.sample_locked_lines(v)
    assert [l.sigma for l in found] == sorted((l.sigma for l in found),
                                              reverse=True)


def test_sample_locked_lines_abstains_on_a_series_too_short_to_fold():
    assert ch.sample_locked_lines(np.arange(10.0)) == []


def test_sample_locked_lines_abstains_on_a_constant():
    assert ch.sample_locked_lines(np.ones(1000)) == []


# --------------------------------------------------------------------------
# trimmed
# --------------------------------------------------------------------------

def test_trimmed_drops_the_requested_seconds_from_both_ends(tmp_path):
    cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0)   # 30 s
    kept, note = ch.trimmed(cap, 5.0)
    assert kept.duration == pytest.approx(cap.duration - 10.0, abs=0.05)
    assert "trimmed 5 s" in note


def test_trimmed_is_a_no_op_when_no_seconds_are_asked_for(tmp_path):
    cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0)
    kept, note = ch.trimmed(cap, 0.0)
    assert kept is cap and note == ""


def test_trimmed_refuses_rather_than_gutting_a_short_capture(tmp_path):
    """A deliberately short capture is legitimate; silently emptying it is not."""
    cap = make(tmp_path / "c.csv", rows=1000, dt=1 / 100.0)   # 10 s
    kept, note = ch.trimmed(cap, 30.0)
    assert kept is cap
    assert "not trimming" in note


def test_trimmed_rejects_a_negative_window(tmp_path):
    cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0)
    with pytest.raises(capture.CaptureError, match="negative"):
        ch.trimmed(cap, -5.0)


# --------------------------------------------------------------------------
# fit_rate_model
# --------------------------------------------------------------------------

def planted_period(cycle_count, count_rate, overhead):
    return rm3100.AXES * (cycle_count / count_rate + overhead)


def test_fit_rate_model_is_exact_from_two_cycle_counts(tmp_path):
    count_rate, overhead = 88_000.0, 40e-6
    caps = [make(tmp_path / f"{cc}.csv", rows=3000, cycle_count=cc,
                 dt=planted_period(cc, count_rate, overhead))
            for cc in (100, 400)]
    fitted_rate, fitted_overhead = compare.fit_rate_model(caps)
    assert fitted_rate == pytest.approx(count_rate, rel=1e-4)
    assert fitted_overhead == pytest.approx(overhead, rel=1e-3)


def test_fit_rate_model_least_squares_over_three_cycle_counts(tmp_path):
    count_rate, overhead = 92_000.0, 38e-6
    caps = [make(tmp_path / f"{cc}.csv", rows=3000, cycle_count=cc,
                 dt=planted_period(cc, count_rate, overhead))
            for cc in (100, 200, 400)]
    fitted_rate, fitted_overhead = compare.fit_rate_model(caps)
    assert fitted_rate == pytest.approx(count_rate, rel=1e-3)
    assert fitted_overhead == pytest.approx(overhead, rel=1e-2)


def test_fit_rate_model_refuses_one_cycle_count(tmp_path):
    """Two unknowns need two points; guessing one would look like a result."""
    caps = [make(tmp_path / f"{i}.csv", rows=1000, cycle_count=100,
                 dt=1 / 300.0) for i in range(3)]
    with pytest.raises(ValueError, match="two distinct cycle counts"):
        compare.fit_rate_model(caps)


def test_fit_rate_model_rejects_captures_that_do_not_follow_the_model(tmp_path):
    """A higher cycle count that samples faster is not this chip."""
    caps = [make(tmp_path / "a.csv", rows=1000, cycle_count=100, dt=1 / 100.0),
            make(tmp_path / "b.csv", rows=1000, cycle_count=400, dt=1 / 300.0)]
    with pytest.raises(ValueError, match="not positive"):
        compare.fit_rate_model(caps)


# --------------------------------------------------------------------------
# gain_and_movement, axis_ratio_spread
# --------------------------------------------------------------------------

def vector(mean):
    mean = np.asarray(mean, dtype=float)
    return {"mean": mean, "field": float(np.linalg.norm(mean))}


def test_gain_and_movement_reads_a_pure_scale_change_exactly():
    a = vector([-40000, 16000, 12000])
    b = vector(np.array(a["mean"]) * 1.07)
    scale, residual, angle = compare.gain_and_movement(a, b)
    assert scale == pytest.approx(1.07)
    assert residual == pytest.approx(0.0, abs=1e-12)
    assert angle == pytest.approx(0.0, abs=1e-6)


def test_gain_and_movement_reads_a_pure_rotation_as_movement():
    a = vector([40000, 0, 0])
    b = vector([40000 * math.cos(math.radians(20)),
                40000 * math.sin(math.radians(20)), 0])
    scale, residual, angle = compare.gain_and_movement(a, b)
    assert angle == pytest.approx(20.0, abs=1e-6)
    assert residual == pytest.approx(math.sin(math.radians(20)), rel=1e-6)
    assert residual > compare.RATIO_SPREAD_OK


def test_gain_and_movement_survives_an_axis_crossing_zero():
    """The case that defeats a per-axis ratio: Z changes sign between runs."""
    a = vector([-40000, 16000, -6000])
    b = vector([-39000, 16400, +8000])
    scale, residual, angle = compare.gain_and_movement(a, b)
    assert math.isfinite(scale) and math.isfinite(residual)
    assert 0.0 < residual < 1.0
    assert angle > 1.0


def test_axis_ratio_spread_is_zero_for_a_pure_scale():
    a = vector([-40000, 16000, 12000])
    b = vector(np.array(a["mean"]) * 1.07)
    spread = compare.axis_ratio_spread(a, b, b["mean"] / a["mean"])
    assert spread == pytest.approx(0.0, abs=1e-9)


def test_axis_ratio_spread_abstains_when_an_axis_carries_no_field():
    a = vector([40000, 100, 5])
    b = vector([41000, -90, 4])
    assert compare.axis_ratio_spread(a, b, b["mean"] / a["mean"]) is None


# --------------------------------------------------------------------------
# labels, conditions, supplies, bands
# --------------------------------------------------------------------------

def test_split_label_reads_an_explicit_label(tmp_path):
    path = tmp_path / "c.csv"
    path.write_text("x")
    assert compare.split_label(f"LDO/cc100={path}") == ("LDO/cc100", str(path))


def test_split_label_leaves_an_unlabelled_path_alone():
    assert compare.split_label("a.csv") == (None, "a.csv")


def test_split_label_prefers_an_existing_path_containing_an_equals(tmp_path):
    """A filename may contain '='; an existing file wins over a label reading."""
    path = tmp_path / "run=2.csv"
    path.write_text("x")
    assert compare.split_label(str(path)) == (None, str(path))


def test_split_label_rejects_an_empty_label():
    with pytest.raises(ValueError, match="empty label"):
        compare.split_label("=a.csv")


def test_split_label_keeps_the_labelled_reading_when_neither_exists():
    """So the error names the path the user meant, not the whole argument."""
    assert compare.split_label("LDO=missing.csv") == ("LDO", "missing.csv")


def test_condition_and_variant_split_on_the_first_separator():
    assert compare.condition_of("LDO/cc100") == "LDO"
    assert compare.variant_of("LDO/cc100") == "cc100"
    assert compare.condition_of("LDO/a/b") == "LDO"
    assert compare.variant_of("LDO/a/b") == "a/b"


def test_a_label_without_a_separator_is_all_condition():
    assert compare.condition_of("plain.csv") == "plain.csv"
    assert compare.variant_of("plain.csv") == ""


def test_conditions_come_back_in_command_line_order():
    records = [("Zeta/cc100", "", {}), ("Alpha/cc100", "", {}),
               ("Zeta/cc400", "", {})]
    assert compare.conditions_in_order(records) == ["Zeta", "Alpha"]


def test_parse_supply_reads_a_condition_and_volts():
    assert compare.parse_supply("LDO=3.0") == ("LDO", 3.0)


@pytest.mark.parametrize("bad", ["LDO", "=3.0", "LDO=", "LDO=abc",
                                 "LDO=0", "LDO=-3", "LDO=1e9"])
def test_parse_supply_rejects_nonsense(bad):
    with pytest.raises(argparse.ArgumentTypeError):
        compare.parse_supply(bad)


def test_parse_band_reads_a_pair():
    assert compare.parse_band("3,30") == (3.0, 30.0)


@pytest.mark.parametrize("bad", ["3", "3,30,300", "30,3", "-1,30", "0,30",
                                 "3,3", "a,b"])
def test_parse_band_rejects_nonsense(bad):
    with pytest.raises(argparse.ArgumentTypeError):
        compare.parse_band(bad)


# --------------------------------------------------------------------------
# decimation pairing and band selection
# --------------------------------------------------------------------------

def entry(label, fs, cycle_count):
    return (label, {"fs": fs, "cycle_count": cycle_count})


def test_decimation_pairs_finds_an_integer_cycle_count_ratio():
    pairs = compare.decimation_pairs([entry("LDO/cc100", 300.0, 100),
                                      entry("LDO/cc400", 75.0, 400)])
    assert len(pairs) == 1
    condition, (fast_label, _), (slow_label, _), k = pairs[0]
    assert (condition, fast_label, slow_label, k) == ("LDO", "LDO/cc100",
                                                      "LDO/cc400", 4)


def test_decimation_pairs_ignores_a_non_integer_ratio():
    assert compare.decimation_pairs([entry("LDO/a", 300.0, 100),
                                     entry("LDO/b", 75.0, 405)]) == []


def test_decimation_pairs_does_not_cross_conditions():
    """Comparing a decimated LDO run to a native 3V3 one confounds the two."""
    assert compare.decimation_pairs([entry("LDO/cc100", 300.0, 100),
                                     entry("3V3/cc400", 75.0, 400)]) == []


def test_decimation_pairs_ignores_an_equal_cycle_count():
    assert compare.decimation_pairs([entry("LDO/a", 300.0, 100),
                                     entry("LDO/b", 299.0, 100)]) == []


def test_the_band_is_set_by_the_decimated_rate_not_the_slowest_capture(tmp_path):
    """Decimating by 4 lands below the natively-slow rate, and that binds."""
    fast = make(tmp_path / "fast.csv", rows=2000, cycle_count=100, dt=1 / 300.0)
    slow = make(tmp_path / "slow.csv", rows=2000, cycle_count=400, dt=1 / 76.0)
    rates = compare.comparable_rates([("LDO/cc100", fast), ("LDO/cc400", slow)])
    assert min(rates) == pytest.approx(75.0, rel=1e-3)   # 300/4, not 76


# --------------------------------------------------------------------------
# End to end
# --------------------------------------------------------------------------

def four_captures(tmp_path, gain=1.0):
    """An interleaved two-condition, two-cycle-count session, as recorded."""
    paths = {}
    for condition, scale in (("LDO", 1.0), ("3V3", gain)):
        for cc, dt in ((100, 1 / 300.0), (400, 1 / 76.0)):
            mean = tuple(component * scale for component in FIELD)
            path = tmp_path / f"{condition}_{cc}.csv"
            counts = field_counts(4096, mean, cc, 20.0, seed=cc)
            write_capture(path, rows=4096, dt=dt, cycle_count=cc, counts=counts)
            paths[f"{condition}/cc{cc}"] = str(path)
    return paths


def run(monkeypatch, capsys, argv):
    monkeypatch.setattr("sys.argv", ["compare.py"] + argv)
    assert compare.main() == 0
    return capsys.readouterr()


def test_end_to_end_reports_every_section(tmp_path, monkeypatch, capsys):
    paths = four_captures(tmp_path, gain=0.93)
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]
              + ["--supply", "LDO=3.0", "--supply", "3V3=3.3"]).out
    assert "timing model" in out
    assert "filtering and decimating" in out
    assert "matched cycle count" in out
    assert "how far the sensor moved" in out
    # A planted 7% scale change on |B| should come back as one.
    assert "-7.0" in out or "-6.9" in out or "-7.1" in out


def test_the_supply_exponent_recovers_a_planted_power_law(tmp_path, monkeypatch,
                                                          capsys):
    """A gain that is exactly ratiometric must come back as V^-1."""
    volts = {"LDO": 3.0, "3V3": 3.3}
    argv = []
    for condition, v in volts.items():
        for cc, dt in ((100, 1 / 300.0), (400, 1 / 76.0)):
            # Gain proportional to 1/V means the reading scales as 1/V too.
            mean = tuple(c * volts["LDO"] / v for c in FIELD)
            path = tmp_path / f"{condition}_{cc}.csv"
            write_capture(path, rows=4096, dt=dt, cycle_count=cc,
                          counts=field_counts(4096, mean, cc, 5.0, seed=cc))
            argv.append(f"{condition}/cc{cc}={path}")
    argv += ["--supply", "LDO=3.0", "--supply", "3V3=3.3"]
    out = run(monkeypatch, capsys, argv).out
    assert "scales with the rail" in out
    for line in out.splitlines():
        if "|B| ~ V^" in line:
            power = float(line.split("|B| ~ V^")[1].split()[0])
            assert power == pytest.approx(-1.0, abs=0.02)


def test_the_supply_exponent_is_absent_without_rail_voltages(tmp_path,
                                                             monkeypatch,
                                                             capsys):
    paths = four_captures(tmp_path, gain=0.93)
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]).out
    assert "scales with the rail" not in out
    assert "x rail volts" not in out


def test_end_to_end_writes_a_figure(tmp_path, monkeypatch, capsys):
    paths = four_captures(tmp_path)
    output = tmp_path / "figure.png"
    run(monkeypatch, capsys,
        [f"{label}={path}" for label, path in paths.items()]
        + ["-o", str(output)])
    assert output.exists() and output.stat().st_size > 10_000


def test_a_capture_that_will_not_load_is_skipped_not_fatal(tmp_path, monkeypatch,
                                                           capsys):
    paths = four_captures(tmp_path)
    broken = tmp_path / "broken.csv"
    broken.write_text("this is not a capture\n")
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]
              + [f"BAD/x={broken}"])
    assert "skipping" in out.err
    assert "timing model" in out.out


def test_no_loadable_capture_exits_rather_than_printing_nothing(tmp_path,
                                                                monkeypatch):
    broken = tmp_path / "broken.csv"
    broken.write_text("nope\n")
    monkeypatch.setattr("sys.argv", ["compare.py", str(broken)])
    with pytest.raises(SystemExit, match="nothing to compare"):
        compare.main()


def test_a_supply_naming_no_capture_warns(tmp_path, monkeypatch, capsys):
    paths = four_captures(tmp_path)
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]
              + ["--supply", "NOSUCH=3.0"])
    assert "names no capture" in out.err


def test_a_band_past_the_slowest_rolloff_warns(tmp_path, monkeypatch, capsys):
    paths = four_captures(tmp_path)
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]
              + ["--band", "3,60"])
    assert "rolls off" in out.err


def test_trim_is_reported_and_shortens_every_capture(tmp_path, monkeypatch,
                                                     capsys):
    paths = four_captures(tmp_path)
    out = run(monkeypatch, capsys,
              [f"{label}={path}" for label, path in paths.items()]
              + ["--trim", "1"]).out
    assert out.count("trimmed 1 s from each end") == 4


def test_a_single_capture_still_produces_a_table(tmp_path, monkeypatch, capsys):
    paths = four_captures(tmp_path)
    out = run(monkeypatch, capsys, [next(iter(paths.values()))]).out
    assert "capture" in out
    # Nothing to contrast against, so those sections stay quiet.
    assert "matched cycle count" not in out
    assert "how far the sensor moved" not in out
